#!/usr/bin/env python3 # retoor import argparse import asyncio import base64 import codecs import collections import contextlib import difflib import getpass import glob import hashlib import importlib.util import hmac import html import http.client import io import itertools import json import operator import os import queue import random import re import shutil import shlex import signal import socket import sqlite3 import ssl import struct import subprocess import sys import tempfile import textwrap import threading import time import urllib.error import urllib.parse import urllib.request import uuid import weakref import zipfile from datetime import datetime, timedelta, timezone try: import readline except ImportError: readline = None try: import fcntl except ImportError: fcntl = None try: import pty import select import termios import tty except ImportError: pty = select = termios = tty = None # Python 3.6 compatibility notes: # - subprocess 'text' and 'capture_output' kwargs were added in 3.7, so this # module only uses 'universal_newlines' plus explicit PIPEs (works on 3.6+). # - datetime.fromisoformat was added in 3.7, so all parsing goes through # _fromisoformat below (native when available, strptime fallback on 3.6). def _fromisoformat(text): """datetime.fromisoformat backport for Python 3.6 (handles Z and +HH:MM).""" native = getattr(datetime, "fromisoformat", None) if native is not None: raw = str(text or "").strip() if raw[-1:] in ("Z", "z"): raw = raw[:-1] + "+00:00" return native(raw) raw = str(text or "").strip() if not raw: raise ValueError("empty datetime") if raw[-1:] in ("Z", "z"): raw = raw[:-1] + "+00:00" tzinfo = None core = raw match = re.match(r"^(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?)?)([+-]\d{2}:?\d{2}(?::\d{2})?|[+-]\d{4}|[+-]\d{2})$", raw) if match is not None and match.group(2): core, tzpart = match.group(1), match.group(2) sign = 1 if tzpart[0] == "+" else -1 digits = tzpart[1:].replace(":", "") hours = int(digits[:2]) minutes = int(digits[2:4]) if len(digits) >= 4 else 0 tzinfo = timezone(sign * timedelta(hours=hours, minutes=minutes)) for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: parsed = datetime.strptime(core, fmt) except ValueError: continue return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") VERSION = "1.37.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 AGENTS = {} AGENTS_LOCK = threading.Lock() AGENTS_NEXT = [1] MUSE_LABEL = "muse" MUSE_CHAT_URL = "muse://exec" MUSE_MODELS_URL = "muse://models" GROK_LABEL = "grok" GROK_CHAT_URL = "grok://exec" GROK_MODELS_URL = "grok://models" CLAUDE_LABEL = "claude" CLAUDE_CHAT_URL = "claude://exec" CLAUDE_MODELS_URL = "claude://models" CODEX_LABEL = "codex" CODEX_CHAT_URL = "codex://exec" CODEX_MODELS_URL = "codex://models" GEMINI_LABEL = "gemini" GEMINI_CHAT_URL = "gemini://exec" GEMINI_MODELS_URL = "gemini://models" OLLAMA_LABEL = "ollama" OLLAMA_BASE = "http://localhost:11434/v1" OLLAMA_LIST_TTL = 600 CLI_CHAT_URLS = (MUSE_CHAT_URL, GROK_CHAT_URL, CLAUDE_CHAT_URL, CODEX_CHAT_URL, GEMINI_CHAT_URL) MUSE_MAX_STEPS = 1 MUSE_SESSIONS_FILE = "muse_sessions.json" MUSE_LOCKS_DIR = "muse_locks" MUSE_WORKER_TTL = 86400 MUSE_REAP_GRACE = 30 OPENROUTER_LABEL = "openrouter" OPENROUTER_BASE = "https://openrouter.ai/api/v1" OPENROUTER_MODEL = "deepseek/deepseek-v4.1-flash" OPENCODE_LABEL = "opencode" OPENCODE_CHAT_URL = "opencode://run" OPENCODE_MODELS_URL = "opencode://models" OPENCODE_AGENT = "build" OPENCODE_PARSE_MAX_SCANS = 200 DEVPLACE_LABEL = "devplace" DEVPLACE_BASE = "https://devplace.net/openai/v1" DEVPLACE_FREE_ROUTES = ("free", "devii", "molodetz") PREFERRED_MODEL = "molodetz" ZEN_LABEL = "zen" ZEN_BASE = "https://opencode.ai/zen/v1" ZEN_FREE_MODELS = ("mimo-v2.6-flash-free", "nemotron-3.5-lightning-free", "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor-free", "jev-1.13-free", "step-5-preview-free", "space-bunny-free", "longcat-2.5-preview-free", "ling-3.1-flash-free") POLLINATIONS_LABEL = "pollinations" POLLINATIONS_BASE = "https://text.pollinations.ai/openai/v1" POLLINATIONS_MODELS = ("openai-fast",) POLLINATIONS_LIST_URL = "https://text.pollinations.ai/models" PROVIDER_LABELS = (MUSE_LABEL, GROK_LABEL, CLAUDE_LABEL, CODEX_LABEL, GEMINI_LABEL, OPENCODE_LABEL, OLLAMA_LABEL, OPENROUTER_LABEL, POLLINATIONS_LABEL, ZEN_LABEL, DEVPLACE_LABEL) MENTION_AUTO = "auto" MENTION_ROUTES = {"molodetz": (DEVPLACE_LABEL, PREFERRED_MODEL)} DIR_DEFAULTS_CAP = 512 OPENCODE_USER_AGENT = "opencode/1.18.29 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.15" OPENCODE_CLIENT_NAME = "cli" MODEL_HEALTH_FILE = "model_health.json" MODEL_NEUTRAL_REWARD = 0.5 MODEL_WEIGHT_PRIOR = 2.0 MODEL_SPEED_REF_TPS = 25.0 MODEL_LATENCY_REF_MS = 8000.0 MODEL_CIRCUIT_THRESHOLD = 3 MODEL_CIRCUIT_COOLDOWN = 300.0 RETRY_BACKOFF = (2, 5, 10, 20, 30, 60) MODEL_LIST_TIMEOUT = 8 OPENROUTER_FREE_CACHE_TTL = 3600 RSEARCH_BASE = "https://rsearch.app.molodetz.nl" FETCH_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") CURL_HINT = "hint: use the web_fetch tool for HTTP(S) (methods, headers, bodies, status) instead of curl/wget via shell" def curl_hint(command): if re.search(r"\bcurl\b|\bwget\b", command or "") and "http" in (command or "").lower(): return "\n" + CURL_HINT return "" DEFAULT_MODEL = "muse" DEFAULT_VOICE = "en-US-EmmaMultilingualNeural" DEFAULT_PASSPHRASE = "tai-default-insecure-change-me" BOX_IMAGE = "tai-box:latest" BOX_NAME = "tai-box" TELEGRAM_API = "https://api.telegram.org/bot" TELEGRAM_FILE_API = "https://api.telegram.org/file/bot" SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") BASHRC_MARK_BEGIN = "# tai command-not-found hook - start" BASHRC_MARK_END = "# tai command-not-found hook - end" BASHRC_BLOCK = ( BASHRC_MARK_BEGIN + "\n" "alias tai=\"$HOME/.local/bin/tai.py\"\n" "command_not_found_handle() {\n" " \"$HOME/.local/bin/tai.py\" \"$@\"\n" " return $?\n" "}\n" + BASHRC_MARK_END + "\n" ) ZSH_NOTFOUND_BLOCK = ( BASHRC_MARK_BEGIN + "\n" "alias tai=\"$HOME/.local/bin/tai.py\"\n" "command_not_found_handler() {\n" " \"$HOME/.local/bin/tai.py\" \"$@\"\n" " return $?\n" "}\n" + BASHRC_MARK_END + "\n" ) FISH_NOTFOUND_BLOCK = ( BASHRC_MARK_BEGIN + "\n" "function tai\n" " \"$HOME/.local/bin/tai.py\" $argv\n" "end\n" "function fish_command_not_found\n" " \"$HOME/.local/bin/tai.py\" $argv\n" "end\n" + BASHRC_MARK_END + "\n" ) BOX_CONTAINERFILE = """FROM python:3.12-slim ENV DEBIAN_FRONTEND=noninteractive PIP_NO_CACHE_DIR=1 RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg espeak-ng python3-venv && rm -rf /var/lib/apt/lists/* RUN python -m venv /box/venv && /box/venv/bin/pip install --no-cache-dir faster-whisper edge-tts RUN /box/venv/bin/python -c "from faster_whisper import WhisperModel; WhisperModel('tiny', device='cpu', compute_type='int8')" COPY stt.py /box/stt.py COPY tts.py /box/tts.py CMD ["sleep", "infinity"] """ BOX_PYTHON = "/box/venv/bin/python" _BOX_PYTHON_CACHE = {} BOX_STT = """import sys from faster_whisper import WhisperModel data = sys.stdin.buffer.read() with open("/tmp/in.audio", "wb") as handle: handle.write(data) model = WhisperModel("tiny", device="cpu", compute_type="int8") segments, _info = model.transcribe("/tmp/in.audio") print(" ".join(segment.text for segment in segments).strip(), flush=True) """ BOX_TTS = """import asyncio import sys import edge_tts async def speak(text, voice): talk = edge_tts.Communicate(text, voice or "en-US-EmmaMultilingualNeural") await talk.save("/tmp/out.mp3") text = sys.stdin.read().strip() voice = sys.argv[1] if len(sys.argv) > 1 else "" asyncio.run(speak(text, voice)) sys.stdout.buffer.write(open("/tmp/out.mp3", "rb").read()) """ TELEGRAM_UNIT = """[Unit] Description=tai telegram bot After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory=%%h ExecStart=%s --telegram EnvironmentFile=%s Restart=on-failure RestartSec=10 [Install] WantedBy=default.target """ SCHEDULER_UNIT = """[Unit] Description=tai scheduler After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory=%%h ExecStart=%s --scheduler Restart=on-failure RestartSec=10 [Install] WantedBy=default.target """ INSTALL_TARGETS = ("binary", "bash-hook", "zsh-hook", "fish-hook", "plexer-hook", "zsh-plexer-hook", "fish-plexer-hook", "venv", "scheduler-service", "telegram-service", "container") CONTEXT_CAP = 32000 COMPACT_RATIO = 0.8 KEEP_TURNS = 6 MAX_STEPS = 25 class Consts: """Live-patched tunables: TOTAL_STEP_CAP bounds Agent.run_turn, SYSINFO_TIMEOUT bounds sysinfo probes.""" TOTAL_STEP_CAP = 500 SYSINFO_TIMEOUT = 10 LOOP_NUDGE_AT = 3 LOOP_STOP_AT = 5 SYSTEM_MAX_CHARS = 12000 SHELL_TIMEOUT = 120 SCHEDULER_INTERVAL = 30 HTTP_TIMEOUT = 60 STREAM_TIMEOUT = 300 EDGE_HOST = "speech.platform.bing.com" EDGE_WS_PATH = "/consumer/speech/synthesize/readaloud/edge/v1" EDGE_TRUSTED_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4" EDGE_CHROMIUM = "143.0.3650.75" PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$") BOT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") BOT_MENTION_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9_-]{0,31})\s+(.*)$", re.DOTALL) LEADING_TAG_RE = re.compile(r"@(\S+)\s*") FILE_MENTION_RE = re.compile(r"(?= cap: break return found SECRET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") LEGACY_PASSWORD_NOTE = "especially passwords (collect and keep every password the user shares), preferences, or standing behavior changes" VAULT_MEMORY_NOTE = "preferences, or standing behavior changes. Store passwords, tokens, and secrets with store_secret and reference them by name; never write secret values into memory" SAFE_COMMANDS = ("ls", "pwd", "echo", "cat", "head", "tail", "grep", "find", "wc", "sort", "uniq", "diff", "file", "stat", "date", "whoami", "uname", "lsb_release") RECORDERS = (("arecord", ("arecord", "-q", "-d", "{seconds}", "-f", "cd", "-t", "wav", "{path}")), ("rec", ("rec", "-q", "{path}", "trim", "0", "{seconds}")), ("ffmpeg", ("ffmpeg", "-y", "-v", "quiet", "-f", "alsa", "-i", "default", "-t", "{seconds}", "{path}"))) TRANSCRIBERS = ("whisper-cpp", "whisper-cli", "whisper", "faster-whisper") SYSINFO_BINARIES = ("tmux", "git", "ffmpeg", "arecord", "sox", "whisper-cpp", "whisper", "systemctl", "podman", "docker", "muse", "grok", "opencode", "claude", "codex", "gemini", "ollama") DEFAULT_SYSTEM = ( "You are tai, a professional autonomous assistant. You act with tools, verify results, and report concisely. " "Rules: prefer the smallest change that solves the task; never silently ignore errors; call ask_user when requirements are ambiguous (never end your turn with open questions in prose: one-shot runs exit and prose questions are never answered); " "keep answers short and factual. Tools: shell and file tools for the local machine, web_search and web_fetch for the internet, " "web_fetch handles every HTTP method with headers and bodies so never curl or wget through shell, " "speak for voice output, recall to search past session memory. Memory: call remember whenever you learn durable facts, " "preferences, or standing behavior changes. Store passwords, tokens, and secrets with store_secret and reference them by name; " "never write secret values into memory. Name and value suffice for secrets; never pester the user for more. Call remember with a forget instruction to drop outdated knowledge. Delegation: do quick, interactive, or memory-changing work yourself; fork background subagents for independent, long, or context-heavy work and collect them with poll; schedule future work instead of waiting on it. " "Records: save big or durable findings with record_save and tag generously; large tool outputs spill to mem: ids automatically, page them with record_read, connect related nodes with graph_link. " "Tags: strict rules, lowercase singular only, shortest common word for the subject, one canonical tag per subject, at most 5 per item, always reuse existing tags from the tags tool instead of inventing synonyms. Content words matching known tags attach automatically, and new records link to recent same-tag records. " "Lazy tools: only everyday tools load by default; name any other tool or topic in your reply text and it becomes callable on your next step. " "Bots: one identity holds many bots; a bot is only a system message plus its own history, all vault data is shared. @name routes one turn to another bot and files the exchange in both histories; /bot switches, create_bot makes bots. " "Terminal: the user's question is very likely about what is visible in their terminal, especially right after a terminal capture or when launched with a single question straight from a shell; resolve vague references (this, that, it, the error) against that content, capture it with get_current_terminal_content instead of asking, and answer from what you see. " "Retrieval: use search first for anything stored: one ranked full-text query across records, events, and audit plus graph expansion beats paging and guessing. Chain tool calls until the task is done. " "Never narrate a planned action in text without emitting its tool call: text without tool calls ends your turn and the action will not happen." ) MERGE_SYSTEM = ( "You maintain an AI assistant system prompt. You receive the current system prompt and one memory instruction. " "Rewrite the system prompt to incorporate the instruction: add new facts, update changed behavior, or remove forgotten items. " "Never persist passwords, tokens, or other secret values; if the instruction holds one, note only that a secret exists and must be stored with store_secret. " "Preserve everything unrelated. Keep it organized with short sections. Output ONLY the rewritten system prompt, no explanation, no code fences." ) COMPACT_SYSTEM = ( "You compress assistant session history into a dense handoff summary. Cover: goal, completed work with outcomes, current state, " "key decisions, files touched, errors and fixes, pending next steps. Output only the summary, no preamble." ) AUTONOMOUS_NOTE = ( "Autonomous mode: never ask the user anything this session (the ask_user tool refuses, do not call it). When requirements are unclear, blocked, or need input, " "research deeply with web_search, web_fetch, shell, and subagents, then decide and proceed. State assumptions briefly in your reply." ) HELP_TEXT = ( "commands:\n" " /profile [name] show current profile or switch to it\n" " /profiles list all profiles, current marked with *\n" " /bots list bots, current marked with *\n" " /bot switch bot, history resumes\n" " /env [home|sandbox] show or switch execution environment\n" " /ctx show shell session tree and recent sessions\n" " /skills list loaded skills with provenance\n" " /skills install ... install an existing skill (copy or symlink)\n" " /secret manage sealed secrets (set|list|delete)\n" " /sysinfo show host environment checks\n" " /models show model roster with speed health\n" " /schedule at|every schedule a prompt for later\n" " /schedules list scheduled prompts\n" " /unschedule delete a scheduled prompt\n" " /records [query] search saved records\n" " /record read one record page\n" " /graph show node neighborhood\n" " /tags [prefix] list tags with usage counts\n" " /tools [name] show core and lazy tools\n" " /install ... install status, install, upgrade, reinstall\n" " /search ranked search over everything\n" " /audit [path] show file audit trail\n" " /restore restore a file from an audit row\n" " /release part msg bump version, back up, log message\n" " /fork spawn a background subagent, REPL stays free\n" " /agents list background subagents\n" " /agent show one result, or purge finished agents\n" " /compact compress history into a summary\n" " /clear drop history, keep system message\n" " /demo fullscreen TUI framework showcase\n" " /help this text\n" " /quit exit\n" " @bot @provider @model route this turn and save dir defaults (@auto clears pins, @path references a file)\n" "anything else is sent to the agent." ) class Ansi: RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" UNDERLINE = "\033[4m" STRIKE = "\033[9m" RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" BLUE = "\033[34m" MAGENTA = "\033[35m" CYAN = "\033[36m" GRAY = "\033[90m" def paint(text, *codes): if os.environ.get("NO_COLOR") or not sys.stdout.isatty(): return text return "".join(codes) + text + Ansi.RESET def now_iso(): return datetime.now(timezone.utc).isoformat(timespec="seconds") def short_error(exc): text = str(exc) or type(exc).__name__ return text[:300] def truncate(text, head=1500, tail=500): if len(text) <= head + tail + 50: return text skipped = len(text) - head - tail return text[:head] + "\n...[%d chars truncated]...\n" % skipped + text[-tail:] SPILL_LIMIT = 6000 RECORD_KINDS = ("note", "output", "file", "research", "transcript", "termlog") AUDIT_MAX_CHARS = 20000 FILE_RECORD_MAX = 50000 SHELL_SNAPSHOT_MAX_FILES = 25 BACKUP_KEEP = 10 SHELL_HISTORY_LINES = 50 SHELL_HISTORY_CHARS = 4000 SHELL_HISTORY_READ_BYTES = 32768 TERMINAL_LIST_MAX_PANES = 50 TAIL_TERMINALS_DEFAULT_LINES = 3 TAIL_TERMINALS_MAX_LINES = 10 TAIL_TERMINALS_MAX_PANES = 20 TAIL_PER_PANE_CHARS = 800 _UNSET = object() def capped_text(text): if text is None: return None, None if len(text) <= AUDIT_MAX_CHARS: return text, len(text.encode("utf-8")) return text[:AUDIT_MAX_CHARS] + "\n...[truncated, full size %d bytes]..." % len(text.encode("utf-8")), len(text.encode("utf-8")) def trunc_marker(true_bytes): return "\n...[truncated, full size %d bytes]..." % true_bytes def read_capped(path, cap=AUDIT_MAX_CHARS): try: size = os.path.getsize(path) except OSError: return None, None try: with open(path, "r", encoding="utf-8", errors="replace") as handle: text = handle.read(cap + 1) except OSError: return None, None if len(text) > cap: return text[:cap], size return text, None SHELL_SEGMENT = (";", "&&", "||", "|", "&", "(", ")") SHELL_WRAPPERS = ("sudo", "command", "env", "nice", "nohup", "setsid") def shell_target_paths(command, workdir): spaced = re.sub(r"([;&|()<>])", r" \1 ", command) try: tokens = shlex.split(spaced, posix=True) except ValueError: return [] found = [] segment = [] def flush(): found.extend(segment_targets(segment)) for token in tokens: if token in SHELL_SEGMENT: flush() segment = [] else: segment.append(token) flush() resolved = [] for item in found: if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break base = item if os.path.isabs(item) else os.path.join(workdir, item) if any(mark in item for mark in ("*", "?", "[")): try: hits = sorted(glob.glob(base, recursive=True)) except (OSError, ValueError): continue else: hits = [base] for hit in hits: if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break if os.path.isfile(hit): resolved.append(os.path.abspath(hit)) elif os.path.isdir(hit): for root, _dirs, files in os.walk(hit): for entry in sorted(files): if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break full = os.path.join(root, entry) if os.path.isfile(full): resolved.append(os.path.abspath(full)) if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break seen = [] for item in resolved: if item not in seen: seen.append(item) return seen def segment_targets(tokens): targets = [] pos = 0 while pos < len(tokens): token = tokens[pos] if "=" in token and not token.startswith((">", "<", "-", "/")): pos += 1 elif token in SHELL_WRAPPERS: pos += 1 elif token == "timeout" and pos + 1 < len(tokens) and re.fullmatch(r"[0-9.]+[smhd]?", tokens[pos + 1] or ""): pos += 2 else: break rest = tokens[pos:] for index, token in enumerate(rest): if token in (">", ">>") and index + 1 < len(rest): cand = rest[index + 1] if cand and not cand.startswith("&") and not cand.startswith("/dev/"): targets.append(cand) if not rest: return targets name = os.path.basename(rest[0]) operands = [item for item in rest[1:] if not item.startswith("-") or item in ("-", "--")] operands = [item for item in operands if item not in (">", ">>", "<", "--")] if name == "rm": targets.extend(operands) elif name in ("mv", "cp") and operands: targets.append(operands[-1]) elif name == "tee": targets.extend(operands) elif name == "truncate": for item in operands: if re.fullmatch(r"[0-9]+[KMGTPE]?[iB]?", item or ""): continue targets.append(item) elif name == "dd": for item in operands: if item.startswith("of="): targets.append(item[3:]) elif name == "shred": targets.extend(operands) return [item for item in targets if item and item != "-"] def spill_output(store, kind, title, text, tags=(), profile=None): profile = profile if profile is not None else store.profile record_id = store.add_record(kind, title, text, tags, profile) store.add_chunks("record", record_id, chunk_lines(text), profile) return record_id def spilled_view(text, record_id, size, head=1500, tail=500): return text[:head] + "\n...[%d chars spilled to %s, record_read pages the rest]...\n" % (size, record_id) + text[-tail:] def spill_or_truncate(store, kind, title, text, tags=(), profile=None): if store is None or len(text) <= SPILL_LIMIT: return truncate(text) record_id = spill_output(store, kind, title, text, tags, profile) return spilled_view(text, record_id, len(text)) CHUNK_MAX_LINES = 60 CHUNK_OVERLAP_LINES = 3 CHUNK_MAX_CHARS = 4000 def chunk_lines(text, max_lines=CHUNK_MAX_LINES, overlap_lines=CHUNK_OVERLAP_LINES, max_chars=CHUNK_MAX_CHARS): """Generic RAG chunker: bounded windows of whole lines with small overlap, never splitting mid-line. Used for any content that gets indexed for retrieval (terminal captures, long records, ...), so there is one chunking rule in the codebase instead of one per content type. """ lines = text.splitlines() n = len(lines) if n == 0: return [] chunks = [] start = 0 while start < n: end = start size = 0 count = 0 while end < n and count < max_lines and size < max_chars: size += len(lines[end]) + 1 count += 1 end += 1 piece = "\n".join(lines[start:end]).strip() if piece: chunks.append(piece) if end >= n: break start = max(start + 1, end - overlap_lines) return chunks def collapse_repeated_lines(text, threshold=3): """Collapses runs of 3+ identical lines (progress bars, retry spam) to one line + a count note.""" lines = text.splitlines() out = [] i = 0 n = len(lines) while i < n: j = i while j < n and lines[j] == lines[i]: j += 1 run = j - i if run >= threshold: out.append(lines[i]) out.append("... (repeated %d more times) ..." % (run - 1)) else: out.extend(lines[i:j]) i = j return "\n".join(out) def tmux_pane_header(): try: info = subprocess.run(["tmux", "display-message", "-p", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError): return "" if info.returncode == 0 and info.stdout.strip(): return info.stdout.strip() return "" ZSH_HISTORY_RE = re.compile(r"^: \d+:\d+;(.*)$") def shell_history_paths(): """Shell history files for terminal context, most specific first.""" paths = [] histfile = os.environ.get("HISTFILE") if histfile: paths.append(histfile) home = os.path.expanduser("~") for cand in (os.path.join(home, ".bash_history"), os.path.join(home, ".zsh_history"), os.path.join(home, ".local/share/fish/fish_history")): if cand not in paths: paths.append(cand) return paths def read_history_file(path): """Tail one shell history file, oldest command first. Never raises.""" try: size = os.path.getsize(path) except OSError: return [] try: with open(path, "rb") as handle: if size > SHELL_HISTORY_READ_BYTES: handle.seek(size - SHELL_HISTORY_READ_BYTES) handle.readline() raw = handle.read() except OSError: return [] fish = os.path.basename(path) == "fish_history" entries = [] for line in raw.decode("utf-8", errors="replace").splitlines(): stripped = line.strip() if not stripped: continue if fish: if not stripped.startswith("- cmd: "): continue stripped = stripped[len("- cmd: "):].strip() if stripped: entries.append(stripped) continue match = ZSH_HISTORY_RE.match(stripped) if match: stripped = match.group(1).strip() if not stripped: continue entries.append(stripped) return entries def shell_history_section(): """Capped recent-commands block for terminal context, or '' when none.""" merged = [] for path in shell_history_paths(): merged.extend(read_history_file(path)) entries = [] for line in merged: if not entries or line != entries[-1]: entries.append(line) entries = entries[-SHELL_HISTORY_LINES:] if SHELL_HISTORY_LINES > 0 else [] text = "\n".join(entries) if len(text) > SHELL_HISTORY_CHARS: text = text[-SHELL_HISTORY_CHARS:] if "\n" in text: text = text.split("\n", 1)[1] if not text.strip(): return "" return "\n\n## Recent shell history\n(oldest first, newest last)\n```\n%s\n```" % text def tmux_panes(): """Every pane in every tmux session. Returns (panes, error).""" if not shutil.which("tmux"): return [], "tmux not available" fmt = "#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_id}\t#{pane_active}\t#{pane_current_command}\t#{pane_current_path}" try: done = subprocess.run(["tmux", "list-panes", "-a", "-F", fmt], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError) as exc: return [], "terminal list failed: " + short_error(exc) if done.returncode != 0: err = (done.stderr or "").strip()[:200] if "no server running" in err: return [], "no open terminals (tmux server not running)" return [], "terminal list failed: " + err panes = [] for line in done.stdout.splitlines(): parts = line.split("\t") if len(parts) != 7: continue session, window, index, pane_id, active, command, path = parts panes.append({"ref": "%s:%s.%s" % (session, window, index), "id": pane_id, "active": active == "1", "command": command or "?", "path": path or "?"}) return panes, "" VERSION_RE = re.compile(r"^VERSION = \"(\d+)\.(\d+)\.(\d+)\"$", re.MULTILINE) def next_version(current, part): major, minor, patch = (int(item) for item in current.split(".")) if part == "major": return "%d.0.0" % (major + 1) if part == "minor": return "%d.%d.0" % (major, minor + 1) if part == "patch": return "%d.%d.%d" % (major, minor, patch + 1) raise ValueError("part must be major, minor, or patch") def atomic_write(path, content): tmp = "%s.tmp-%d" % (path, os.getpid()) with open(tmp, "w", encoding="utf-8") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(tmp, path) def sealed_write(path, content): atomic_write(path, content) try: os.chmod(path, 0o600) except OSError: pass def quarantine(path): try: if os.path.exists(path): os.replace(path, "%s.corrupt-%d" % (path, int(time.time()))) except OSError: pass def release_bump(script_file, part): with open(script_file, "r", encoding="utf-8") as handle: text = handle.read() match = VERSION_RE.search(text) if match is None: raise ValueError("no VERSION line in %s" % script_file) old = ".".join(match.groups()) new = next_version(old, part) atomic_write(script_file, text[:match.start()] + "VERSION = \"%s\"" % new + text[match.end():]) return old, new def backups_dir(home): return os.path.join(home, "backups") def prune_backups(folder): try: names = sorted((entry for entry in os.listdir(folder) if entry.startswith("tai-") and entry.endswith(".py")), key=lambda entry: os.path.getmtime(os.path.join(folder, entry))) except OSError: return 0 pruned = 0 while len(names) > BACKUP_KEEP: try: os.remove(os.path.join(folder, names.pop(0))) pruned += 1 except OSError: break return pruned def snapshot_self(script_file, folder, version): with open(script_file, "rb") as handle: data = handle.read() digest = hashlib.sha256(data).hexdigest()[:8] stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") name = "tai-%s-%s-%s.py" % (version, stamp, digest) os.makedirs(folder, exist_ok=True) dest = os.path.join(folder, name) with open(dest, "wb") as handle: handle.write(data) prune_backups(folder) return dest, digest def backup_has_digest(folder, digest): try: return any(entry.endswith("-%s.py" % digest) for entry in os.listdir(folder)) except OSError: return False def ensure_self_backup(config, store, script_file=None, actor="main"): script_file = script_file or os.path.abspath(__file__) folder = backups_dir(config.home) with open(script_file, "rb") as handle: digest = hashlib.sha256(handle.read()).hexdigest()[:8] if backup_has_digest(folder, digest): return None dest, _digest = snapshot_self(script_file, folder, VERSION) try: store.audit_event(actor, "snapshot", script_file, "backed up as %s" % os.path.basename(dest), tags=["snapshot", "v" + VERSION]) text, override = read_capped(script_file, FILE_RECORD_MAX) if text is not None: store.upsert_file_record(script_file, text, override) except (sqlite3.Error, OSError): pass return dest def do_release(store, script_file, folder, part, message, actor="main"): if not message or not message.strip(): raise ValueError("release message is required") old, new = release_bump(script_file, part) dest, _digest = snapshot_self(script_file, folder, new) row_id = store.audit_event(actor, "release", script_file, message.strip()[:500], old=old, new=new, tags=["release", "v" + new]) return old, new, dest, row_id def estimate_tokens(text): return max(1, len(text or "") // 4) def as_int(value, default=0): try: return int(value) except (TypeError, ValueError): return default def tool_name_clean(raw_name): # Some backends leak harmony control tokens into the name ("shell<|channel|>commentary"). return str(raw_name or "").split("<|", 1)[0].strip() TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$") PERMANENT_STATUSES = (400, 401, 402, 403, 404, 405, 410, 413, 422) def clean_history(messages): """Copy of messages that strict backends accept: tool calls with an invalid name are dropped together with their results, and an assistant turn left empty goes.""" dropped = set() kept = [] for message in messages: calls = message.get("tool_calls") if message.get("role") == "assistant" else None if calls: good = [call for call in calls if TOOL_NAME_RE.match(str((call.get("function") or {}).get("name") or ""))] dropped.update(call.get("id") for call in calls if call not in good) if len(good) != len(calls): message = dict(message, tool_calls=good or None) if not good and not message.get("content"): continue fixed = [dict(call, function=dict(call["function"], arguments=tool_args_valid(call["function"].get("arguments")))) if isinstance(call.get("function"), dict) and tool_args_valid(call["function"].get("arguments")) != call["function"].get("arguments") else call for call in (message.get("tool_calls") or [])] if fixed: message = dict(message, tool_calls=fixed) kept.append(message) return [message for message in kept if not (message.get("role") == "tool" and message.get("tool_call_id") in dropped)] def transient_failure(exc): """True when asking again later can succeed: timeouts, rate limits, 5xx, network.""" if getattr(exc, "status", 0) in PERMANENT_STATUSES: return False return "CLI not found" not in str(exc) def tool_args_valid(arguments): """Arguments text strict backends accept: empty or non-JSON becomes an empty object.""" try: json.loads(arguments) except (TypeError, ValueError): return "{}" return arguments def tool_args_text(raw_arguments): if not raw_arguments: return "{}" if isinstance(raw_arguments, str): return raw_arguments try: return json.dumps(raw_arguments, default=str) except (TypeError, ValueError): return str(raw_arguments) def action_signature(name, raw_arguments): try: flat = json.dumps(json.loads(tool_args_text(raw_arguments)), sort_keys=True, default=str) except ValueError: flat = str(raw_arguments or "") return "%s %s" % (name, flat[:500]) def result_digest(result): return hashlib.sha1((result or "").encode("utf-8", "replace")).hexdigest() class Spinner: FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") def __init__(self, label): self.label = label self.stop_flag = threading.Event() self.worker = None def start(self): if not sys.stderr.isatty(): return self.worker = threading.Thread(target=self.spin, daemon=True) self.worker.start() def spin(self): pos = 0 started = time.time() glow = Glow.stderr_enabled() widest = len(self.label) + 2 while not self.stop_flag.is_set(): frame = self.FRAMES[pos % len(self.FRAMES)] if glow: clock = "· %.1fs" % (time.time() - started) sys.stderr.write("\r%s%s%s %s %s%s%s" % (Ansi.BOLD, Ansi.CYAN, frame, Glow.shimmer(self.label, pos), Ansi.DIM, clock, Ansi.RESET)) widest = max(widest, len(self.label) + len(clock) + 3) else: sys.stderr.write("\r%s %s" % (frame, self.label)) sys.stderr.flush() pos += 1 time.sleep(0.08) sys.stderr.write("\r%s\r" % (" " * widest)) sys.stderr.flush() def stop(self): self.stop_flag.set() if self.worker is not None: self.worker.join(timeout=1) STREAM_HEIGHT = 4 RUN_OUTPUT_CAP = 4000000 RUN_PROMPT_IDLE = 1.0 RUN_MAX_ASKS = 20 RUN_ANSWER_CAP = 1000 RUN_ROWS = 24 RUN_COLS = 120 RUN_CTRL_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") RUN_PROMPT_RE = re.compile(r"(?:[:?>\]\)$#%]|\(y/n\)|\[y/n\])\s*$", re.I) def human_size(num): num = max(0, int(num)) if num < 1024: return "%d B" % num if num < 1048576: return "%.1f KB" % (num / 1024) return "%.1f MB" % (num / 1048576) def stream_status(code, output, elapsed, timed_out): detail = "%d lines · %s" % (len(output.splitlines()), human_size(len(output.encode("utf-8", "replace")))) if timed_out: return "timed out after %ds (killed) · %s" % (SHELL_TIMEOUT, detail) return "exit %d · %.1fs · %s" % (code, elapsed, detail) def truncate_ansi(text, width): plain = ANSI_RE.sub("", text) if len(plain) <= width: return text out = [] count = 0 pos = 0 for match in ANSI_RE.finditer(text): chunk = text[pos:match.start()] take = min(len(chunk), width - count) out.append(chunk[:take]) count += take if count >= width: pos = match.start() break out.append(match.group(0)) pos = match.end() if count < width: out.append(ANSI_RE.sub("", text[pos:])[:width - count]) return "".join(out) + Ansi.RESET class LiveStream: def __init__(self, height=STREAM_HEIGHT, width=0, file=None, enabled=True): self.height = max(1, height) self.width = width or shutil.get_terminal_size().columns self.file = file if file is not None else sys.stdout self.enabled = enabled self.lines = collections.deque(maxlen=self.height) self.shown = 0 def feed(self, line): if not self.enabled: return cleaned = truncate_ansi(line.split("\r")[-1].rstrip("\n").expandtabs(8), max(8, self.width - 2)) self.lines.append(cleaned) if self.shown < self.height: self.file.write("│ %s\n" % cleaned) self.shown += 1 else: self.file.write("\033[%dA" % self.height) for kept in self.lines: self.file.write("\r\033[K│ %s\n" % kept) self.file.flush() def pause(self): """The terminal is about to be used for a question: leave the window where it is and start a fresh one below whatever gets printed.""" self.lines.clear() self.shown = 0 def close(self, status): if not self.enabled: return if self.shown == 0: self.file.write("│ (no output)\n") self.file.write("│ %s\n" % truncate_ansi(status, max(8, self.width - 2))) self.file.flush() class PtyProc: """A child on its own pty with the pty as controlling terminal, so ssh, sudo and friends can prompt exactly as in a real terminal. One reader (select), one writer; the caller owns the loop.""" @staticmethod def available(): return pty is not None and termios is not None and fcntl is not None and select is not None def __init__(self, argv, shell, cwd, env): master, slave = pty.openpty() try: fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", RUN_ROWS, RUN_COLS, 0, 0)) def adopt(): os.setsid() fcntl.ioctl(0, termios.TIOCSCTTY, 0) self.proc = subprocess.Popen(argv, shell=shell, cwd=cwd, env=env, stdin=slave, stdout=slave, stderr=slave, preexec_fn=adopt, close_fds=True) except BaseException: os.close(master) raise finally: os.close(slave) self.master = master def read(self, timeout): """bytes, b"" when nothing arrived in time, None at end of output.""" if not select.select([self.master], [], [], max(0.0, timeout))[0]: return b"" try: return os.read(self.master, 65536) or None except OSError: return None def write(self, text): data = text.encode("utf-8", "replace") while data: sent = os.write(self.master, data) data = data[sent:] def echo_off(self): try: return not termios.tcgetattr(self.master)[3] & termios.ECHO except termios.error: return False def kill(self): try: os.killpg(self.proc.pid, signal.SIGKILL) except (OSError, ProcessLookupError): self.proc.kill() def close(self): try: os.close(self.master) except OSError: pass def run_live_pty(argv, timeout, shell, cwd, env, stream, ask): """run_live on a pty. When the command sits on a prompt (partial last line that looks like one, or echo switched off for a password) ask(text, secret) gets the question; its answer is typed into the command. None declines: the command gets end-of-file. Time spent waiting for the user does not count against the timeout.""" started = time.time() env = dict(env if env is not None else os.environ) env.setdefault("PAGER", "cat") env.setdefault("GIT_PAGER", "cat") term = PtyProc(argv, shell, cwd, env) decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") chunks = [] size = 0 partial = "" asked = "" asks = 0 declined = False timed_out = False deadline = started + timeout last_output = time.time() def emit(raw): nonlocal size line = RUN_CTRL_RE.sub("", raw.rstrip("\r").split("\r")[-1]) if size < RUN_OUTPUT_CAP: chunks.append(line + "\n") size += len(line) + 1 if stream is not None: stream.feed(line + "\n") def take(data): nonlocal partial partial += decoder.decode(data) while "\n" in partial: line, partial = partial.split("\n", 1) emit(line) try: while True: remaining = deadline - time.time() if remaining <= 0: timed_out = True break data = term.read(min(0.05, remaining)) if data is None: break if data: take(data) last_output = time.time() continue if term.proc.poll() is not None: while True: data = term.read(0) if not data: break take(data) break text = RUN_CTRL_RE.sub("", partial).strip() if declined or asks >= RUN_MAX_ASKS or not text or text == asked or time.time() - last_output < RUN_PROMPT_IDLE: continue secret = term.echo_off() if not secret and (RUN_PROMPT_RE.search(text) is None or "\r" in partial): continue asked = text asks += 1 if stream is not None and hasattr(stream, "pause"): stream.pause() waited = time.time() answer = ask(text, secret) deadline += time.time() - waited last_output = time.time() if answer is None: declined = True term.write("\x04") note = "» %s (no answer)" % text else: answer = answer.replace("\r", "").split("\n")[0][:RUN_ANSWER_CAP] term.write(answer + "\n") note = "» %s %s" % (text, "(hidden)" if secret else answer) if stream is not None: stream.feed(note + "\n") except BaseException: term.kill() term.proc.wait() raise finally: if timed_out: term.kill() try: term.proc.wait(timeout=10) except subprocess.TimeoutExpired: term.kill() term.proc.wait() term.close() if partial.strip(): emit(partial) return term.proc.returncode, "".join(chunks), time.time() - started, timed_out def run_live(argv, timeout=SHELL_TIMEOUT, shell=False, cwd=None, env=None, stream=None, ask=None): if ask is not None and PtyProc.available(): return run_live_pty(argv, timeout, shell, cwd, env, stream, ask) started = time.time() proc = subprocess.Popen(argv, shell=shell, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, errors="replace", bufsize=1) chunks = [] pending = queue.Queue() def pump(): try: for line in proc.stdout: chunks.append(line) pending.put(line) finally: pending.put(None) reader = threading.Thread(target=pump, daemon=True) reader.start() timed_out = False try: deadline = started + timeout while True: remaining = deadline - time.time() if remaining <= 0: timed_out = True break try: item = pending.get(timeout=min(0.05, remaining)) except queue.Empty: continue if item is None: break if stream is not None: stream.feed(item) except BaseException: proc.kill() proc.wait() raise finally: if timed_out: proc.kill() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() proc.wait() reader.join(timeout=10) if not timed_out: while not pending.empty(): item = pending.get_nowait() if item is None: break if stream is not None: stream.feed(item) return proc.returncode, "".join(chunks), time.time() - started, timed_out class LaneBoard: """Live lane block for poll waits: ticking elapsed, pulsing dots. Prints K lines once, then rewrites them in place at FPS until stop() leaves a steady final frame. Rows only flip running to finished mid-wait; if the row count ever changes the board degrades to fresh static lines instead of mis-drawing over scrollback. """ FPS = 3 PULSE = (Ansi.DIM, "", Ansi.BOLD, "") def __init__(self, profile, width=0, cap=6, file=None): self.profile = profile self.width = width or Glow.width() self.cap = cap self.file = file self.stop_flag = threading.Event() self.worker = None self.painted = 0 @staticmethod def available(app): return not getattr(app, "quiet", False) and Glow.enabled() def rows(self, pulse=""): return Glow.lanes(list_agents(self.profile), self.cap, self.width, pulse) def _paint(self, pulse): out = self.file if self.file is not None else sys.stdout rows = self.rows(pulse) if not rows: return if self.painted and self.painted != len(rows): for row in rows: out.write("%s\n" % row) out.flush() self.painted = len(rows) return if self.painted: out.write("\033[%dA" % self.painted) for row in rows: out.write("\r\033[K%s\n" % row) out.flush() self.painted = len(rows) def _tick(self): frame = 1 while not self.stop_flag.is_set(): time.sleep(1.0 / self.FPS) if self.stop_flag.is_set(): break try: self._paint(self.PULSE[frame % len(self.PULSE)]) except Exception: break frame += 1 def start(self): if self.worker is not None: return self._paint("") self.worker = threading.Thread(target=self._tick, daemon=True) self.worker.start() def stop(self): self.stop_flag.set() if self.worker is not None: self.worker.join(timeout=2) self.worker = None self._paint("") DIFF_CONTEXT = 3 DIFF_MAX_LINES = 120 BG_ADD = "\033[48;5;236m" BG_DEL = "\033[48;5;236m" FG_ADD = "\033[38;5;222m" FG_DEL = "\033[38;5;203m" FG_FILE = "\033[38;5;111m" FG_BRIGHT = "\033[97m" FG_YOLO = "\033[38;5;211m" FG_ORANGE = "\033[38;5;214m" FG_RUN = "\033[38;5;80m" FG_FORK = "\033[38;5;135m" PY_KEYWORDS = ("False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", "match", "case") PY_TOKEN_RE = re.compile(r"(?P\"\"\"(?:\\.|[^\\])*?\"\"\"|'''(?:\\.|[^\\])*?'''|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*')|(?P#[^\n]*)|(?P\b\d[\d._]*(?:[eE][+-]?\d+)?[jJ]?\b)|(?P\b(?:%s)\b)|(?P@[A-Za-z_][\w.]*)|(?P(?<=def )[A-Za-z_]\w*|(?<=class )[A-Za-z_]\w*)" % "|".join(PY_KEYWORDS)) def color_enabled(): return not os.environ.get("NO_COLOR") and sys.stdout.isatty() def highlight_python(line): parts = [] pos = 0 for match in PY_TOKEN_RE.finditer(line): parts.append(line[pos:match.start()]) kind = match.lastgroup text = match.group(0) if kind == "string": parts.append(Ansi.YELLOW + text + Ansi.RESET) elif kind == "comment": parts.append(Ansi.GRAY + text + Ansi.RESET) elif kind == "number": parts.append(FG_ORANGE + text + Ansi.RESET) elif kind == "keyword": parts.append(Ansi.MAGENTA + text + Ansi.RESET) else: parts.append(Ansi.CYAN + text + Ansi.RESET) pos = match.end() parts.append(line[pos:]) return "".join(parts) def split_diff_lines(text): if not text: return [] lines = text.split("\n") if lines and lines[-1] == "": lines.pop() return lines def render_diff(path, old, new, width=0, colors=True): if new is not None and (old or "") == new: return "" old_lines = split_diff_lines(old or "") new_lines = split_diff_lines(new) if new is not None else [] if not width: try: width = shutil.get_terminal_size().columns except OSError: width = 80 matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False) rows = [] for group in matcher.get_grouped_opcodes(DIFF_CONTEXT): if rows: rows.append(("gap", 0, 0, "")) for tag, i1, i2, j1, j2 in group: if tag == "equal": for offset in range(i2 - i1): rows.append(("ctx", i1 + offset + 1, j1 + offset + 1, old_lines[i1 + offset])) elif tag == "delete": for offset in range(i2 - i1): rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset])) elif tag == "insert": for offset in range(j2 - j1): rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset])) else: for offset in range(i2 - i1): rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset])) for offset in range(j2 - j1): rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset])) added = sum(1 for kind, _a, _b, _t in rows if kind == "add") removed = sum(1 for kind, _a, _b, _t in rows if kind == "del") if new is None: verb, meta = "Deleted", "(deleted, %d lines)" % len(old_lines) elif not old_lines: verb, meta = "Created", "(new file, %d lines)" % len(new_lines) else: verb, meta = "Edited", "(+%d -%d)" % (added, removed) python = str(path).lower().endswith(".py") num_width = max(4, len(str(max(len(old_lines), len(new_lines), 1)))) code_width = max(20, width - num_width - 4) fence = None out = [] if colors: out.append(Glow.event_row("◆", verb, str(path), meta, width)) else: out.append("│ ── %s %s" % (path, meta)) for kind, old_no, new_no, text in rows[:DIFF_MAX_LINES]: if kind == "gap": out.append(Ansi.DIM + "···" + Ansi.RESET if colors else "···") continue code = strip_ansi(text).expandtabs(8) if fence is not None: if python and colors: code = Ansi.YELLOW + code + Ansi.RESET if fence in text: fence = None elif python and colors: for mark in ('"""', "'''"): if text.count(mark) % 2 == 1: fence = mark code = Ansi.YELLOW + code + Ansi.RESET break else: code = highlight_python(code) number = old_no if kind in ("ctx", "del") else new_no gutter = str(number).rjust(num_width) if not colors: sign = {"ctx": " ", "del": "-", "add": "+"}[kind] out.append("%s %s %s" % (gutter, sign, code[:code_width])) continue dimmed = Ansi.DIM + gutter + Ansi.RESET if kind == "ctx": out.append("%s %s" % (dimmed, truncate_ansi(code, code_width))) elif kind == "del": row = "%s %s %s" % (dimmed, Ansi.RED + Ansi.BOLD + "-" + Ansi.RESET, truncate_ansi(code, code_width)) out.append(pad_tinted(style_span(row, BG_DEL, FG_DEL), width)) else: row = "%s %s %s" % (dimmed, FG_ADD + Ansi.BOLD + "+" + Ansi.RESET, truncate_ansi(code, code_width)) out.append(pad_tinted(style_span(row, BG_ADD, FG_ADD), width)) if len(rows) > DIFF_MAX_LINES: note = "··· %d more lines hidden" % (len(rows) - DIFF_MAX_LINES) out.append(Ansi.DIM + note + Ansi.RESET if colors else note) return "\n".join(out) def pad_tinted(line, width): plain = len(ANSI_RE.sub("", line)) if plain < width: fill = " " * (width - plain) if line.endswith(Ansi.RESET): return line[:-len(Ansi.RESET)] + fill + Ansi.RESET return line + fill return line ANSI_RE = re.compile(r"\033\[[0-9;]*m") MD_BULLETS = ("•", "◦", "▪") def strip_ansi(text): return ANSI_RE.sub("", text) def style_text(text, *codes): return "".join(codes) + text + Ansi.RESET def style_span(text, *codes): prefix = "".join(codes) return prefix + text.replace(Ansi.RESET, Ansi.RESET + prefix) + Ansi.RESET def wrap_ansi(text, width, first_prefix="", next_prefix=""): parts = re.findall(r"\033\[[0-9;]*m|[^\s\033]+|\s+", text) lines = [] current = first_prefix length = len(strip_ansi(first_prefix)) base = length active = "" for part in parts: if not part: continue if part.startswith("\033"): current += part if part == Ansi.RESET: active = "" else: active += part continue if part.isspace(): if length < width and length > base: current += " " length += 1 continue if length + len(part) > width and length > base: if active: current += Ansi.RESET lines.append(current.rstrip()) current = next_prefix + active length = len(strip_ansi(next_prefix)) base = length current += part length += len(part) if active: current += Ansi.RESET if current.strip(): lines.append(current.rstrip()) return lines def md_inline(text): stashed = [] def stash(value): stashed.append(value) return "\x00%d\x00" % (len(stashed) - 1) text = re.sub(r"`([^`\n]+)`", lambda match: stash(style_text(match.group(1), Ansi.CYAN)), text) text = re.sub(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])", lambda match: stash(match.group(1)), text) text = re.sub(r"!\[([^\]\n]*)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: stash("[image: %s]" % (match.group(1) or match.group(2))), text) text = re.sub(r"\[([^\]\n]+)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: style_span(md_inline(match.group(1)), Ansi.BLUE, Ansi.UNDERLINE) + style_text(" (%s)" % match.group(2), Ansi.DIM), text) text = re.sub(r"\*\*([^*\n]+)\*\*", lambda match: style_text(match.group(1), Ansi.BOLD), text) text = re.sub(r"(?", " \n", text, flags=re.IGNORECASE) raw = text.split("\n") out = [] pos = 0 total = len(raw) while pos < total: line = raw[pos] stripped = line.strip() if not stripped: pos += 1 continue fence = re.match(r"^\s*(`{3,}|~{3,})\s*\S*\s*$", line) if fence: char = fence.group(1)[0] pos += 1 while pos < total: closer = raw[pos].strip() if closer and set(closer) == {char} and len(closer) >= 3: pos += 1 break out.append(" " + style_text(raw[pos], Ansi.DIM) if raw[pos].strip() else "") pos += 1 out.append("") continue if "|" in line and pos + 1 < total and md_is_separator(raw[pos + 1]): header = md_split_row(line) aligns = [] for cell in md_split_row(raw[pos + 1]): if cell.startswith(":") and cell.endswith(":") and len(cell) > 2: aligns.append("center") elif cell.endswith(":"): aligns.append("right") else: aligns.append("left") pos += 2 rows = [] while pos < total and "|" in raw[pos] and raw[pos].strip(): rows.append(md_split_row(raw[pos])) pos += 1 while len(aligns) < max([len(header)] + [len(row) for row in rows]): aligns.append("left") out.extend(md_table_block(header, aligns, rows)) out.append("") continue heading = re.match(r"^(#{1,6})\s+(.*\S)\s*$", stripped) if heading: level = len(heading.group(1)) body = md_inline(heading.group(2)) if level == 1: out.append(style_span(body, Ansi.BOLD, Ansi.CYAN)) elif level == 2: out.append(style_span(body, Ansi.BOLD)) else: out.append(style_span(body, Ansi.BOLD, Ansi.DIM)) out.append("") pos += 1 continue if re.fullmatch(r"\*{3,}|-{3,}|_{3,}", stripped.replace(" ", "")): out.append(style_text("─" * width, Ansi.DIM)) out.append("") pos += 1 continue if stripped.startswith(">"): paras = [] current = [] while pos < total and raw[pos].strip().startswith(">"): inner = re.sub(r"^\s*(?:>\s?)+", "", raw[pos]) if inner.strip(): current.append(inner.strip()) elif current: paras.append(current) current = [] pos += 1 if current: paras.append(current) for index, para in enumerate(paras): if index: out.append(style_text("│", Ansi.DIM)) prefix = style_text("│ ", Ansi.DIM) for wrapped in wrap_ansi(md_inline(" ".join(para)), width, prefix, prefix): out.append(wrapped) out.append("") continue bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", line) numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", line) if bullet or numbered: counters = {} while pos < total: again_bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", raw[pos]) again_numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", raw[pos]) if not again_bullet and not again_numbered: break if again_bullet: level = len(again_bullet.group(1)) // 2 marker = MD_BULLETS[level % len(MD_BULLETS)] body = again_bullet.group(2) check = re.match(r"^\[([ xX])\]\s+(.*\S)\s*$", body) if check: marker = "☑" if check.group(1).lower() == "x" else "☐" body = check.group(2) else: level = len(again_numbered.group(1)) // 2 counters[level] = counters.get(level, 0) + 1 for deeper in [key for key in counters if key > level]: del counters[deeper] marker = "%d." % counters[level] body = again_numbered.group(2) pad = " " * level for wrapped in wrap_ansi(md_inline(body), width, pad + marker + " ", pad + " " * (len(marker) + 1)): out.append(wrapped) pos += 1 out.append("") continue para = [] while pos < total and raw[pos].strip(): para.append(raw[pos]) pos += 1 segments = [] current = [] for chunk in para: current.append(chunk.strip()) if chunk.endswith(" "): segments.append(" ".join(current)) current = [] if current: segments.append(" ".join(current)) for segment in segments: for wrapped in wrap_ansi(md_inline(segment), width): out.append(wrapped) out.append("") while out and not out[-1]: out.pop() return "\n".join(out) class Glow: """Screenshot-style CLI event renderer: bold verbs, blue targets, dim meta. Every styling method always emits codes; call sites gate on enabled() so pipes, NO_COLOR, and tests keep the plain format byte for byte. """ @staticmethod def enabled(): return color_enabled() @staticmethod def stderr_enabled(): return not os.environ.get("NO_COLOR") and sys.stderr.isatty() @staticmethod def width(fallback=80): try: return shutil.get_terminal_size().columns except OSError: return fallback @staticmethod def accent_for(name): """Kind color for the card bar and open icon. Small surface on purpose.""" if name == "shell": return FG_RUN if name == "fork": return FG_FORK if name == "poll": return FG_FILE if name in ("write_file", "edit_file", "delete_file"): return Ansi.GREEN if name == "ask_user": return FG_ORANGE if name in ("store_secret", "list_secrets", "delete_secret"): return FG_YOLO return Ansi.DIM @staticmethod def bar(name): return "%s▌%s " % (Glow.accent_for(name), Ansi.RESET) @staticmethod def event_row(icon, verb, target, meta="", width=0, icon_color=""): row = "%s%s%s%s %s%s%s" % (Ansi.BOLD, icon_color, icon, Ansi.RESET, Ansi.BOLD, verb, Ansi.RESET) if target: row += " %s%s%s" % (FG_FILE, target, Ansi.RESET) if meta: row += " %s%s%s" % (Ansi.DIM, meta, Ansi.RESET) return truncate_ansi(row, width) if width else row @staticmethod def preview_row(text, width=0): row = "%s│ %s%s" % (Ansi.DIM, text, Ansi.RESET) return truncate_ansi(row, width) if width else row @staticmethod def icon_for(name): """Small open-row icon set: execute, spawn, poll, everything else.""" if name == "shell": return "▶" if name == "fork": return "✦" if name == "poll": return "⟳" return "◇" @staticmethod def ellipsis(text, cap=60, head=False): """Truncate long targets: middle cut by default, head cut for sentences whose discriminating words sit at the front (agent tasks).""" if len(text or "") <= cap or cap < 8: return text if head: return text[:cap - 1] + "…" keep = cap - 1 front = keep // 2 return text[:front] + "…" + text[len(text) - (keep - front):] @staticmethod def done_row(verb, target, chars, elapsed_ms, failed=False, width=0): elapsed = "%.1fs" % (elapsed_ms / 1000.0) if elapsed_ms >= 1000 else "%dms" % elapsed_ms mark = Ansi.RED + "✗" + Ansi.RESET if failed else Ansi.GREEN + "✓" + Ansi.RESET tail = "%s· %s · %d chars%s" % (Ansi.DIM, elapsed, chars, Ansi.RESET) left = "%s◆%s %s%s%s" % (Ansi.BOLD, Ansi.RESET, Ansi.BOLD, verb, Ansi.RESET) if target: left += " %s%s%s" % (FG_FILE, target, Ansi.RESET) if width: plain_left, plain_right = len(strip_ansi(left)), len(strip_ansi(mark + " " + tail)) if plain_left + 2 + plain_right <= width: return left + " " * (width - plain_left - plain_right) + mark + " " + tail return truncate_ansi(left + " " + Ansi.DIM + "·" + Ansi.RESET + " " + mark + " " + tail, width) return left + " " + Ansi.DIM + "·" + Ansi.RESET + " " + mark + " " + tail @staticmethod def hairline(width=0): width = width or Glow.width() return "%s%s%s" % (Ansi.DIM, "─" * max(1, width), Ansi.RESET) @staticmethod def shimmer(text, pos, span=3): """Traveling bright window over dim text: the live-line light effect.""" if not text: return "" cycle = len(text) + span * 2 center = (pos % cycle) - span out = [] for pos_, char in enumerate(text): dist = abs(pos_ - center) if dist == 0: out.append(Ansi.BOLD + FG_BRIGHT + char) elif dist <= span: out.append(Ansi.BOLD + char) else: out.append(Ansi.DIM + char) return "".join(out) + Ansi.RESET @staticmethod def describe_call(name, raw_arguments): """Map a tool call to (verb, target, extra, preview) for event rows.""" try: args = raw_arguments if isinstance(raw_arguments, dict) else json.loads(raw_arguments or "{}") parsed = True except (TypeError, ValueError): args = {} parsed = False if not isinstance(args, dict): args = {} parsed = False if name == "store_secret" and "value" in args: args = dict(args) args["value"] = "[hidden]" def head(key, cap=80): value = args.get(key) if value is None: return "" return " ".join(str(value).split())[:cap] def base(key): value = head(key) return os.path.basename(value.rstrip("/")) or value path = base("path") or base("file") command = head("command") query = head("query") url = head("url") prompt = head("prompt", 60) task = head("task", 60) who = head("name") ident = head("id") or head("target") or head("agent_id") when = head("at") or ("every %s" % head("every") if head("every") else "") table = { "shell": ("Run", command, ""), "read_file": ("Read", path, ""), "write_file": ("Write", path, ""), "edit_file": ("Edit", path, ""), "delete_file": ("Delete", path, ""), "web_search": ("Search", query, ""), "web_fetch": ("Fetch", url, ""), "describe_image": ("See", path or head("image"), ""), "speak": ("Speak", head("text", 60), ""), "listen": ("Listen", head("seconds"), ""), "remember": ("Remember", head("instruction", 60), ""), "recall": ("Recall", query, ""), "load_skill": ("Skill", who, ""), "create_skill": ("Skill", who, ""), "install_skill": ("Install", who or head("source"), ""), "get_current_terminal_content": ("Capture", "terminal", ""), "list_terminals": ("Terminals", "", ""), "tail_terminals": ("Tail", "terminals", ""), "export_terminal_log": ("Export", "terminal log", ""), "plexer_send": ("Send", ident, ""), "plexer_read": ("Read", ident, ""), "plexer_type": ("Type", ident, ""), "plexer_status": ("Plexer", "", ""), "tui_screen": ("TUI", "screen", ""), "tui_keys": ("TUI", "keys", ""), "tui_type": ("TUI", "type", ""), "tui_click": ("TUI", "click", ""), "tui_goto": ("TUI", "goto", ""), "tui_result": ("TUI", "result", ""), "fork": ("Fork", task, ""), "poll": ("Poll", ident, ""), "ask_user": ("Ask", head("question", 60), ""), "sysinfo": ("Probe", "", ""), "store_secret": ("Seal", who, ""), "list_secrets": ("Seal", "list", ""), "delete_secret": ("Delete", who, ""), "schedule": ("Schedule", prompt, when), "unschedule": ("Unschedule", ident, ""), "schedules": ("Schedules", "", ""), "record_save": ("Save", head("title"), ""), "record_read": ("Read", ident, ""), "record_search": ("Search", query, ""), "record_delete": ("Delete", ident, ""), "graph_link": ("Link", "", ""), "graph_query": ("Graph", head("node"), ""), "audit": ("Audit", path, ""), "restore": ("Restore", ident, ""), "release": ("Release", head("part"), head("message", 60)), "tags": ("Tags", head("prefix"), ""), "install": ("Install", head("component"), ""), "create_bot": ("Bot", who, ""), "search": ("Search", query, ""), } primary = {"shell": "command", "read_file": "path", "write_file": "path", "edit_file": "path", "delete_file": "path", "web_search": "query", "web_fetch": "url", "describe_image": "path", "speak": "text", "remember": "instruction", "recall": "query", "load_skill": "name", "create_skill": "name", "install_skill": "source", "fork": "task", "ask_user": "question", "delete_secret": "name", "schedule": "prompt", "unschedule": "id", "record_save": "title", "record_read": "id", "record_search": "query", "record_delete": "id", "graph_query": "node", "audit": "path", "restore": "id", "release": "part", "tags": "prefix", "install": "component", "create_bot": "name", "search": "query"} if name not in table: return name, "", "", " ".join(tool_args_text(raw_arguments).split()) verb, target, extra = table[name] bits = ([extra] if extra else []) + [name] if name == "poll" and ident: preview = "agent %s" % ident elif name in primary: preview = head(primary[name], 200) else: preview = "" if not preview: preview = " ".join(tool_args_text(args if parsed else raw_arguments).split()) return verb, Glow.ellipsis(target, 60), " · ".join(bits), preview @staticmethod def statusbar(version, profile, directory, yolo=False, agents=0, width=0): home = os.path.expanduser("~") short = directory if directory == home: short = "~" elif home and directory.startswith(home + os.sep): short = "~" + directory[len(home):] segs = [ "%s%stai %s%s" % (Ansi.BOLD, FG_FILE, version, Ansi.RESET), "%s%s%s" % (FG_FILE, profile, Ansi.RESET), "%s%s%s" % (Ansi.DIM, short, Ansi.RESET), ] if yolo: segs.append("%s%sYOLO%s" % (Ansi.BOLD, FG_YOLO, Ansi.RESET)) if agents: segs.append("%s+%d%s" % (Ansi.YELLOW, agents, Ansi.RESET)) dot = "%s·%s" % (Ansi.DIM, Ansi.RESET) width = width or Glow.width() flow = dot.join(" %s " % seg for seg in segs).strip() if (yolo or agents) and len(segs) > 3: left = dot.join(" %s " % seg for seg in segs[:3]).strip() right = dot.join(" %s " % seg for seg in segs[3:]).strip() gap = width - len(strip_ansi(left)) - len(strip_ansi(right)) if gap >= 4: return left + " " * gap + right flow = dot.join(" %s " % seg for seg in [segs[0], segs[1]] + segs[3:]).strip() if len(strip_ansi(flow)) <= width: return flow core = truncate_ansi(flow, width) return re.sub(r"(?:\033\[[0-9;]*m|[ ·])+$", "", core) + Ansi.RESET @staticmethod def prompt_text(): return "%s%s› %s" % (Ansi.BOLD, FG_ORANGE, Ansi.RESET) @staticmethod def agent_row(record, width=0, pulse="", suffix=""): dots = {"running": Ansi.YELLOW, "done": Ansi.GREEN, "error": Ansi.RED, "timeout": FG_ORANGE} dot = dots.get(record.get("status"), Ansi.DIM) task = Glow.ellipsis(" ".join(str(record.get("task") or "").split()), 50, head=True) row = " #%d %s%s●%s %s %s· %s · %s%s" % (record.get("id"), pulse, dot, Ansi.RESET, task, Ansi.DIM, record.get("status"), format_delay(record.get("elapsed") or 0), Ansi.RESET) if record.get("tools") == 0 and record.get("status") != "running": row += " %s· 0 tools%s" % (FG_ORANGE, Ansi.RESET) if suffix: row += suffix return truncate_ansi(row, width) if width else row @staticmethod def lanes(records, cap=6, width=0, pulse=""): running = [record for record in records if record["status"] == "running"] pending = [record for record in records if record["status"] in ("done", "timeout", "error") and not record.get("collected")] view = running + pending head = "◇ lanes · %d running" % len(running) if pending: head += " · %d to collect" % len(pending) rows = [truncate_ansi("%s%s%s" % (Ansi.DIM, head, Ansi.RESET), width) if width else "%s%s%s" % (Ansi.DIM, head, Ansi.RESET)] for record in view[:cap]: suffix = " %s· to collect%s" % (Ansi.DIM, Ansi.RESET) if record in pending else "" rows.append(Glow.agent_row(record, width, pulse if record["status"] == "running" else "", suffix)) if len(view) > cap: rows.append("%s · +%d more%s" % (Ansi.DIM, len(view) - cap, Ansi.RESET)) return rows class SealError(Exception): pass class Seal: PREFIX = "tai1$" ROUNDS = 200000 def __init__(self, home, passphrase): self.enabled = bool(passphrase) self.default_key = False self.master = b"" self.enc_key = b"" self.mac_key = b"" if self.enabled: self.master = self.load_master(home, passphrase) self.enc_key = hmac.new(self.master, b"tai-enc-1", hashlib.sha256).digest() self.mac_key = hmac.new(self.master, b"tai-mac-1", hashlib.sha256).digest() def load_master(self, home, passphrase): path = os.path.join(home, ".seal") raw = "" if os.path.exists(path): with open(path, "r", encoding="utf-8") as handle: raw = handle.read() saved = {} if raw: try: saved = json.loads(raw) salt = base64.b64decode(saved["salt"]) except (ValueError, KeyError): raise SealError("seal file is corrupt") else: salt = os.urandom(16) master = hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, self.ROUNDS, 32) check = hmac.new(master, b"tai-check-1", hashlib.sha256).hexdigest() if raw: if not hmac.compare_digest(saved.get("check", ""), check): raise SealError("wrong TAI_PASSPHRASE") return master with open(path, "w", encoding="utf-8") as handle: handle.write(json.dumps({"salt": base64.b64encode(salt).decode("ascii"), "check": check})) try: os.chmod(path, 0o600) except OSError: pass return master def stream(self, nonce, length): out = bytearray() counter = 0 while len(out) < length: out += hmac.new(self.enc_key, nonce + struct.pack(">I", counter), hashlib.sha256).digest() counter += 1 return bytes(out[:length]) def lock(self, text): if not self.enabled or text.startswith(self.PREFIX): return text nonce = os.urandom(16) body = text.encode("utf-8") keystream = self.stream(nonce, len(body)) cipher = bytes(piece ^ keystream[pos] for pos, piece in enumerate(body)) mac = hmac.new(self.mac_key, nonce + cipher, hashlib.sha256).digest() parts = [base64.b64encode(blob).decode("ascii") for blob in (nonce, cipher, mac)] return self.PREFIX + "$".join(parts) def unlock(self, text): if not text.startswith(self.PREFIX): raise SealError("not sealed") try: _tag, nonce64, cipher64, mac64 = text.split("$", 3) nonce = base64.b64decode(nonce64) cipher = base64.b64decode(cipher64) mac = base64.b64decode(mac64) except ValueError: raise SealError("sealed value is corrupt") expect = hmac.new(self.mac_key, nonce + cipher, hashlib.sha256).digest() if not hmac.compare_digest(mac, expect): raise SealError("seal check failed") keystream = self.stream(nonce, len(cipher)) plain = bytes(piece ^ keystream[pos] for pos, piece in enumerate(cipher)) return plain.decode("utf-8") def safe_unlock(self, text): try: return self.unlock(text) except SealError: return "[sealed: decrypt failed]" def resolve_passphrase(): raw = os.environ.get("TAI_PASSPHRASE") if raw is None: return DEFAULT_PASSPHRASE, True return raw, False def rotate_seal(config, old_seal, new_passphrase): db = sqlite3.connect(config.db_path) rows = db.execute("SELECT id, text FROM events").fetchall() tables = [row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()] secret_cols = [row[1] for row in db.execute("PRAGMA table_info(secrets)").fetchall()] if "secrets" in tables else [] has_profile = "profile" in secret_cols secret_rows = db.execute("SELECT profile, name, value FROM secrets").fetchall() if has_profile else [(None, name, value) for name, value in db.execute("SELECT name, value FROM secrets").fetchall()] if "secrets" in tables else [] meta_rows = db.execute("SELECT profile, name, meta FROM secrets WHERE meta IS NOT NULL").fetchall() if has_profile and "meta" in secret_cols else [(None, name, meta) for name, meta in db.execute("SELECT name, meta FROM secrets WHERE meta IS NOT NULL").fetchall()] if "meta" in secret_cols else [] planned_rows = db.execute("SELECT id, prompt FROM schedules").fetchall() if "schedules" in tables else [] db.close() texts = {} for row_id, text in rows: texts[row_id] = old_seal.unlock(text) if text.startswith(Seal.PREFIX) else text secret_texts = {} for profile, name, value in secret_rows: secret_texts[(profile, name)] = old_seal.unlock(value) if value.startswith(Seal.PREFIX) else value secret_metas = {} for profile, name, meta in meta_rows: secret_metas[(profile, name)] = old_seal.unlock(meta) if meta.startswith(Seal.PREFIX) else meta planned_texts = {} for row_id, prompt in planned_rows: planned_texts[row_id] = old_seal.unlock(prompt) if prompt.startswith(Seal.PREFIX) else prompt files = {} for entry in os.listdir(config.profiles_dir): if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(config.profiles_dir, entry) with open(path, "r", encoding="utf-8") as handle: raw = handle.read() files[path] = old_seal.unlock(raw) if raw.startswith(Seal.PREFIX) else raw try: os.remove(os.path.join(config.home, ".seal")) except OSError: pass fresh = Seal(config.home, new_passphrase) db = sqlite3.connect(config.db_path) for row_id, plain in texts.items(): db.execute("UPDATE events SET text = ? WHERE id = ?", (fresh.lock(plain), row_id)) for (profile, name), plain in secret_texts.items(): if profile is None: db.execute("UPDATE secrets SET value = ? WHERE name = ?", (fresh.lock(plain), name)) else: db.execute("UPDATE secrets SET value = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) for (profile, name), plain in secret_metas.items(): if profile is None: db.execute("UPDATE secrets SET meta = ? WHERE name = ?", (fresh.lock(plain), name)) else: db.execute("UPDATE secrets SET meta = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) for row_id, plain in planned_texts.items(): db.execute("UPDATE schedules SET prompt = ? WHERE id = ?", (fresh.lock(plain), row_id)) db.commit() db.close() for path, plain in files.items(): with open(path, "w", encoding="utf-8") as handle: handle.write(fresh.lock(plain)) return fresh def parse_skill_front(lines): front = {} pos = 0 while pos < len(lines): line = lines[pos] if ":" in line and not line.startswith((" ", "\t")): key, value = line.split(":", 1) value = value.strip() if value in (">", "|"): joined = [] pos += 1 while pos < len(lines) and lines[pos].startswith((" ", "\t")): joined.append(lines[pos].strip()) pos += 1 front[key.strip()] = (" " if value == ">" else "\n").join(joined) continue front[key.strip()] = value.strip("'\"") pos += 1 return front def parse_skill_file(path): with open(path, "r", encoding="utf-8") as handle: raw = handle.read() if not raw.startswith("---"): return None end = raw.find("\n---", 3) if end < 0: return None front = parse_skill_front(raw[3:end].strip().splitlines()) name = front.get("name", "") description = front.get("description", "") if not name or not description or not SKILL_NAME_RE.match(name): return None return {"name": name, "description": description, "body": raw[end + 4:].lstrip("\n")} EXTERNAL_SKILL_DIRNAMES = (os.path.join(".claude", "skills"), os.path.join(".agents", "skills")) SKILL_INSTALL_META_DIRNAME = ".installs" def skill_scope_dir(scope, home_dir, project_dir, name=""): """Shared by create_skill and install_skill so both land skills in the same place.""" base = os.path.join(project_dir, ".tai", "skills") if scope == "project" else os.path.join(home_dir, "skills") return os.path.join(base, name) if name else base def default_skill_scope(project_dir): for marker in (".git", ".tai") + EXTERNAL_SKILL_DIRNAMES: if os.path.exists(os.path.join(project_dir, marker)): return "project" return "home" def skill_extra_files(root): extras = [] for sub in ("scripts", "references", "assets"): folder = os.path.join(root, sub) if os.path.isdir(folder): for entry in sorted(os.listdir(folder)): extras.append(os.path.join(root, sub, entry)) return extras def skill_install_meta_path(scope_dir, entry): return os.path.join(scope_dir, SKILL_INSTALL_META_DIRNAME, entry + ".json") def load_skill_install_meta(scope_dir, entry): try: with open(skill_install_meta_path(scope_dir, entry), "r", encoding="utf-8") as handle: return json.load(handle) except (OSError, ValueError): return None def save_skill_install_meta(scope_dir, entry, meta): path = skill_install_meta_path(scope_dir, entry) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(meta, handle) def is_external_skill_root(path): return os.path.dirname(os.path.normpath(path)).endswith(EXTERNAL_SKILL_DIRNAMES) def skill_provenance_note(skill): install = skill.get("install") if install: return "[installed %s, %s scope, from %s]" % (install.get("mode", "copy"), install.get("scope", "?"), install.get("source", "?")) if not skill.get("managed", True): return "[external, not managed by tai: %s]" % skill["root"] return "[native]" def discover_skills(home_dir, project_dir): found = {} tai_roots = (os.path.join(home_dir, "skills"), os.path.join(project_dir, ".tai", "skills")) external_roots = (os.path.join(os.path.expanduser("~"), ".claude", "skills"),) + tuple(os.path.join(project_dir, dirname) for dirname in EXTERNAL_SKILL_DIRNAMES) # External roots scan first so tai's own dirs always win on a name collision (dict assignment below overwrites). for base in external_roots + tai_roots: if not os.path.isdir(base): continue managed = base in tai_roots for entry in sorted(os.listdir(base)): path = os.path.join(base, entry, "SKILL.md") if not os.path.isfile(path): continue try: skill = parse_skill_file(path) except OSError: continue if skill is None: continue skill["root"] = os.path.join(base, entry) skill["managed"] = managed skill["install"] = load_skill_install_meta(base, entry) if managed else None found[skill["name"]] = skill return found SKILL_BLUEPRINTS = { "bot-creator": { "hint": "build a dedicated chat bot from a tai.py variant", "scope": "project", "brief": "Deep-research preferred chat-bot features first: transport (Telegram long-poll versus webhook), command routing, per-user allowlists, rate limits, and safe restarts. Also research safe techniques for scripting huge single-file programs: exact-match edits, atomic temp-plus-rename writes, and versioned backups before every reshape. Then copy tai.py to a variant path and reshape the copy into a dedicated bot, editing efficiently and keeping the audit trail intact.", }, "api-client": { "hint": "build a resilient REST/OpenAPI client", "scope": "project", "brief": "Deep-research resilient HTTP client design first: retries with backoff, timeouts, pagination, auth header handling, and error classification. Then write a skill that builds small stdlib-only API clients from a base URL and an endpoint list.", }, "web-researcher": { "hint": "deep research with triangulated sources", "scope": "project", "brief": "Deep-research professional open-source research methodology first: query expansion, source triangulation, recency checks, and claim grading. Then write a skill that turns a question into a sourced brief with confidence levels.", }, "pdf-forms": { "hint": "fill PDF forms and extract field data", "scope": "project", "brief": "Deep-research PDF form handling with freely available tooling first: AcroForm field discovery, filling, flattening, and text extraction. Then write a skill that fills forms and extracts field data from this machine's installed tools.", }, "data-wrangler": { "hint": "reshape CSV, JSON, and SQLite data", "scope": "project", "brief": "Deep-research tabular data reshaping first: CSV dialects, JSON normalization, SQLite import ergonomics, and streaming for large files. Then write a skill that reshapes CSV, JSON, and SQLite inputs using stdlib-only scripts.", }, "home-sysadmin": { "hint": "routine Linux host care and triage", "scope": "home", "brief": "Deep-research routine single-host Linux care first: disk, memory, service health, log triage, and backup verification. Then write a skill that runs a host checkup and reports findings with suggested fixes.", }, } def skill_catalog(skills): lines = [] if skills: lines += ["", "", "## Available skills"] for name in sorted(skills): lines.append("- %s: %s" % (name, skills[name]["description"][:300])) wanted = sorted(name for name in SKILL_BLUEPRINTS if name not in (skills or {})) if wanted: lines += ["", "", "## Buildable skill blueprints (load_skill builds one on demand)"] for name in wanted: lines.append("- %s: %s" % (name, SKILL_BLUEPRINTS[name]["hint"])) return "\n".join(lines) lines.append("Load full instructions with load_skill before using one.") return "\n".join(lines) class Sandbox: """Container engine probes (static; tests patch these live).""" @staticmethod def container_engine(): if shutil.which("podman"): return "podman" if shutil.which("docker"): return "docker" return "" @staticmethod def box_exec(engine, argv, extra=(), input_data=None, timeout=180): return subprocess.run([engine, "exec", *extra, "-i", BOX_NAME, *argv], input=input_data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) def container_engine(): return Sandbox.container_engine() def box_state(engine): try: done = subprocess.run([engine, "inspect", "-f", "{{.State.Running}}", BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError): return "missing" if done.returncode != 0: return "missing" return "running" if done.stdout.strip() == "true" else "stopped" def ensure_box(): engine = container_engine() if not engine: raise BackendError("no container engine found, install podman") state = box_state(engine) if state == "running": return engine if state == "stopped": done = subprocess.run([engine, "start", BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=60) if done.returncode != 0: raise BackendError("cannot start box: " + (done.stderr or "").strip()[:200]) return engine print("building sandbox image %s (one-time, takes minutes)..." % BOX_IMAGE) with tempfile.TemporaryDirectory() as build: for filename, content in (("Containerfile", BOX_CONTAINERFILE), ("stt.py", BOX_STT), ("tts.py", BOX_TTS)): with open(os.path.join(build, filename), "w", encoding="utf-8") as handle: handle.write(content) done = subprocess.run([engine, "build", "-t", BOX_IMAGE, build], timeout=1800) if done.returncode != 0: raise BackendError("box image build failed") done = subprocess.run([engine, "run", "-d", "--name", BOX_NAME, "--restart", "unless-stopped", BOX_IMAGE], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120) if done.returncode != 0: raise BackendError("cannot start box: " + (done.stderr or "").strip()[:200]) return engine def box_exec(engine, argv, extra=(), input_data=None, timeout=180): return Sandbox.box_exec(engine, argv, extra, input_data, timeout) def box_python(engine): if engine not in _BOX_PYTHON_CACHE: try: probe = box_exec(engine, ["test", "-x", BOX_PYTHON], timeout=30) _BOX_PYTHON_CACHE[engine] = BOX_PYTHON if probe.returncode == 0 else "python3" except (OSError, subprocess.SubprocessError): _BOX_PYTHON_CACHE[engine] = "python3" return _BOX_PYTHON_CACHE[engine] def box_transcribe(engine, audio, timeout=300): done = box_exec(engine, [box_python(engine), "/box/stt.py"], input_data=audio, timeout=timeout) if done.returncode != 0: raise BackendError("box stt failed: " + done.stderr.decode("utf-8", "replace")[:300]) return done.stdout.decode("utf-8", "replace").strip() def box_speak(engine, text, voice=""): argv = [box_python(engine), "/box/tts.py"] + ([voice] if voice else []) done = box_exec(engine, argv, input_data=text.encode("utf-8"), timeout=180) if done.returncode != 0 or not done.stdout: raise BackendError("box tts failed: " + done.stderr.decode("utf-8", "replace")[:300]) return done.stdout BOX_OPENCODE_NODE_VERSION = "20.19.2" _BOX_OPENCODE_CACHE = {} # Installed on demand, not baked into BOX_CONTAINERFILE: the box exists for # STT/TTS today and most installs already have one running, so this only # pays Node's weight (~800MB, dominated by opencode-ai's own node_modules, # not the runtime itself) on the one machine, the one time, it's actually # needed as a last-resort chat backend (see ChatClient.all_candidates()). # The official nodejs.org tarball, not apt: Debian's nodejs/npm packages # drag in webpack/babel/jest as transitive deps (measured: 1.49GB vs 971MB # for the exact same opencode-ai install) and are not relevant here at all. BOX_OPENCODE_INSTALL_SCRIPT = ( "set -e; " "ARCH=\"$(dpkg --print-architecture)\"; " "case \"$ARCH\" in amd64) NODE_ARCH=x64;; arm64) NODE_ARCH=arm64;; armhf) NODE_ARCH=armv7l;; i386) NODE_ARCH=x86;; *) echo \"unsupported arch $ARCH\" >&2; exit 1;; esac; " "apt-get update -qq && apt-get install -y -qq --no-install-recommends curl xz-utils ca-certificates; " "curl -fsSL \"https://nodejs.org/dist/v%(ver)s/node-v%(ver)s-linux-$NODE_ARCH.tar.xz\" -o /tmp/node.tar.xz; " "tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1; " "rm /tmp/node.tar.xz; " "apt-get purge -y -qq curl xz-utils; apt-get autoremove -y -qq; rm -rf /var/lib/apt/lists/*; " "npm install -g opencode-ai --no-fund --no-audit" ) % {"ver": BOX_OPENCODE_NODE_VERSION} def box_opencode_binary(engine): """True once the opencode CLI is confirmed present inside the sandbox box, installing Node.js + `npm install -g opencode-ai` into the box first if it isn't there yet. Only ever called as a last resort (see ChatClient.all_candidates()) and only against a box that is ALREADY running -- this never creates the box itself; that stays the explicit, approval-gated action it already is (ensure_box(), via --env sandbox or the install tool). Cached per engine for the process lifetime so normal turns that succeed through a real backend never pay this cost twice.""" if engine in _BOX_OPENCODE_CACHE: return _BOX_OPENCODE_CACHE[engine] try: probe = box_exec(engine, ["which", "opencode"], timeout=10) except (OSError, subprocess.SubprocessError): _BOX_OPENCODE_CACHE[engine] = False return False if probe.returncode == 0: _BOX_OPENCODE_CACHE[engine] = True return True print(paint( "opencode: no local CLI on this machine; installing it into the existing sandbox " "box (container '%s') instead, as a last-resort backend. Nothing outside that " "container is touched. Streaming the install below:" % BOX_NAME, Ansi.DIM ), file=sys.stderr) try: # Deliberately not box_exec(): this one streams straight to the # real stdout/stderr instead of capturing, so the person watching # sees the actual apt/curl/npm output live, not a silent pause. done = subprocess.run([engine, "exec", BOX_NAME, "sh", "-c", BOX_OPENCODE_INSTALL_SCRIPT], timeout=600) except (OSError, subprocess.SubprocessError) as exc: print(paint("opencode box install failed: %s" % short_error(exc), Ansi.DIM), file=sys.stderr) _BOX_OPENCODE_CACHE[engine] = False return False ok = done.returncode == 0 print(paint("opencode box install %s" % ("succeeded" if ok else "failed (exit %d)" % done.returncode), Ansi.DIM), file=sys.stderr) _BOX_OPENCODE_CACHE[engine] = ok return ok def box_opencode_run(engine, model, prompt, timeout): argv = ["opencode", "run", "--pure", "--agent", OPENCODE_AGENT, "--format", "json", "-m", "opencode/" + model] try: done = box_exec(engine, argv, input_data=prompt.encode("utf-8"), timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("opencode (box) run timed out after %ds" % timeout) except (OSError, subprocess.SubprocessError) as exc: raise BackendError("opencode (box) run failed: %s" % short_error(exc)) return opencode_parse_events(done.stdout.decode("utf-8", "replace"), done.returncode, done.stderr.decode("utf-8", "replace")) def split_models(spec): return [item.strip() for item in (spec or "").replace("\n", ",").split(",") if item.strip()] def parse_backends(spec): found = [] for entry in (spec or "").replace("\n", ";").split(";"): fields = [field.strip() for field in entry.split("|")] if len(fields) < 3: continue label, base, key = fields[0], fields[1].rstrip("/"), fields[2] if key.startswith("$"): key = os.environ.get(key[1:], "") model = fields[3].strip() if len(fields) > 3 else "" profile = fields[4].strip().lower() if len(fields) > 4 else "" if profile not in ("", "opencode"): profile = "" if not label or not base: continue found.append({"label": label, "base": base, "key": key, "model": model or "", "profile": profile}) return found class ShellSession: """One interactive shell is one conversation context. Spec: every bash/zsh/fish instance gets a stable logical id. All tai runs inside that shell share one conversation history; a new shell starts fresh (system message only), so the opening messages are identical on every start. Vault data (records, secrets, memory, audit) stays shared across shells; only the chat transcript and turn state are scoped per session. Id format (derived): YYYYMMDD-HHMMSS-p-b: local date and time of the shell's start, the shell pid, and 8 hex chars of the kernel boot id. Deterministic per shell (no state file, no install hook), unique across reboots. Fallback with no shell ancestor or no /proc is ephemeral: YYYYMMDD-HHMMSS-e-, fresh per process. Resolution (ShellSession.current): an explicit TAI_SESSION wins when it is a plain word (a pinned, deliberately shared context) or when its embedded -p- matches the nearest shell ancestor (the same shell coming back). An id that embeds a different pid means a nested shell that inherited the variable, so a fresh child id is derived and the inherited value becomes its parent link. boot() exports the resolved id back into os.environ, which makes TAI_SESSION the global var every child process (shell tool, nested tai) inherits. Set TAI_SESSION to one fixed word to share a single context across all shells; unset it for strict per-shell isolation. Parentage (for fun, queryable): register() upserts one shell_sessions row per boot plus stub rows for outer shell ancestors, whose ids derive from the same formula, so the whole nesting tree accretes over time. Dangling parents after pruning are fine; this table is a log, not a constraint. /ctx (ShellSession.report) renders the ancestor path and children of the current session from these links. Caps: sid charset [A-Za-z0-9_.-], SID_MAX chars; CHAIN_CAP process levels; FILES_CAP scoped transcript files per profile and bot (oldest pruned on save); ROWS_CAP shell_sessions rows (oldest pruned on register). """ ENV = "TAI_SESSION" SHELLS = frozenset(("bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "nu")) SID_MAX = 64 PID_RE = re.compile(r"-p(\d+)-") CHAIN_CAP = 64 FILES_CAP = 20 ROWS_CAP = 1000 TREE_DEPTH = 8 TREE_KIDS = 10 TREE_LINES = 40 def __init__(self, sid, shell="", pid=0, started="", parent=None, chain=()): self.sid = sid self.shell = shell self.pid = pid self.started = started self.parent = parent self.chain = list(chain) @classmethod def sanitize(cls, value): return re.sub(r"[^A-Za-z0-9_.-]", "", str(value or ""))[:cls.SID_MAX] @classmethod def coerce(cls, value): if isinstance(value, ShellSession): return value.sid return cls.sanitize(value) @staticmethod def read_text(path): try: with open(path, "r", encoding="utf-8", errors="replace") as handle: return handle.read(4096) except OSError: return "" @classmethod def parse_stat(cls, text): head, _, tail = (text or "").rpartition(")") if not head or "(" not in head: return None comm = head.split("(", 1)[1] fields = tail.split() try: return comm, int(fields[1]), int(fields[19]) except (IndexError, ValueError): return None @classmethod def boot_time(cls, proc_root): try: with open(os.path.join(proc_root, "stat"), "r", encoding="utf-8") as handle: for line in handle: if line.startswith("btime "): return float(line.split()[1]) except (OSError, ValueError): return None return None @classmethod def clk_tick(cls): try: return int(os.sysconf("SC_CLK_TCK")) except (AttributeError, OSError, ValueError): return 100 @classmethod def boot_tag(cls, proc_root): digest = re.sub(r"[^0-9a-f]", "", cls.read_text(os.path.join(proc_root, "sys/kernel/random/boot_id")).lower())[:8] if digest: return digest btime = cls.boot_time(proc_root) if btime: return hashlib.md5(str(btime).encode("utf-8")).hexdigest()[:8] return "deadbeef" @classmethod def shell_name(cls, argv0, comm): for cand in (os.path.basename((argv0 or "").split("\0")[0]).lstrip("-").lower(), (comm or "").lstrip("-").lower()): if cand in cls.SHELLS: return cand return "" @classmethod def read_chain(cls, pid, proc_root, btime, tick, cap): chain = [] seen = set() current = pid while current and current not in seen and len(chain) < cap: seen.add(current) parsed = cls.parse_stat(cls.read_text(os.path.join(proc_root, str(current), "stat"))) if parsed is None: break comm, ppid, ticks = parsed argv0 = os.path.basename(cls.read_text(os.path.join(proc_root, str(current), "cmdline")).split("\0")[0])[:64] start = (btime + ticks / tick) if btime else 0.0 chain.append({"pid": current, "ppid": ppid, "comm": comm[:64], "argv0": argv0, "name": cls.shell_name(argv0, comm), "start": start, "start_iso": datetime.fromtimestamp(start, tz=timezone.utc).isoformat(timespec="seconds") if start else "", "sid": ""}) if ppid <= 0 or ppid == current: break current = ppid return chain @classmethod def shell_id(cls, start_ts, pid, boot): return "%s-p%d-%s" % (time.strftime("%Y%m%d-%H%M%S", time.localtime(start_ts)), pid, boot) @classmethod def ephemeral_id(cls, now_ts, pid): return "%s-e%d-%s" % (time.strftime("%Y%m%d-%H%M%S", time.localtime(now_ts)), pid, uuid.uuid4().hex[:4]) @classmethod def current(cls, env=None, pid=None, proc_root="/proc", now=None, clk_tck=None): source = os.environ if env is None else env raw = cls.sanitize(source.get(cls.ENV) or "") pid = os.getpid() if pid is None else pid now = time.time() if now is None else now btime = cls.boot_time(proc_root) tick = clk_tck or cls.clk_tick() boot = cls.boot_tag(proc_root) chain = cls.read_chain(pid, proc_root, btime, tick, cls.CHAIN_CAP) for entry in chain: if entry["name"] and entry["start"]: entry["sid"] = cls.shell_id(entry["start"], entry["pid"], boot) shells = [entry for entry in chain if entry["sid"]] inner = shells[0] if shells else None outer = shells[1] if len(shells) > 1 else None outer_sid = outer["sid"] if outer else None if raw: match = cls.PID_RE.search(raw) if match is None: return cls(raw, shell=inner["name"] if inner else "", pid=inner["pid"] if inner else pid, started=inner["start_iso"] if inner else "", chain=chain) if inner is not None and int(match.group(1)) == inner["pid"]: return cls(raw, shell=inner["name"], pid=inner["pid"], started=inner["start_iso"], parent=outer_sid, chain=chain) fresh = inner["sid"] if inner else cls.ephemeral_id(now, pid) started = inner["start_iso"] if inner else datetime.fromtimestamp(now, tz=timezone.utc).isoformat(timespec="seconds") return cls(fresh, shell=inner["name"] if inner else "", pid=inner["pid"] if inner else pid, started=started, parent=raw, chain=chain) if inner is not None: return cls(inner["sid"], shell=inner["name"], pid=inner["pid"], started=inner["start_iso"], parent=outer_sid, chain=chain) return cls(cls.ephemeral_id(now, pid), pid=pid, started=datetime.fromtimestamp(now, tz=timezone.utc).isoformat(timespec="seconds"), chain=chain) def export(self): os.environ[self.ENV] = self.sid def register(self, store): try: stamp = now_iso() shells = [entry for entry in self.chain if entry.get("sid")] for pos, entry in enumerate(shells): next_sid = shells[pos + 1]["sid"] if pos + 1 < len(shells) else None store.db.execute("INSERT OR IGNORE INTO shell_sessions (sid, parent, shell, pid, started) VALUES (?, ?, ?, ?, ?)", (entry["sid"], next_sid, entry["name"], entry["pid"], entry["start_iso"])) store.db.execute("INSERT OR IGNORE INTO shell_sessions (sid, parent, shell, pid, started, last_seen, cwd, chain) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (self.sid, self.parent, self.shell, self.pid, self.started, stamp, dir_key(), json.dumps(self.chain))) store.db.execute("UPDATE shell_sessions SET last_seen = ?, cwd = ?, parent = COALESCE(parent, ?), chain = ? WHERE sid = ?", (stamp, dir_key(), self.parent, json.dumps(self.chain), self.sid)) store.db.execute("DELETE FROM shell_sessions WHERE sid NOT IN (SELECT sid FROM shell_sessions ORDER BY COALESCE(last_seen, started, '') DESC LIMIT ?)", (self.ROWS_CAP,)) store.db.commit() except (sqlite3.Error, OSError, ValueError): pass @classmethod def tree_lines(cls, store, sid): """Hierarchy around sid: ancestor path root-first, then the children below. Cycle-safe, capped by TREE_DEPTH/TREE_KIDS/TREE_LINES.""" try: rows = store.db.execute("SELECT sid, parent, shell, pid FROM shell_sessions").fetchall() except sqlite3.Error: return [] by_sid = {} kids = {} for item in rows: by_sid[item[0]] = item if item[1]: kids.setdefault(item[1], []).append(item[0]) for key in kids: kids[key].sort() if sid not in by_sid: return [] def label(key): item = by_sid.get(key) if item is None: return "%s (pruned)" % key return "%s %s%s" % (key, item[2] or "?", (" pid %s" % item[3]) if item[3] else "") upwards = [] seen = {sid} node = sid while len(upwards) < cls.TREE_DEPTH: parent = by_sid[node][1] if node in by_sid else None if not parent or parent in seen: break seen.add(parent) upwards.append(parent) node = parent lines = [" " * depth + label(key) for depth, key in enumerate(reversed(upwards))] lines.append(" " * len(upwards) + "* " + label(sid)) def below(key, depth, seen_here): out = [] if depth > cls.TREE_DEPTH or len(lines) + len(out) >= cls.TREE_LINES: return out for child in kids.get(key, [])[:cls.TREE_KIDS]: if child in seen_here or len(lines) + len(out) >= cls.TREE_LINES: continue out.append(" " * depth + label(child)) out.extend(below(child, depth + 1, seen_here | {child})) return out lines.extend(below(sid, len(upwards) + 1, seen)) return lines[:cls.TREE_LINES] @classmethod def report(cls, store, sid): try: row = store.db.execute("SELECT sid, parent, shell, pid, started, last_seen, cwd FROM shell_sessions WHERE sid = ?", (sid,)).fetchone() if sid else None except sqlite3.Error: return "ctx unavailable (vault error)" if row: lines = ["ctx: %s" % row[0], "shell: %s (pid %s, started %s)" % (row[2] or "?", row[3] or "?", row[4] or "?"), "parent: %s" % (row[1] or "(none)"), "cwd: %s" % (row[6] or "?")] elif sid: lines = ["ctx: %s (unregistered)" % sid] else: lines = ["ctx: (legacy, no session)"] tree = cls.tree_lines(store, sid) if tree: lines.append("tree:") lines.extend(tree) try: rows = store.db.execute("SELECT sid, shell, last_seen FROM shell_sessions ORDER BY COALESCE(last_seen, started, '') DESC LIMIT 8").fetchall() except sqlite3.Error: rows = [] if rows: lines.append("recent:") for item in rows: lines.append("%s %s %s %s" % ("*" if item[0] == sid else " ", item[0], item[1] or "?", item[2] or "")) return "\n".join(lines) class Config: def __init__(self, args): self.home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") self.profiles_dir = os.path.join(self.home, "profiles") self.audio_dir = os.path.join(self.home, "audio") self.db_path = os.path.join(self.home, "memory.db") self.history_path = os.path.join(self.home, "history") self.model = os.environ.get("TAI_MODEL") or DEFAULT_MODEL self.explicit_model = os.environ.get("TAI_MODEL") or "" self.backends = parse_backends(os.environ.get("TAI_BACKENDS")) self.openrouter_key = os.environ.get("OPENROUTER_API_KEY") or "" self.openrouter_models = split_models(os.environ.get("TAI_OPENROUTER_MODELS")) self.retry_seconds = float(os.environ.get("TAI_RETRY_SECONDS") or 0) if re.match(r"^\d+(\.\d+)?$", (os.environ.get("TAI_RETRY_SECONDS") or "0").strip()) else 0.0 self.opencode_enabled = (os.environ.get("TAI_OPENCODE") or "1").strip().lower() not in ("0", "false", "no", "off") if getattr(args, "no_opencode", False): self.opencode_enabled = False self.opencode_models = split_models(os.environ.get("TAI_OPENCODE_MODELS")) self.devplace_key = os.environ.get("DEVPLACE_API_KEY") or "" self.devplace_base = (os.environ.get("TAI_DEVPLACE_BASE") or DEVPLACE_BASE).rstrip("/") self.devplace_models = split_models(os.environ.get("TAI_DEVPLACE_MODELS")) self.openrouter_enabled = (os.environ.get("TAI_OPENROUTER") or "1").strip().lower() not in ("0", "false", "no", "off") self.pollinations_enabled = (os.environ.get("TAI_POLLINATIONS") or "1").strip().lower() not in ("0", "false", "no", "off") self.pollinations_models = split_models(os.environ.get("TAI_POLLINATIONS_MODELS")) self.zen_enabled = (os.environ.get("TAI_ZEN") or "").strip().lower() in ("1", "true", "yes", "on") self.zen_models = split_models(os.environ.get("TAI_ZEN_MODELS")) self.voice = os.environ.get("TAI_VOICE") or DEFAULT_VOICE self.auto_approve = bool(args.yes) self.profile = args.profile or "default" os.makedirs(self.profiles_dir, exist_ok=True) os.makedirs(self.audio_dir, exist_ok=True) def ensure_column(db, table, column, ddl): names = [row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()] if column not in names: db.execute("ALTER TABLE %s ADD COLUMN %s %s" % (table, column, ddl)) def fts_escape(query): terms = re.findall(r"[a-z0-9]+", (query or "").lower()) if not terms: return None return ['"%s"' % term for term in terms] def fts_match(terms): return " AND ".join("{title body} : %s" % term for term in terms) MEM_EVENTS_CAP = 10000 def open_mem_events(): mem = sqlite3.connect(":memory:") try: mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") except sqlite3.Error: mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile)") return mem def setup_fts(db): try: exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'fts_docs'").fetchone() if not exists: try: db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") except sqlite3.Error: db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile)") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ai AFTER INSERT ON records BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_au AFTER UPDATE ON records WHEN old.title != new.title OR old.content != new.content OR old.profile != new.profile BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ad AFTER DELETE ON records BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_ai AFTER INSERT ON events BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_au AFTER UPDATE ON events WHEN old.text != new.text OR old.profile != new.profile BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'event'; INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_audit_ai AFTER INSERT ON audit BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('audit:' || new.id, 'audit', new.action, new.path, new.message, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_chunks_ai AFTER INSERT ON chunks BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('chunk:' || new.id, 'chunk', new.parent_kind, new.parent_id, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_chunks_ad AFTER DELETE ON chunks BEGIN DELETE FROM fts_docs WHERE item = 'chunk:' || old.id AND kind = 'chunk'; END""") if db.execute("SELECT COUNT(*) FROM fts_docs").fetchone()[0] == 0: db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'record', kind, title, content, profile FROM records") db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'event', role || '/' || kind, ts || ' ' || role || '/' || kind, text, profile FROM events") db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT 'audit:' || id, 'audit', action, path, message, profile FROM audit") db.commit() return True except sqlite3.Error: return False def table_pk_columns(db, table): for row in db.execute("PRAGMA index_list(%s)" % table).fetchall(): if row[3] == "pk": return [info[2] for info in db.execute("PRAGMA index_info(%s)" % row[1]).fetchall()] return [] def rebuild_with_profile(db, table, columns, pk): if table_pk_columns(db, table) == list(pk): return False names = [column.split()[0] for column in columns] have = {row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()} keep = [name for name in names if name in have] db.execute("CREATE TABLE %s_new (%s, PRIMARY KEY (%s))" % (table, ", ".join(columns), ", ".join(pk))) if keep: db.execute("INSERT OR IGNORE INTO %s_new (%s) SELECT %s FROM %s" % (table, ", ".join(keep), ", ".join(keep), table)) db.execute("DROP TABLE %s" % table) db.execute("ALTER TABLE %s_new RENAME TO %s" % (table, table)) return True def migrate_profiles(db): for table in ("secrets", "records", "tags", "edges", "audit", "events", "schedules"): ensure_column(db, table, "profile", "TEXT") db.execute("UPDATE %s SET profile = 'default' WHERE profile IS NULL" % table) rebuild_with_profile(db, "secrets", ("profile TEXT", "name TEXT", "value TEXT", "meta TEXT", "updated TEXT"), ("profile", "name")) rebuild_with_profile(db, "tags", ("item TEXT", "tag TEXT", "profile TEXT"), ("item", "tag", "profile")) rebuild_with_profile(db, "edges", ("src TEXT", "dst TEXT", "relation TEXT", "profile TEXT", "created TEXT"), ("src", "dst", "relation", "profile")) SCHEMA = ( ("events", ("id INTEGER PRIMARY KEY", "profile TEXT", "ts TEXT", "role TEXT", "kind TEXT", "text TEXT", "tags TEXT"), ()), ("secrets", ("profile TEXT", "name TEXT", "value TEXT", "meta TEXT", "updated TEXT"), ("profile", "name")), ("schedules", ("id INTEGER PRIMARY KEY", "name TEXT", "prompt TEXT", "profile TEXT", "every_sec INTEGER", "next_run TEXT", "timeout INTEGER", "status TEXT", "last_status TEXT", "last_result TEXT", "created TEXT", "updated TEXT"), ()), ("records", ("id TEXT PRIMARY KEY", "kind TEXT", "profile TEXT", "title TEXT", "content TEXT", "size INTEGER", "reads INTEGER", "created TEXT", "updated TEXT"), ()), ("tags", ("item TEXT", "tag TEXT", "profile TEXT"), ("item", "tag", "profile")), ("edges", ("src TEXT", "dst TEXT", "relation TEXT", "profile TEXT", "created TEXT"), ("src", "dst", "relation", "profile")), ("audit", ("id INTEGER PRIMARY KEY", "profile TEXT", "ts TEXT", "actor TEXT", "action TEXT", "path TEXT", "message TEXT", "old_size INTEGER", "new_size INTEGER", "old TEXT", "new TEXT", "tags TEXT"), ()), ("chunks", ("id INTEGER PRIMARY KEY", "parent_kind TEXT", "parent_id TEXT", "idx INTEGER", "text TEXT", "profile TEXT", "created TEXT"), ()), ("dir_defaults", ("dir TEXT PRIMARY KEY", "provider TEXT", "model TEXT", "bot TEXT", "updated TEXT"), ()), ("input_history", ("id INTEGER PRIMARY KEY", "profile TEXT", "bot TEXT", "ts TEXT", "text TEXT"), ()), ("shell_sessions", ("sid TEXT PRIMARY KEY", "parent TEXT", "shell TEXT", "pid INTEGER", "started TEXT", "last_seen TEXT", "cwd TEXT", "chain TEXT"), ()), ) SCHEMA_INDEXES = ( ("idx_events_profile", "events(profile)"), ("idx_tags_tag", "tags(tag)"), ("idx_edges_src", "edges(src)"), ("idx_edges_dst", "edges(dst)"), ("idx_audit_path", "audit(path)"), ("idx_tags_profile", "tags(profile)"), ("idx_edges_profile", "edges(profile)"), ("idx_records_profile", "records(profile)"), ("idx_audit_profile", "audit(profile)"), ("idx_chunks_parent", "chunks(parent_kind, parent_id)"), ("idx_chunks_profile", "chunks(profile)"), ("idx_input_history_profile", "input_history(profile, id)"), ("idx_shell_sessions_parent", "shell_sessions(parent)"), ) def ensure_schema(db): notes = [] for table, columns, pk in SCHEMA: exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table,)).fetchone() if not exists: extra = ", PRIMARY KEY (%s)" % ", ".join(pk) if pk else "" db.execute("CREATE TABLE %s (%s%s)" % (table, ", ".join(columns), extra)) continue have = {row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()} for column in columns: parts = column.split() if parts[0] not in have: db.execute("ALTER TABLE %s ADD COLUMN %s" % (table, " ".join(parts[:2]))) notes.append("added %s.%s" % (table, parts[0])) for index, target in SCHEMA_INDEXES: db.execute("CREATE INDEX IF NOT EXISTS %s ON %s" % (index, target)) db.commit() return notes INPUT_HISTORY_CAP = 1000 INPUT_HISTORY_MAX_CHARS = 4000 class Store: def __init__(self, config, seal): self.config = config self.seal = seal self.db = sqlite3.connect(config.db_path, timeout=30, check_same_thread=False) self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA synchronous=NORMAL") self.db.execute("PRAGMA busy_timeout=30000") self.schema_notes = ensure_schema(self.db) migrate_profiles(self.db) self.db.commit() self.profile = config.profile self.secret_cache = {} self.fts_ok = setup_fts(self.db) if seal.enabled: self.db.create_function("tai_enc", 1, seal.lock) self.db.create_function("tai_dec", 1, seal.safe_unlock) self.db.execute("UPDATE events SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") self.db.execute("UPDATE secrets SET value = tai_enc(value) WHERE value NOT LIKE 'tai1$%'") self.db.execute("UPDATE secrets SET meta = tai_enc(COALESCE(meta, '{}')) WHERE meta IS NULL OR meta NOT LIKE 'tai1$%'") self.db.execute("UPDATE schedules SET prompt = tai_enc(prompt) WHERE prompt NOT LIKE 'tai1$%'") self.db.execute("UPDATE input_history SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") self.db.commit() self.seal_files() else: sealed = self.db.execute("SELECT COUNT(*) FROM events WHERE text LIKE 'tai1$%'").fetchone()[0] if sealed: raise SealError("memory is sealed, set TAI_PASSPHRASE") locked = self.db.execute("SELECT COUNT(*) FROM secrets WHERE value LIKE 'tai1$%' OR meta LIKE 'tai1$%'").fetchone()[0] if locked: raise SealError("secrets are sealed, set TAI_PASSPHRASE") planned = self.db.execute("SELECT COUNT(*) FROM schedules WHERE prompt LIKE 'tai1$%'").fetchone()[0] if planned: raise SealError("schedules are sealed, set TAI_PASSPHRASE") typed = self.db.execute("SELECT COUNT(*) FROM input_history WHERE text LIKE 'tai1$%'").fetchone()[0] if typed: raise SealError("input history is sealed, set TAI_PASSPHRASE") self.memdb = None self.mem_events_max = 0 if seal.enabled and self.fts_ok: try: self.memdb = open_mem_events() floor = self.db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0] - MEM_EVENTS_CAP self.mem_events_max = max(0, floor) self._sync_mem_events() except sqlite3.Error: self.memdb = None def _sync_mem_events(self): if self.memdb is None: return try: rows = self.db.execute("SELECT id, profile, ts, role, kind, tai_dec(text) FROM events WHERE id > ? ORDER BY id ASC", (self.mem_events_max,)).fetchall() except sqlite3.Error: return if not rows: return try: self.memdb.executemany("INSERT INTO mem_events(item, kind, sub, title, body, profile) VALUES (?, 'event', ? || '/' || ?, ? || ' ' || ? || '/' || ?, ?, ?)", [(row[0], row[3], row[4], row[2], row[3], row[4], row[5], row[1]) for row in rows]) self.memdb.commit() except sqlite3.Error: return self.mem_events_max = rows[-1][0] try: self.memdb.execute("DELETE FROM mem_events WHERE CAST(item AS INTEGER) <= ?", (self.mem_events_max - MEM_EVENTS_CAP,)) self.memdb.commit() except sqlite3.Error: pass def seal_files(self): for entry in os.listdir(self.config.profiles_dir): if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(self.config.profiles_dir, entry) try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: continue if raw.startswith(Seal.PREFIX): continue try: with open(path, "w", encoding="utf-8") as handle: handle.write(self.seal.lock(raw)) except OSError: pass def history_last(self, profile): row = self.db.execute("SELECT %s FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT 1" % ("tai_dec(text)" if self.seal.enabled else "text"), (profile,)).fetchone() return row[0] if row else None def history_add(self, profile, bot, text): """Record one typed input for the profile. Sealed like events, one profile list shared by every bot (readline habit), the bot kept as a column for filtering. Skips empty text and an immediate repeat of the last entry; trims the profile to INPUT_HISTORY_CAP rows in the same transaction. Returns True when a row was written.""" value = str(text or "").strip()[:INPUT_HISTORY_MAX_CHARS] if not value: return False try: if self.history_last(profile) == value: return False if self.seal.enabled: self.db.execute("INSERT INTO input_history (profile, bot, ts, text) VALUES (?, ?, ?, tai_enc(?))", (profile, bot, now_iso(), value)) else: self.db.execute("INSERT INTO input_history (profile, bot, ts, text) VALUES (?, ?, ?, ?)", (profile, bot, now_iso(), value)) self.db.execute("DELETE FROM input_history WHERE profile = ? AND id <= COALESCE((SELECT id FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)", (profile, profile, INPUT_HISTORY_CAP)) self.db.commit() return True except sqlite3.Error: return False def history_load(self, profile, limit=INPUT_HISTORY_CAP): """Entries oldest to newest. A profile with no rows yet imports the old plain-text readline file once, so earlier REPL history is kept.""" limit = max(1, min(int(limit), INPUT_HISTORY_CAP)) try: count = self.db.execute("SELECT COUNT(*) FROM input_history WHERE profile = ?", (profile,)).fetchone()[0] if count == 0: self.history_import_file(profile, self.config.history_path) rows = self.db.execute("SELECT %s FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT ?" % ("tai_dec(text)" if self.seal.enabled else "text"), (profile, limit)).fetchall() except sqlite3.Error: return [] return [row[0] for row in reversed(rows)] def history_import_file(self, profile, path): try: with open(path, "r", encoding="utf-8", errors="replace") as handle: lines = [line.rstrip("\n") for line in handle if line.strip()][-INPUT_HISTORY_CAP:] except OSError: return 0 written = 0 for line in lines: written += 1 if self.history_add(profile, "main", line) else 0 return written def log_event(self, profile, role, kind, text, tags=()): value = (text or "")[:2000] stamped = " " + " ".join([role, kind] + normalize_tags(tags)) + " " try: if self.seal.enabled: self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, tai_enc(?), ?)", (profile, now_iso(), role, kind, value, stamped)) else: self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, ?, ?)", (profile, now_iso(), role, kind, value, stamped)) self.db.commit() self._sync_mem_events() except sqlite3.Error: pass def fts_search(self, query, kinds=(), profile=None, limit=10): profile = self._scope(profile) if not self.fts_ok: return [] terms = fts_escape(query) if terms is None: return [] sql = "SELECT item, kind, sub, title, snippet(fts_docs, 4, '[', ']', '...', 12), bm25(fts_docs) FROM fts_docs WHERE fts_docs MATCH ? AND profile = ?" params = [fts_match(terms), profile] if kinds: marks = ", ".join("?" * len(kinds)) sql += " AND kind IN (%s)" % marks params += list(kinds) if self.seal.enabled: sql += " AND kind != 'event'" sql += " ORDER BY bm25(fts_docs) LIMIT ?" capped = max(1, min(limit, 100)) params.append(capped) try: rows = self.db.execute(sql, tuple(params)).fetchall() except sqlite3.Error: return [] hits = [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in rows] if self.seal.enabled and self.memdb is not None and (not kinds or "event" in kinds): self._sync_mem_events() try: mem_rows = self.memdb.execute("SELECT item, kind, sub, title, snippet(mem_events, 4, '[', ']', '...', 12), bm25(mem_events) FROM mem_events WHERE mem_events MATCH ? AND profile = ? ORDER BY bm25(mem_events) LIMIT ?", (fts_match(terms), profile, capped)).fetchall() except sqlite3.Error: mem_rows = [] hits += [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in mem_rows] hits.sort(key=lambda hit: hit["rank"]) hits = hits[:capped] return hits def audit_search(self, query, profile=None, limit=20): profile = self._scope(profile) hits = self.fts_search(query, ("audit",), profile, limit) found = [] for hit in hits: try: row_id = int(str(hit["item"]).split(":", 1)[1]) except (IndexError, ValueError): continue row = self.audit_get(row_id, profile) if row is not None: row["snippet"] = hit["snippet"] found.append(row) return found def search_events(self, profile, query, limit=8, tags=()): clean = normalize_tags(tags) if self.fts_ok: hits = self.fts_search(query, ("event",), profile, max(limit * 5, 20)) if hits: ids = [int(hit["item"]) for hit in hits] marks = ", ".join("?" * len(ids)) text_col = "tai_dec(text)" if self.seal.enabled else "text" rows = {row[0]: row[1:] for row in self.db.execute("SELECT id, ts, role, kind, %s, tags FROM events WHERE id IN (%s)" % (text_col, marks), tuple(ids)).fetchall()} found = [] for hit in hits: row = rows.get(int(hit["item"])) if row is None: continue if clean and not all((" %s " % tag) in (" %s " % (row[4] or "")) for tag in clean): continue found.append(row[:4]) if len(found) >= limit: break if found: return found return self.events_like(profile, query, limit, clean) def events_like(self, profile, query, limit=8, tags=()): clean = normalize_tags(tags) extra = "".join(" AND tags LIKE ?" for _tag in clean) wild = tuple("% %s %%" % tag for tag in clean) if self.seal.enabled: return self.db.execute("SELECT ts, role, kind, text FROM (SELECT id, ts, role, kind, tags, tai_dec(text) AS text FROM events WHERE profile = ? ORDER BY id DESC LIMIT 5000) WHERE text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() return self.db.execute("SELECT ts, role, kind, text FROM events WHERE profile = ? AND text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() def _scope(self, profile): return profile or self.profile def save_secret(self, name, value, meta=None, tags=None, profile=None): profile = self._scope(profile) self.secret_cache.pop(profile, None) stored = self.seal.lock(value) if self.seal.enabled else value blob = json.dumps(meta or {}) locked_meta = self.seal.lock(blob) if self.seal.enabled else blob self.db.execute("INSERT OR REPLACE INTO secrets (profile, name, value, meta, updated) VALUES (?, ?, ?, ?, ?)", (profile, name, stored, locked_meta, now_iso())) if tags is None: self.tag_item("secret:" + name, ["secret"], profile) else: self.set_item_tags("secret:" + name, ["secret"] + list(tags), profile) self.db.commit() def secret_meta(self, name, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT meta FROM secrets WHERE profile = ? AND name = ?", (profile, name)).fetchone() if row is None or not row[0]: return {} raw = self.seal.safe_unlock(row[0]) if row[0].startswith(Seal.PREFIX) else row[0] try: data = json.loads(raw) except ValueError: return {} return data if isinstance(data, dict) else {} def list_secret_infos(self, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT name, meta, updated FROM secrets WHERE profile = ? ORDER BY name", (profile,)).fetchall() items = [] for name, meta, updated in rows: raw = None if meta: raw = self.seal.safe_unlock(meta) if meta.startswith(Seal.PREFIX) else meta try: data = json.loads(raw or "{}") except ValueError: data = {} items.append({"name": name, "meta": data if isinstance(data, dict) else {}, "tags": self.item_tags("secret:" + name, profile), "updated": updated}) return items def known_tags(self, profile=None): profile = self._scope(profile) return {row[0] for row in self.db.execute("SELECT DISTINCT tag FROM tags WHERE profile = ?", (profile,)).fetchall()} def canonical_tag(self, tag, vocab=None, profile=None): if vocab is None: vocab = self.known_tags(profile) if tag in vocab: return tag single = singular_noun(tag) if single != tag and single in vocab: return single return tag def canonical_tags(self, tags, vocab=None, profile=None): profile = self._scope(profile) if vocab is None: vocab = self.known_tags(profile) seen = [] for tag in normalize_tags(tags): clean = self.canonical_tag(tag, vocab) if clean not in seen: seen.append(clean) return seen def tag_counts(self, prefix="", limit=50, profile=None): profile = self._scope(profile) clean = normalize_tag(prefix) sql = "SELECT tag, COUNT(*) FROM tags WHERE profile = ?" params = [profile] if clean: sql += " AND tag LIKE ? ESCAPE '\\'" params.append(clean.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%") sql += " GROUP BY tag ORDER BY COUNT(*) DESC, tag LIMIT ?" params.append(max(1, min(limit, 200))) return [(row[0], row[1]) for row in self.db.execute(sql, tuple(params)).fetchall()] def auto_tags_for(self, text, explicit=(), vocab=None): if vocab is None: vocab = self.known_tags() skip = set(explicit) | set(TAG_AUTO_SKIP) words = re.findall(r"[a-z0-9]+", (text or "").lower()) found = [] for pos, word in enumerate(words): if len(found) >= TAG_AUTO_MAX: break if pos + 1 < len(words): pair = word + "-" + words[pos + 1] if pair in vocab and pair not in skip and pair not in found: found.append(pair) continue if word in vocab and word not in skip and word not in found: found.append(word) continue single = singular_noun(word) if single != word and single in vocab and single not in skip and single not in found: found.append(single) return found def tag_neighbors(self, record_id, limit=None, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT r.id, MIN(t.tag) FROM records r JOIN tags t ON t.item = r.id WHERE t.tag IN (SELECT tag FROM tags WHERE item = ? AND profile = ? AND tag NOT IN ('record', 'file')) AND r.id != ? AND r.profile = ? AND t.profile = ? GROUP BY r.id ORDER BY r.updated DESC LIMIT ?", (record_id, profile, record_id, profile, profile, max(1, limit or TAG_LINK_MAX))).fetchall() return [(row[0], row[1]) for row in rows] def tag_item(self, item, tags, profile=None): profile = self._scope(profile) for tag in self.canonical_tags(tags, None, profile): self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) self.db.commit() def set_item_tags(self, item, tags, profile=None): profile = self._scope(profile) self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) for tag in self.canonical_tags(tags, None, profile): self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) self.db.commit() def item_tags(self, item, profile=None): profile = self._scope(profile) return [row[0] for row in self.db.execute("SELECT tag FROM tags WHERE item = ? AND profile = ? ORDER BY tag", (item, profile)).fetchall()] def tagged_items(self, tags, profile=None): profile = self._scope(profile) clean = normalize_tags(tags) if not clean: return None marks = ", ".join("?" * len(clean)) rows = self.db.execute("SELECT item FROM tags WHERE profile = ? AND tag IN (%s) GROUP BY item HAVING COUNT(DISTINCT tag) = ?" % marks, (profile,) + tuple(clean) + (len(clean),)).fetchall() return [row[0] for row in rows] def has_node(self, kind, key, profile=None): profile = self._scope(profile) if kind == "mem": row = self.db.execute("SELECT 1 FROM records WHERE profile = ? AND id = ?", (profile, "mem:" + key,)).fetchone() elif kind == "secret": row = self.db.execute("SELECT 1 FROM secrets WHERE profile = ? AND name = ?", (profile, key,)).fetchone() else: row = self.db.execute("SELECT 1 FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() return row is not None def node_title(self, node, profile=None): profile = self._scope(profile) parsed = parse_node_id(node) if parsed is None: return node kind, key = parsed if kind == "mem": row = self.db.execute("SELECT title FROM records WHERE profile = ? AND id = ?", (profile, node,)).fetchone() return row[0] or node if row else node if kind == "secret": return "secret:" + key row = self.db.execute("SELECT name, prompt FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() if not row: return node return row[0] or "schedule #%s" % key def add_record(self, kind, title, content, tags=(), profile=None): profile = self._scope(profile) record_id = "mem:" + uuid.uuid4().hex[:16] while self.db.execute("SELECT 1 FROM records WHERE id = ?", (record_id,)).fetchone(): record_id = "mem:" + uuid.uuid4().hex[:16] stamp = datetime.now(timezone.utc).isoformat() size = len(content.encode("utf-8")) self.db.execute("INSERT INTO records(id, kind, profile, title, content, size, reads, created, updated) VALUES(?, ?, ?, ?, ?, ?, 0, ?, ?)", (record_id, kind, profile, title, content, size, stamp, stamp)) vocab = self.known_tags(profile) explicit = self.canonical_tags(tags, vocab, profile) scan = "%s\n%s" % (title, content.split("\n...[truncated", 1)[0]) auto = self.auto_tags_for(scan, explicit, vocab) self.set_item_tags(record_id, ["record"] + explicit + auto, profile) for other, shared in self.tag_neighbors(record_id, None, profile): self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (record_id, other, "shares-" + shared, profile, stamp)) self.db.commit() return record_id def add_chunks(self, parent_kind, parent_id, texts, profile=None): """Replaces any prior chunks for this parent, so re-indexing is idempotent.""" profile = self._scope(profile) self.db.execute("DELETE FROM chunks WHERE parent_kind = ? AND parent_id = ? AND profile = ?", (parent_kind, parent_id, profile)) stamp = datetime.now(timezone.utc).isoformat() for idx, text in enumerate(texts): self.db.execute("INSERT INTO chunks(parent_kind, parent_id, idx, text, profile, created) VALUES (?, ?, ?, ?, ?, ?)", (parent_kind, parent_id, idx, text, profile, stamp)) self.db.commit() def get_chunks_by_ids(self, ids, profile=None): if not ids: return {} profile = self._scope(profile) marks = ", ".join("?" * len(ids)) rows = self.db.execute("SELECT id, parent_kind, parent_id, idx, text FROM chunks WHERE profile = ? AND id IN (%s)" % marks, tuple([profile] + list(ids))).fetchall() return {row[0]: {"parent_kind": row[1], "parent_id": row[2], "idx": row[3], "text": row[4]} for row in rows} def get_record(self, record_id, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id, kind, title, content, size, reads, created, updated FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() if row is None: return None self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) self.db.commit() return {"id": row[0], "kind": row[1], "title": row[2], "content": row[3], "size": as_int(row[4]), "reads": as_int(row[5]) + 1, "created": row[6], "updated": row[7], "tags": self.item_tags(row[0], profile)} def read_record(self, record_id, offset=0, limit=4000, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT content FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() if row is None: return None content = row[0] total = len(content) start = max(0, min(offset, total)) end = min(total, start + max(1, limit)) self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) self.db.commit() return {"id": record_id, "slice": content[start:end], "start": start, "end": end, "total": total, "tags": self.item_tags(record_id, profile)} def search_records(self, query="", kind=None, tags=(), limit=8, profile=None): profile = self._scope(profile) clean = normalize_tags(tags) terms = fts_escape(query) if query else None if terms is not None and self.fts_ok: ranked = self.search_records_fts(terms, kind, clean, limit, profile) if ranked: return ranked sql = "SELECT id, kind, title, size, reads, updated FROM records" clauses = ["profile = ?"] params = [profile] if kind: clauses.append("kind = ?") params.append(kind) if query: clauses.append("(title LIKE ? OR content LIKE ?)") params += ["%" + query + "%", "%" + query + "%"] if clean: for tag in clean: options = tag_variants(tag) marks = ", ".join("?" * len(options)) clauses.append("id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks) params += [profile] + options sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY updated DESC LIMIT ?" params.append(max(1, min(limit, 50))) rows = self.db.execute(sql, tuple(params)).fetchall() return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] def search_records_fts(self, terms, kind, clean, limit, profile): sql = "SELECT r.id, r.kind, r.title, r.size, r.reads, r.updated FROM records r JOIN fts_docs f ON f.item = r.id AND f.kind = 'record' WHERE r.profile = ? AND f MATCH ?" params = [profile, fts_match(terms)] if kind: sql += " AND r.kind = ?" params.append(kind) for tag in clean: options = tag_variants(tag) marks = ", ".join("?" * len(options)) sql += " AND r.id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks params += [profile] + options sql += " ORDER BY bm25(f) LIMIT ?" params.append(max(1, min(limit, 50))) try: rows = self.db.execute(sql, tuple(params)).fetchall() except sqlite3.Error: return [] return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] def delete_record(self, record_id, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM records WHERE profile = ? AND id = ?", (profile, record_id,)) if done.rowcount > 0: self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (record_id, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, record_id, record_id)) self.db.execute("DELETE FROM chunks WHERE parent_kind = 'record' AND parent_id = ? AND profile = ?", (record_id, profile)) self.db.commit() return True self.db.commit() return False def add_edge(self, src, dst, relation="linked", profile=None): profile = self._scope(profile) for node in (src, dst): parsed = parse_node_id(node) if parsed is None or not self.has_node(*parsed, profile): raise ValueError("unknown node " + node) clean = re.sub(r"[^a-z0-9]+", "-", str(relation or "").lower()).strip("-")[:32] or "linked" self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (src, dst, clean, profile, datetime.now(timezone.utc).isoformat())) self.db.commit() return clean def edges_for(self, node, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT src, dst, relation FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)).fetchall() return [{"relation": row[2], "other": row[1] if row[0] == node else row[0], "direction": "out" if row[0] == node else "in"} for row in rows] def remove_edges(self, node, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)) self.db.commit() return done.rowcount def traverse(self, start, depth=2, limit=50, profile=None): profile = self._scope(profile) seen = {start} found = [{"node": start, "title": self.node_title(start, profile), "depth": 0, "via": ""}] frontier = [(start, 0, "")] capped = max(1, min(depth, 4)) while frontier and len(found) < max(1, min(limit, 200)): node, level, _via = frontier.pop(0) if level >= capped: continue for edge in self.edges_for(node, profile): other = edge["other"] if other in seen: continue seen.add(other) hop = "%s -[%s]-> %s" % (node, edge["relation"], other) if edge["direction"] == "out" else "%s <-[%s]- %s" % (node, edge["relation"], other) found.append({"node": other, "title": self.node_title(other, profile), "depth": level + 1, "via": hop}) frontier.append((other, level + 1, hop)) if len(found) >= max(1, min(limit, 200)): break return found def audit_event(self, actor, action, path, message="", old=None, new=None, tags=(), old_size=None, new_size=None, profile=None): profile = self._scope(profile) old_text, computed_old = capped_text(old) new_text, computed_new = capped_text(new) if old is not None and old_size is not None and old_size > len(old.encode("utf-8")): old_text = old[:AUDIT_MAX_CHARS] + trunc_marker(old_size) computed_old = old_size if new is not None and new_size is not None and new_size > len(new.encode("utf-8")): new_text = new[:AUDIT_MAX_CHARS] + trunc_marker(new_size) computed_new = new_size cursor = self.db.execute("INSERT INTO audit(profile, ts, actor, action, path, message, old_size, new_size, old, new, tags) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (profile, datetime.now(timezone.utc).isoformat(), actor, action, path, str(message or "")[:2000], computed_old, computed_new, old_text, new_text, " ".join(normalize_tags(tags)))) self.db.commit() return cursor.lastrowid def audit_history(self, path=None, tag=None, limit=20, profile=None): profile = self._scope(profile) sql = "SELECT id, ts, actor, action, path, message, old_size, new_size, tags FROM audit" clauses = ["profile = ?"] params = [profile] if path: clauses.append("path = ?") params.append(path) if tag: clauses.append("tags LIKE ?") params.append("%" + tag + "%") sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY id DESC LIMIT ?" params.append(max(1, min(limit, 100))) rows = self.db.execute(sql, tuple(params)).fetchall() return [{"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "tags": row[8]} for row in rows] def audit_get(self, row_id, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id, ts, actor, action, path, message, old_size, new_size, old, new, tags FROM audit WHERE profile = ? AND id = ?", (profile, row_id,)).fetchone() if row is None: return None return {"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "old": row[8], "new": row[9], "tags": row[10]} def file_record_id(self, path, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id FROM records WHERE profile = ? AND kind = 'file' AND title = ?", (profile, path,)).fetchone() return row[0] if row else None def upsert_file_record(self, path, content, true_size=None, profile=None): profile = self._scope(profile) full_bytes = len(content.encode("utf-8")) if true_size is not None and true_size > full_bytes: text = content + trunc_marker(true_size) elif len(content) <= FILE_RECORD_MAX: text = content else: text = content[:FILE_RECORD_MAX] + trunc_marker(full_bytes) ext = os.path.splitext(path)[1].lstrip(".").lower() explicit = ["file"] + ([ext] if ext and ext.isalnum() and len(ext) <= 5 else []) vocab = self.known_tags(profile) tags = explicit + self.auto_tags_for("%s\n%s" % (path, content), explicit, vocab) record_id = self.file_record_id(path, profile) stamp = datetime.now(timezone.utc).isoformat() if record_id is None: return self.add_record("file", path, text, tags, profile) self.db.execute("UPDATE records SET content = ?, size = ?, updated = ? WHERE id = ?", (text, len(text.encode("utf-8")), stamp, record_id)) self.set_item_tags(record_id, tags, profile) self.db.commit() return record_id def delete_file_record(self, path, profile=None): profile = self._scope(profile) record_id = self.file_record_id(path, profile) if record_id is None: return False return self.delete_record(record_id, profile) def load_secret(self, name, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT value FROM secrets WHERE profile = ? AND name = ?", (profile, name,)).fetchone() if row is None: return None if row[0].startswith(Seal.PREFIX): return self.seal.unlock(row[0]) if self.seal.enabled else None return row[0] def delete_secret(self, name, profile=None): profile = self._scope(profile) self.secret_cache.pop(profile, None) done = self.db.execute("DELETE FROM secrets WHERE profile = ? AND name = ?", (profile, name,)) if done.rowcount > 0: item = "secret:" + name self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) self.db.commit() return True self.db.commit() return False def secret_values(self, profile=None): profile = self._scope(profile) if profile not in self.secret_cache: found = {} for name, value in self.db.execute("SELECT name, value FROM secrets WHERE profile = ?", (profile,)).fetchall(): if value.startswith(Seal.PREFIX): if not self.seal.enabled: continue value = self.seal.unlock(value) if len(value) >= 4: found[name] = value self.secret_cache[profile] = found return self.secret_cache[profile] def redact(self, text, profile=None): if not isinstance(text, str): return text for name, value in sorted(self.secret_values(profile).items(), key=lambda item: -len(item[1])): if value in text: text = text.replace(value, "[redacted:%s]" % name) return text def add_schedule(self, name, prompt, profile, every_sec, next_run, timeout, tags=None): stored = self.seal.lock(prompt) if self.seal.enabled else prompt done = self.db.execute("INSERT INTO schedules (name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created, updated) VALUES (?, ?, ?, ?, ?, ?, 'pending', '', '', ?, ?)", (name, stored, profile, every_sec, next_run, timeout, now_iso(), now_iso())) item = "sched:%s" % done.lastrowid if tags is None: self.tag_item(item, ["schedule"], profile) else: self.set_item_tags(item, ["schedule"] + list(tags), profile) self.db.commit() return done.lastrowid def remove_schedule(self, row_id, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM schedules WHERE id = ? AND profile = ?", (row_id, profile)) if done.rowcount > 0: item = "sched:%s" % row_id self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) self.db.commit() return True self.db.commit() return False def list_schedules(self, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT id, name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created FROM schedules WHERE profile = ? ORDER BY next_run", (profile,)).fetchall() items = [] for row in rows: prompt = self.seal.safe_unlock(row[2]) if row[2].startswith(Seal.PREFIX) else row[2] items.append({"id": row[0], "name": row[1], "prompt": prompt, "profile": row[3], "every": row[4], "next_run": row[5], "timeout": row[6], "status": row[7], "last": row[8], "result": row[9], "created": row[10], "tags": self.item_tags("sched:%s" % row[0], row[3])}) return items def profile_path(self, name): return os.path.join(self.config.profiles_dir, name + ".sys.md") def session_path(self, name, ctx=None): sid = ShellSession.sanitize(ctx) if ctx else "" if sid: return os.path.join(self.config.profiles_dir, "%s.session.%s.json" % (name, sid)) return os.path.join(self.config.profiles_dir, name + ".session.json") def list_profiles(self): names = [] for entry in sorted(os.listdir(self.config.profiles_dir)): if entry.endswith(".sys.md") and ".bot." not in entry: names.append(entry[:-7]) return names def unseal_file(self, raw): if raw.startswith(Seal.PREFIX): if not self.seal.enabled: raise SealError("profile is sealed, set TAI_PASSPHRASE") return self.seal.unlock(raw) return raw def load_system(self, name): path = self.profile_path(name) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as handle: return self.unseal_file(handle.read()) except (OSError, ValueError): quarantine(path) return None except SealError: if not self.seal.enabled: raise quarantine(path) return None def save_system(self, name, text): try: sealed_write(self.profile_path(name), self.seal.lock(text)) except OSError: pass def load_session(self, name, bot="main", ctx=None): path = self.session_path(name, ctx=ctx) if bot == "main" else self.bot_session_path(name, bot, ctx=ctx) if not os.path.exists(path): return [] try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() data = json.loads(self.unseal_file(raw)) return [item for item in data if isinstance(item, dict) and item.get("role") in ("user", "assistant", "tool")] except (OSError, ValueError): quarantine(path) return [] except SealError: if not self.seal.enabled: raise quarantine(path) return [] def save_session(self, name, messages, bot="main", ctx=None): path = self.session_path(name, ctx=ctx) if bot == "main" else self.bot_session_path(name, bot, ctx=ctx) try: sealed_write(path, self.seal.lock(json.dumps([item for item in messages if item.get("role") != "system"]))) except OSError: pass if ShellSession.sanitize(ctx) if ctx else "": stem = name if bot == "main" else "%s.bot.%s" % (name, bot) self.prune_ctx_files("%s.session." % stem, "%s.session.json" % stem) def prune_ctx_files(self, prefix, legacy, cap=0): try: entries = [entry for entry in os.listdir(self.config.profiles_dir) if entry.startswith(prefix) and entry.endswith(".json") and entry != legacy] except OSError: return keep = cap or ShellSession.FILES_CAP if len(entries) <= keep: return paths = [os.path.join(self.config.profiles_dir, entry) for entry in entries] try: paths.sort(key=lambda item: os.path.getmtime(item)) except OSError: return for stale in paths[:len(paths) - keep]: try: os.remove(stale) except OSError: pass def load_dir_defaults(self, directory): try: row = self.db.execute("SELECT provider, model, bot FROM dir_defaults WHERE dir = ?", (directory,)).fetchone() except sqlite3.Error: return {} if not row: return {} return {key: value for key, value in zip(("provider", "model", "bot"), row) if value} def save_dir_defaults(self, directory, provider=None, model=None, bot=None, clear=()): if not directory: return current = self.load_dir_defaults(directory) for key, value in (("provider", provider), ("model", model), ("bot", bot)): if value: current[key] = value for key in clear or (): current.pop(key, None) try: if not current: self.db.execute("DELETE FROM dir_defaults WHERE dir = ?", (directory,)) else: self.db.execute( "INSERT OR REPLACE INTO dir_defaults (dir, provider, model, bot, updated) VALUES (?, ?, ?, ?, ?)", (directory, current.get("provider", ""), current.get("model", ""), current.get("bot", ""), now_iso())) self.db.execute( "DELETE FROM dir_defaults WHERE dir NOT IN (SELECT dir FROM dir_defaults ORDER BY updated DESC LIMIT ?)", (DIR_DEFAULTS_CAP,)) self.db.commit() except sqlite3.Error: pass def turn_state_path(self, profile, bot="main", ctx=None): sid = ShellSession.sanitize(ctx) if ctx else "" stem = profile if bot == "main" else "%s.bot.%s" % (profile, bot) if sid: return os.path.join(self.config.profiles_dir, "%s.turn.%s.json" % (stem, sid)) return os.path.join(self.config.profiles_dir, "%s.turn.json" % stem) def load_turn_state(self, profile, bot="main", ctx=None): path = self.turn_state_path(profile, bot, ctx=ctx) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.loads(self.unseal_file(handle.read())) return data if isinstance(data, dict) else {} except (OSError, ValueError): quarantine(path) return {} except SealError: if not self.seal.enabled: raise quarantine(path) return {} def save_turn_state(self, profile, bot, state, ctx=None): try: sealed_write(self.turn_state_path(profile, bot, ctx=ctx), self.seal.lock(json.dumps(state))) except OSError: pass if ShellSession.sanitize(ctx) if ctx else "": stem = profile if bot == "main" else "%s.bot.%s" % (profile, bot) self.prune_ctx_files("%s.turn." % stem, "%s.turn.json" % stem) def bot_system_path(self, profile, bot): return os.path.join(self.config.profiles_dir, "%s.bot.%s.sys.md" % (profile, bot)) def bot_session_path(self, profile, bot, ctx=None): sid = ShellSession.sanitize(ctx) if ctx else "" if sid: return os.path.join(self.config.profiles_dir, "%s.bot.%s.session.%s.json" % (profile, bot, sid)) return os.path.join(self.config.profiles_dir, "%s.bot.%s.session.json" % (profile, bot)) def bots_registry_path(self, profile): return os.path.join(self.config.profiles_dir, "%s.bots.json" % profile) def load_bots(self, profile): path = self.bots_registry_path(profile) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.loads(self.unseal_file(handle.read())) bots = data.get("bots") if isinstance(data, dict) else None return bots if isinstance(bots, dict) else {} except (OSError, ValueError): quarantine(path) return {} except SealError: if not self.seal.enabled: raise quarantine(path) return {} def save_bots(self, profile, bots): try: sealed_write(self.bots_registry_path(profile), self.seal.lock(json.dumps({"bots": bots}))) except OSError: pass def list_bots(self, profile): bots = self.load_bots(profile) found = [{"name": "main", "nicknames": []}] for name in sorted(bots): found.append({"name": name, "nicknames": (bots.get(name) or {}).get("nicknames", [])}) return found def resolve_bot(self, profile, mention): want = str(mention or "").strip().lower() if want in ("", "main"): return "main" bots = self.load_bots(profile) if want in bots: return want for name in sorted(bots): if want in (bots.get(name) or {}).get("nicknames", []): return name return None def load_bot_system(self, profile, bot): if bot == "main": return self.load_system(profile) path = self.bot_system_path(profile, bot) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as handle: return self.unseal_file(handle.read()) except (OSError, ValueError): quarantine(path) return None except SealError: if not self.seal.enabled: raise quarantine(path) return None def save_bot_system(self, profile, bot, text): try: sealed_write(self.bot_system_path(profile, bot), self.seal.lock(text)) except OSError: pass def close(self): try: self.db.execute("PRAGMA wal_checkpoint(TRUNCATE)") except sqlite3.Error: pass try: self.db.close() except sqlite3.Error: pass if getattr(self, "memdb", None) is not None: try: self.memdb.close() except sqlite3.Error: pass self.memdb = None class BackendError(Exception): def __init__(self, message, status=0): super().__init__(message) self.status = status def muse_binary(): return os.environ.get("MUSE_BIN") or shutil.which("muse") def opencode_binary(): return os.environ.get("OPENCODE_BIN") or shutil.which("opencode") def grok_binary(): return os.environ.get("GROK_BIN") or shutil.which("grok") or shutil.which("grokcli") def claude_binary(): return os.environ.get("CLAUDE_BIN") or shutil.which("claude") def codex_binary(): return os.environ.get("CODEX_BIN") or shutil.which("codex") def gemini_binary(): return os.environ.get("GEMINI_BIN") or shutil.which("gemini") def opencode_prompt(messages, tools): schema = json.dumps(muse_output_schema()) rules = ( "You are the model behind another agent. The transcript above is the whole conversation; lines starting with TOOL are results of tool calls that already ran. " "Do not use any of your own tools and do not repeat a tool call whose result is already shown. " "If the results answer the user, put the final answer in content with an empty tool_calls list; otherwise request the next tool calls from TOOLS. " "An empty tool_calls list ends the turn at once, nothing else runs. " "Never narrate a planned action without its tool call in the same reply. " "Reply with one JSON object only, no code fence, matching this schema: " ) return muse_prompt(messages, tools) + "\n\n" + rules + schema def opencode_find_answer(text): # Scan every "{" last-first and decode one value at each spot: free # models often echo the prompt schema or tool JSON before the real # answer, and a first-"{"-to-last-"}" slice spans both into garbage. # Last-first means the model's own trailing answer wins over echoes. decoder = json.JSONDecoder() starts = [pos for pos, char in enumerate(text) if char == "{"] for pos in reversed(starts[-OPENCODE_PARSE_MAX_SCANS:]): try: answer, _end = decoder.raw_decode(text, pos) except ValueError: continue if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer): return answer return None def opencode_parse(text): text = (text or "").strip() fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.S) if fenced: text = fenced.group(1) return opencode_find_answer(text) or {"content": text, "tool_calls": []} def cli_json_repair_prompt(messages, tools, bad_text): return opencode_prompt(messages, tools) + ( "\n\nYour previous reply contained no JSON object, so no tool ran and the turn is about to end. " "Reply now with exactly one JSON object matching the schema above and no other text. " "Previous reply for reference:\n" + (bad_text or "").strip()[:2000] ) def opencode_parse_events(stdout_text, returncode, stderr_text): """Shared by opencode_run() (local CLI) and box_opencode_run() (same CLI, executed inside the sandbox box) -- the JSON-lines event stream is identical either way, only how the process gets launched differs.""" texts = [] for line in stdout_text.splitlines(): try: event = json.loads(line) except ValueError: continue if not isinstance(event, dict): continue if event.get("type") == "error": data = (event.get("error") or {}).get("data") or {} raise BackendError("opencode: %s" % (data.get("message") or json.dumps(event.get("error"))[:300]), data.get("statusCode") or 0) part = event.get("part") or {} if event.get("type") == "text" and part.get("text"): texts.append(part["text"]) if not texts: raise BackendError("opencode returned no text (exit %d): %s" % (returncode, (stderr_text or stdout_text)[-300:])) return "".join(texts) class Opencode: """Opencode CLI runner (static; tests patch opencode_run live).""" @staticmethod def opencode_run(model, prompt, timeout): binary = opencode_binary() if not binary: raise BackendError("opencode CLI not found (OPENCODE_BIN or PATH)") argv = [binary, "run", "--pure", "--agent", OPENCODE_AGENT, "--format", "json", "-m", "opencode/" + model] with tempfile.TemporaryDirectory(prefix="tai-opencode-") as folder: try: done = subprocess.run(argv, input=prompt, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=folder, timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("opencode run timed out after %ds" % timeout) except OSError as exc: raise BackendError("opencode run failed: %s" % short_error(exc)) return opencode_parse_events(done.stdout, done.returncode, done.stderr) def opencode_run(model, prompt, timeout): return Opencode.opencode_run(model, prompt, timeout) def opencode_free_models(): binary = opencode_binary() if not binary: return None try: done = subprocess.run([binary, "models", "opencode"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=MODEL_LIST_TIMEOUT * 4) except (OSError, subprocess.TimeoutExpired): return None models = [line.strip().split("/", 1)[1] for line in done.stdout.splitlines() if line.strip().startswith("opencode/")] return [item for item in models if is_free_model(item)] or None def _cli_unknown_flag(stderr): return bool(re.search(r"unknown (option|flag|command)|unrecognised|unrecognized|invalid flag|not found|no such option", stderr or "", re.IGNORECASE)) def _cli_auth_failure(text): return bool(re.search(r"not (logged in|authenticated)|auth|api key|unauthorized|401|login required|please (login|log in|authenticate)", text or "", re.IGNORECASE)) def _cli_run(argv, prompt_input, timeout, label): try: done = subprocess.run(argv, input=prompt_input, stdin=subprocess.DEVNULL if prompt_input is None else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("%s run timed out after %ds" % (label, timeout)) except OSError as exc: raise BackendError("%s run failed: %s" % (label, short_error(exc))) return done def grok_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return opencode_parse(stdout) if isinstance(data, dict): if data.get("is_error") or data.get("error"): detail = data.get("error") or data.get("result") or data.get("text") or "unknown error" raise BackendError("grok: %s" % str(detail)[:300]) if isinstance(data.get("content"), str) or isinstance(data.get("tool_calls"), list): if "content" in data or "tool_calls" in data: return data for key in ("text", "result", "output", "answer", "content"): value = data.get(key) if isinstance(value, str) and value.strip(): return opencode_parse(value) return opencode_parse(stdout) def grok_run(prompt, timeout): binary = grok_binary() if not binary: raise BackendError("grok CLI not found (GROK_BIN or PATH)") with tempfile.TemporaryDirectory(prefix="tai-grok-") as folder: prompt_path = os.path.join(folder, "prompt.txt") with open(prompt_path, "w", encoding="utf-8") as handle: handle.write(prompt) variants = [ [binary, "--prompt-file", prompt_path, "--output-format", "json", "--no-auto-update"], [binary, "--prompt-file", prompt_path, "--output-format", "json"], [binary, "-p", prompt, "--output-format", "json"], [binary, "-p", prompt], ] last_error = "" for argv in variants: done = _cli_run(argv, None, timeout, "grok") if done.returncode != 0 and _cli_unknown_flag(done.stderr) and argv is not variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("grok not authenticated (set XAI_API_KEY or run grok login): %s" % blob[:200]) raise BackendError("grok exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("grok returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("grok failed: %s" % (last_error or "no usable output")) def claude_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return opencode_parse(stdout) if not isinstance(data, dict): return opencode_parse(stdout) if data.get("is_error"): raise BackendError("claude: %s" % str(data.get("result") or "unknown error")[:300]) structured = data.get("structured_output") if isinstance(structured, dict) and ("content" in structured or "tool_calls" in structured): return structured result = data.get("result") if isinstance(result, str) and result.strip(): return opencode_parse(result) return opencode_parse(stdout) def claude_run(prompt, timeout): binary = claude_binary() if not binary: raise BackendError("claude CLI not found (CLAUDE_BIN or PATH)") schema = json.dumps(muse_output_schema()) base = [binary, "-p"] if len(prompt.encode("utf-8", "replace")) > 120000: variants = [ (base + ["--output-format", "json", "--json-schema", schema], prompt), (base + ["--output-format", "json"], prompt), ] else: variants = [ (base + [prompt, "--output-format", "json", "--json-schema", schema], None), (base + [prompt, "--output-format", "json"], None), (base + [prompt], None), ] last_error = "" for argv, prompt_input in variants: done = _cli_run(argv, prompt_input, timeout, "claude") if done.returncode != 0 and _cli_unknown_flag(done.stderr) and (argv, prompt_input) != variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("claude not authenticated (run claude login): %s" % blob[:200]) raise BackendError("claude exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("claude returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("claude failed: %s" % (last_error or "no usable output")) def codex_jsonl_texts(stdout): texts = [] last_error = "" for line in (stdout or "").splitlines(): line = line.strip() if not line.startswith("{"): continue try: event = json.loads(line) except ValueError: continue if not isinstance(event, dict): continue etype = event.get("type") if etype == "item.completed": item = event.get("item") or {} if item.get("type") == "agent_message" and item.get("text"): texts.append(item["text"]) elif etype in ("turn.failed", "error"): msg = event.get("message") or event.get("error") or "" if isinstance(msg, dict): msg = msg.get("message") or json.dumps(msg) if msg: last_error = str(msg)[:300] return texts, last_error def codex_parse_output(stdout, outfile_text="", used_schema=False): if isinstance(outfile_text, str) and outfile_text.strip(): if used_schema: try: answer = json.loads(outfile_text) except ValueError: answer = None if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer): return answer return opencode_parse(outfile_text) texts, last_error = codex_jsonl_texts(stdout) if texts: return opencode_parse("".join(texts)) if last_error: raise BackendError("codex: %s" % last_error) return opencode_parse(stdout) def codex_sandbox_args(): """Codex CLI sandbox flags. Default off; set TAI_CODEX_SANDBOX=1 to re-enable.""" if (os.environ.get("TAI_CODEX_SANDBOX") or "").strip().lower() in ("1", "true", "yes", "on", "read-only", "readonly"): return ["--sandbox", "read-only"] return [] def codex_run(prompt, timeout): binary = codex_binary() if not binary: raise BackendError("codex CLI not found (CODEX_BIN or PATH)") schema = json.dumps(muse_output_schema()) with tempfile.TemporaryDirectory(prefix="tai-codex-") as folder: schema_path = os.path.join(folder, "schema.json") out_path = os.path.join(folder, "last.txt") with open(schema_path, "w", encoding="utf-8") as handle: handle.write(schema) base = [binary, "exec", "--json", "--ephemeral"] + codex_sandbox_args() + ["--skip-git-repo-check"] if len(prompt.encode("utf-8", "replace")) > 120000: variants = [ (base + ["--output-schema", schema_path, "-o", out_path, "-"], prompt, True), (base + ["-o", out_path, "-"], prompt, False), ] else: variants = [ (base + ["--output-schema", schema_path, "-o", out_path, prompt], None, True), (base + ["-o", out_path, prompt], None, False), (base + [prompt], None, False), ] last_error = "" for argv, prompt_input, used_schema in variants: try: done = subprocess.run(argv, input=prompt_input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=folder, timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("codex run timed out after %ds" % timeout) except OSError as exc: raise BackendError("codex run failed: %s" % short_error(exc)) if done.returncode != 0 and _cli_unknown_flag(done.stderr) and (argv, prompt_input, used_schema) != variants[-1]: last_error = (done.stderr or "").strip()[:200] continue outfile_text = "" if "-o" in argv: try: with open(out_path, "r", encoding="utf-8", errors="replace") as handle: outfile_text = handle.read() except OSError: outfile_text = "" if done.returncode != 0 and not (outfile_text.strip() or (done.stdout or "").strip()): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("codex not authenticated (run codex login): %s" % blob[:200]) raise BackendError("codex exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (outfile_text.strip() or (done.stdout or "").strip()): blob = (done.stderr or "").strip() raise BackendError("codex returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "", outfile_text, used_schema raise BackendError("codex failed: %s" % (last_error or "no usable output")) def gemini_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return opencode_parse(stdout) if not isinstance(data, dict): return opencode_parse(stdout) err = data.get("error") if err: raise BackendError("gemini: %s" % (json.dumps(err) if isinstance(err, dict) else str(err))[:300]) response = data.get("response") if isinstance(response, str) and response.strip(): return opencode_parse(response) return opencode_parse(stdout) def gemini_plan_enabled(): """Gemini plan-mode variant. Default off; set TAI_GEMINI_PLAN=1 to re-enable.""" return (os.environ.get("TAI_GEMINI_PLAN") or "").strip().lower() in ("1", "true", "yes", "on", "plan") def gemini_run(prompt, timeout): binary = gemini_binary() if not binary: raise BackendError("gemini CLI not found (GEMINI_BIN or PATH)") base = [binary, "-p", prompt] variants = [] if gemini_plan_enabled(): variants.append(base + ["--output-format", "json", "--approval-mode", "plan"]) variants.extend([ base + ["--output-format", "json"], base, ]) last_error = "" for argv in variants: done = _cli_run(argv, None, timeout, "gemini") if done.returncode != 0 and _cli_unknown_flag(done.stderr) and argv is not variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("gemini not authenticated (run gemini login or set GEMINI_API_KEY): %s" % blob[:200]) hint = {42: "input error", 53: "turn limit exceeded"}.get(done.returncode, "") raise BackendError("gemini exit %d%s: %s" % (done.returncode, " (%s)" % hint if hint else "", blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("gemini returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("gemini failed: %s" % (last_error or "no usable output")) def muse_output_schema(): call = {"type": "object", "additionalProperties": False, "properties": {"name": {"type": "string"}, "arguments": {"type": "string"}}, "required": ["name", "arguments"]} return {"type": "object", "additionalProperties": False, "properties": {"content": {"type": "string"}, "tool_calls": {"type": "array", "items": call}}, "required": ["content", "tool_calls"]} def muse_prompt(messages, tools): parts = [] for message in messages or []: role = (message.get("role") or "user").upper() body = message.get("content") or "" if isinstance(body, list): body = " ".join(str(part.get("text") or "") for part in body if isinstance(part, dict)) if message.get("tool_call_id"): parts.append("%s %s:\n%s" % (role, message["tool_call_id"], body)) else: parts.append("%s:\n%s" % (role, body)) for call in message.get("tool_calls") or []: func = call.get("function") or {} parts.append("TOOL CALL %s:\n%s %s" % (call.get("id") or "", func.get("name") or "", func.get("arguments") or "")) if tools: parts.append("TOOLS:\n%s" % json.dumps(tools)) parts.append("Answer with content text plus tool_calls for every tool to invoke now. Empty tool_calls ends the turn.") parts.append("Reply with data only: never invoke assistant or harness tools yourself. The TOOLS block above is response vocabulary, not executable tools.") return "\n\n".join(part for part in parts if part.strip()) def muse_argv(config, schema_path, prompt_path, session_id=None): argv = [muse_binary(), "exec", "--json", "--max-model-steps", str(MUSE_MAX_STEPS), "--disable-web-tools", "--disable-reminders", "--disable-shell", "--disable-write", "--output-schema", schema_path, "--prompt-file", prompt_path] if session_id is None: argv.append("--no-session-log") else: argv.extend(["--allow-workspace-switch", "--session-id", session_id]) if config.explicit_model: argv.extend(["--model", config.explicit_model]) return argv def muse_write_file(path, text): with open(path, "w", encoding="utf-8") as handle: handle.write(text) handle.flush() os.fsync(handle.fileno()) def muse_message_hash(message): blob = json.dumps(message, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def muse_map_path(home): return os.path.join(home, MUSE_SESSIONS_FILE) def muse_load_map(home): path = muse_map_path(home) try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) except FileNotFoundError: return {} except (OSError, ValueError): quarantine(path) return {} if not isinstance(data, dict): quarantine(path) return {} now = time.time() pruned = False for key in list(data): entry = data[key] if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not isinstance(entry.get("hashes"), list): del data[key] pruned = True elif key.startswith("worker/"): try: stamp = _fromisoformat(entry.get("updated") or "").timestamp() except ValueError: stamp = 0 if now - stamp > MUSE_WORKER_TTL: del data[key] pruned = True if pruned: muse_save_map(home, data) return data def muse_save_map(home, data): muse_write_file(muse_map_path(home), json.dumps(data, sort_keys=True) + "\n") _MUSE_THREAD_LOCKS = {} _MUSE_THREAD_GUARD = threading.Lock() def muse_acquire(home, name): with _MUSE_THREAD_GUARD: thread_lock = _MUSE_THREAD_LOCKS.setdefault(name, threading.Lock()) thread_lock.acquire() handle = None if fcntl is not None: try: folder = os.path.join(home, MUSE_LOCKS_DIR) os.makedirs(folder, exist_ok=True) handle = open(os.path.join(folder, hashlib.sha256(name.encode("utf-8")).hexdigest() + ".lock"), "w") fcntl.flock(handle.fileno(), fcntl.LOCK_EX) except OSError: if handle is not None: try: handle.close() except OSError: pass handle = None def release(): try: if handle is not None: if fcntl is not None: try: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) except OSError: pass try: handle.close() except OSError: pass finally: thread_lock.release() return release def muse_transact(home, func): release = muse_acquire(home, "map") try: data = muse_load_map(home) result = func(data) muse_save_map(home, data) return result finally: release() def muse_session_root(): base = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share") return os.path.join(base, "muse", "sessions") def muse_session_alive(session_id): try: pattern = os.path.join(muse_session_root(), "*", "*", "*", session_id, "session.jsonl") return bool(glob.glob(pattern)) except (OSError, ValueError): return True def muse_session_split(messages, hashes): if len(hashes) > len(messages): return None for pos, digest in enumerate(hashes): if muse_message_hash(messages[pos]) != digest: return None return list(messages[len(hashes):]) def muse_valid_answer(text): try: data = json.loads(text) except ValueError: return None if not isinstance(data, dict): return None if not isinstance(data.get("content"), str) or not isinstance(data.get("tool_calls"), list): return None for call in data["tool_calls"]: if not isinstance(call, dict) or not isinstance(call.get("name"), str) or not isinstance(call.get("arguments"), str): return None return data def muse_gate(func): def gated(ok): func(ok) gated.armed = threading.Event() return gated def muse_reap(proc, stderr_done, on_reap): def target(): ok = False try: try: for _line in proc.stdout: pass except (OSError, ValueError): pass try: proc.stdout.close() except (OSError, ValueError): pass try: ok = proc.wait(timeout=MUSE_REAP_GRACE) == 0 except subprocess.TimeoutExpired: try: proc.kill() except OSError: pass try: proc.wait(timeout=10) except (OSError, subprocess.SubprocessError): pass try: stderr_done.wait(timeout=10) except (OSError, RuntimeError): pass finally: if on_reap is not None: try: on_reap(ok) except Exception: pass thread = threading.Thread(target=target, daemon=True) thread.start() return thread def muse_run(argv, cwd, stream_sink, timeout, on_reap=None): try: proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=cwd, bufsize=1) except OSError as exc: raise BackendError("muse exec failed: %s" % short_error(exc)) gate = getattr(on_reap, "armed", None) if gate is not None: gate.set() stderr_parts = [] stderr_done = threading.Event() def collect_stderr(): try: for chunk in proc.stderr: stderr_parts.append(chunk) except (OSError, ValueError): pass finally: stderr_done.set() threading.Thread(target=collect_stderr, daemon=True).start() finished = threading.Event() expired = threading.Event() def watchdog(): if not finished.wait(timeout): expired.set() try: proc.kill() except OSError: pass threading.Thread(target=watchdog, daemon=True).start() answer = None terminal = None buffer = [] try: for line in proc.stdout: text = line.strip() if not text.startswith("{"): continue try: event = json.loads(text) except ValueError: continue kind = event.get("payload_type") or "" payload = event.get("payload") or {} if kind == "run.output.delta" and payload.get("text"): piece = payload["text"] if stream_sink is not None: stream_sink(piece) buffer.append(piece) if answer is None: answer = muse_valid_answer("".join(buffer)) if answer is not None: break elif kind.startswith("run.terminal."): terminal = payload break finally: finished.set() muse_reap(proc, stderr_done, on_reap) if terminal is not None: if terminal.get("terminal") != "completed": raise BackendError("muse %s: %s" % (terminal.get("terminal") or "failed", (terminal.get("reason") or "unknown")[:300])) parsed = muse_valid_answer(terminal.get("text") or "") if parsed is None: raise BackendError("muse returned invalid structured answer") return parsed if answer is not None: return answer if expired.is_set(): raise BackendError("muse timed out after %ds" % timeout) try: code = proc.returncode except (OSError, ValueError): code = "?" raise BackendError("muse exit %s: %s" % (code, "".join(stderr_parts).strip()[-300:] or "no terminal event")) def _cli_version_detail(done, fallback): return (done.stdout.strip() or done.stderr.strip()).splitlines()[0][:60] if (done.stdout.strip() or done.stderr.strip()) else fallback def _cli_version_only_health(binary, fallback_name): """Shared by the CLIs with no cheap, read-only way to check real login state (muse, grok): `--version` only proves the binary runs, never that it's actually authenticated, so this deliberately never claims "ok" or "subscription" -- a claim this check cannot back up (see codex_health/ claude_health for the two CLIs that DO expose a real status command, used instead of this). Caught a real bug: this used to print "ok, subscription" unconditionally on a successful --version, which is false whenever the CLI is installed but never logged in.""" try: done = subprocess.run([binary, "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % short_error(exc)[:60] if done.returncode != 0: return "fail: exit %d" % done.returncode return "cli found (%s), login unverified: no status command" % _cli_version_detail(done, fallback_name) def muse_health(): binary = muse_binary() if not binary: return "missing: muse CLI not found" return _cli_version_only_health(binary, "muse") def grok_health(): binary = grok_binary() if not binary: return "missing: grok CLI not found" return _cli_version_only_health(binary, "grok") def claude_health(): binary = claude_binary() if not binary: return "missing: claude CLI not found" try: done = subprocess.run([binary, "auth", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % short_error(exc)[:60] try: info = json.loads(done.stdout) except ValueError: info = None if isinstance(info, dict) and "loggedIn" in info: if info["loggedIn"]: return "ok, subscription (%s)" % (info.get("authMethod") or "logged in") return "not logged in" # `claude auth status` didn't return the expected shape (older/newer # CLI) -- fall back to the honest version-only signal instead of # claiming a subscription this check couldn't actually verify. return _cli_version_only_health(binary, "claude") def codex_health(): binary = codex_binary() if not binary: return "missing: codex CLI not found" try: done = subprocess.run([binary, "login", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % short_error(exc)[:60] detail = _cli_version_detail(done, "codex") if done.returncode != 0: return "not logged in: %s" % detail return "ok, subscription (%s)" % detail def gemini_health(): binary = gemini_binary() if not binary: return "missing: gemini CLI not found" return _cli_version_only_health(binary, "gemini") _OPENCODE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" _OPENCODE_SESSION = "ses_" + "".join(random.choice(_OPENCODE_ID_ALPHABET) for _ in range(26)) def opencode_headers(): return { "User-Agent": os.environ.get("OPENCODE_CLIENT_USER_AGENT") or OPENCODE_USER_AGENT, "x-opencode-client": os.environ.get("OPENCODE_CLIENT_NAME") or OPENCODE_CLIENT_NAME, "x-opencode-project": "global", "x-opencode-session": _OPENCODE_SESSION, "x-opencode-request": "msg_" + "".join(random.choice(_OPENCODE_ID_ALPHABET) for _ in range(26)), } def model_speed_reward(tokens_per_second): if not tokens_per_second or tokens_per_second <= 0: return MODEL_NEUTRAL_REWARD return tokens_per_second / (tokens_per_second + MODEL_SPEED_REF_TPS) def model_latency_reward(latency_ms): if not latency_ms or latency_ms <= 0: return 1.0 return 1.0 / (1.0 + latency_ms / MODEL_LATENCY_REF_MS) def model_outcome_reward(latency_ms=None, tokens_per_second=None): return model_speed_reward(tokens_per_second) * model_latency_reward(latency_ms) def model_weight(entry): entry = entry or {} attempts = (entry.get("s") or 0) + (entry.get("f") or 0) return ((entry.get("r") or 0.0) + MODEL_NEUTRAL_REWARD * MODEL_WEIGHT_PRIOR) / (attempts + MODEL_WEIGHT_PRIOR) class ModelHealth: def __init__(self, home): self.path = os.path.join(home, MODEL_HEALTH_FILE) if home else "" self.data = {"models": {}, "lists": {}} if self.path: try: with open(self.path, "r", encoding="utf-8") as handle: raw = json.load(handle) if isinstance(raw, dict): if isinstance(raw.get("models"), dict): self.data["models"] = raw["models"] if isinstance(raw.get("lists"), dict): self.data["lists"] = raw["lists"] except (OSError, ValueError): pass def _save(self): if not self.path: return try: tmp = self.path + ".tmp" with open(tmp, "w", encoding="utf-8") as handle: json.dump(self.data, handle) os.replace(tmp, self.path) except OSError: pass def entry(self, provider, model): return self.data["models"].get("%s|%s" % (provider or "default", model or "")) or {} def weight(self, provider, model): return model_weight(self.entry(provider, model)) def circuit_open(self, provider, model): return (self.entry(provider, model).get("until") or 0.0) > time.time() def record(self, provider, model, success, latency_ms=None, tokens_per_second=None): if not model: return key = "%s|%s" % (provider or "default", model or "") entry = self.data["models"].setdefault(key, {"s": 0, "f": 0, "r": 0.0, "lat": 0.0, "tps": 0.0, "cf": 0, "until": 0.0, "used": 0.0, "n": 0}) entry["n"] += 1 entry["used"] = time.time() if success: reward = MODEL_NEUTRAL_REWARD if latency_ms is None and tokens_per_second is None else model_outcome_reward(latency_ms, tokens_per_second) entry["s"] += 1 entry["cf"] = 0 entry["until"] = 0.0 entry["r"] += reward if latency_ms is not None: entry["lat"] += latency_ms if tokens_per_second is not None: entry["tps"] += tokens_per_second else: entry["f"] += 1 entry["cf"] += 1 if entry["cf"] >= MODEL_CIRCUIT_THRESHOLD: entry["until"] = time.time() + MODEL_CIRCUIT_COOLDOWN self._save() def cached_list(self, name, ttl=OPENROUTER_FREE_CACHE_TTL): row = self.data["lists"].get(name) if not isinstance(row, dict): return None models = row.get("models") if not models or (time.time() - (row.get("at") or 0)) > ttl: return None return [str(item) for item in models if str(item).strip()] def store_list(self, name, models): cleaned = [str(item).strip() for item in models if str(item).strip()] if not cleaned: return self.data["lists"][name] = {"at": time.time(), "models": cleaned} self._save() def replace_list(self, name, provider, models): """Store a freshly discovered list and drop stats of this provider's models that are gone. Returns (added, removed).""" cleaned = [str(item).strip() for item in models if str(item).strip()] if not cleaned: return [], [] row = self.data["lists"].get(name) old = [str(item) for item in (row or {}).get("models") or []] if isinstance(row, dict) else [] prefix = provider + "|" for key in list(self.data["models"]): if key.startswith(prefix) and key[len(prefix):] not in cleaned: del self.data["models"][key] self.data["lists"][name] = {"at": time.time(), "models": cleaned} self._save() return [item for item in cleaned if item not in old], [item for item in old if item not in cleaned] def snapshot(self): rows = [] for key, entry in self.data["models"].items(): provider, _, model = key.partition("|") ok = entry.get("s") or 0 rows.append({ "provider": provider, "model": model, "weight": round(model_weight(entry), 4), "ok": ok, "fail": entry.get("f") or 0, "avg_ms": round((entry.get("lat") or 0.0) / ok) if ok and entry.get("lat") else None, "avg_tps": round((entry.get("tps") or 0.0) / ok, 1) if ok and entry.get("tps") else None, "circuit_open": (entry.get("until") or 0.0) > time.time(), "used": entry.get("used") or 0.0, }) rows.sort(key=lambda row: (-row["weight"], row["provider"], row["model"])) return rows def fetch_model_ids(models_url, key="", extra_headers=None, timeout=MODEL_LIST_TIMEOUT): headers = dict(extra_headers or {}) if key: headers["Authorization"] = "Bearer " + key try: request = urllib.request.Request(models_url, headers=headers, method="GET") with urllib.request.urlopen(request, timeout=timeout) as response: payload = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError): return None rows = payload.get("data") if isinstance(payload, dict) else None if not isinstance(rows, list): return None ids = [str(row.get("id")).strip() for row in rows if isinstance(row, dict) and str(row.get("id") or "").strip()] return ids or None def fetch_pollinations_free(): """Anonymous-tier model names from pollinations; None when unreachable.""" try: with urllib.request.urlopen(urllib.request.Request(POLLINATIONS_LIST_URL, method="GET"), timeout=MODEL_LIST_TIMEOUT) as response: rows = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError): return None if not isinstance(rows, list): return None names = [str(row.get("name")).strip() for row in rows if isinstance(row, dict) and row.get("tier") == "anonymous" and row.get("name") and (row.get("output_modalities") or ["text"]) == ["text"]] return names or None def is_free_openrouter(model): return model.endswith(":free") or model == "openrouter/free" def embedded_failure_message(data): if not isinstance(data, dict): return "" error = data.get("error") if isinstance(error, dict): message = error.get("message") or error.get("type") or "" return str(message).strip() if isinstance(error, str) and error.strip(): return error.strip() if data.get("type") == "error": return str(data.get("message") or "upstream error").strip() return "" def is_free_model(model): model = (model or "").lower() return model.endswith(":free") or model.endswith("-free") def prefer_muse(models): return sorted(models, key=lambda item: 0 if MUSE_LABEL in item.lower() else 1) def openrouter_free_tier(cand): model = (cand.get("model") or "").lower() muse = 0 if MUSE_LABEL in model else 1 if cand.get("provider") == OPENCODE_LABEL: return muse if cand.get("provider") == DEVPLACE_LABEL: return 2 + muse if cand.get("provider") == OPENROUTER_LABEL and is_free_model(model): return 4 + muse return 6 def models_report(chat): lines = ["label/model | weight ok/fail avg-ms avg-tps status"] seen = set() for cand in chat.ordered_candidates(): key = (cand["provider"], cand["model"]) if key in seen: continue seen.add(key) entry = chat.health.entry(cand["provider"], cand["model"]) ok = entry.get("s") or 0 fail = entry.get("f") or 0 avg_ms = str(round((entry.get("lat") or 0.0) / ok)) if ok and entry.get("lat") else "-" avg_tps = str(round((entry.get("tps") or 0.0) / ok, 1)) if ok and entry.get("tps") else "-" if cand["chat_url"] == MUSE_CHAT_URL: status = muse_health() elif chat.health.circuit_open(cand["provider"], cand["model"]): status = "circuit open" elif not ok and not fail: status = "untested" else: status = "ready" lines.append("%s/%s | %.4f %d/%d %s %s %s" % (cand["provider"], cand["model"], model_weight(entry), ok, fail, avg_ms, avg_tps, status)) return "\n".join(lines) class ChatClient: def __init__(self, config): self.config = config self.scope = ("default", "main") self.fixed_key = None self.provider_pin = None self.round_transient = False self.pin_fallback_said = False self.model_pin = None self.prefer = (DEVPLACE_LABEL, PREFERRED_MODEL) self.health = ModelHealth(getattr(config, "home", "") or "") def muse_key(self): if self.fixed_key is not None: return self.fixed_key return "%s/%s" % (self.scope[0], self.scope[1]) def reset_muse(self): home = getattr(self.config, "home", None) if home is None: return key = self.muse_key() def drop(data): data.pop(key, None) muse_transact(home, drop) def openrouter_models(self): models = [item for item in getattr(self.config, "openrouter_models", []) if item] explicit = getattr(self.config, "explicit_model", "") if explicit and explicit not in models: models.insert(0, explicit) free = self.health.cached_list("openrouter:free") if free is None: fetched = fetch_model_ids(OPENROUTER_BASE + "/models", self.config.openrouter_key) free = [item for item in fetched if is_free_openrouter(item)] if fetched else [] if free: self.health.store_list("openrouter:free", free) free = prefer_muse(free or []) models = [item for item in free if item not in models] + models if not models: models = [OPENROUTER_MODEL] return models def update_models(self): """Rediscover every model list from its source. A source that cannot be reached keeps its old list (reported, never emptied).""" sources = [("opencode:free", OPENCODE_LABEL, lambda: opencode_free_models() if opencode_binary() else None)] if getattr(self.config, "openrouter_enabled", True): def openrouter(): fetched = fetch_model_ids(OPENROUTER_BASE + "/models", self.config.openrouter_key) return [item for item in fetched if is_free_openrouter(item)] if fetched else None sources.append(("openrouter:free", OPENROUTER_LABEL, openrouter)) if self.config.devplace_key: def devplace(): fetched = fetch_model_ids(self.config.devplace_base + "/models", self.config.devplace_key) if not fetched: return None return [item for item in fetched if item in DEVPLACE_FREE_ROUTES or is_free_model(item)] sources.append(("devplace:free", DEVPLACE_LABEL, devplace)) if self.config.zen_enabled: sources.append(("zen:free", ZEN_LABEL, lambda: [item for item in fetch_model_ids(ZEN_BASE + "/models", extra_headers=opencode_headers()) or [] if is_free_model(item)] or None)) if self.config.pollinations_enabled: sources.append(("pollinations:free", POLLINATIONS_LABEL, fetch_pollinations_free)) if getattr(self.config, "ollama_enabled", True) is not False: sources.append(("ollama:local", OLLAMA_LABEL, lambda: fetch_model_ids(OLLAMA_BASE + "/models", "", timeout=3))) report = [] for name, label, fetch in sources: try: fresh = fetch() except Exception as exc: fresh = None print(paint("%s: discovery failed: %s" % (label, short_error(exc)), Ansi.DIM), file=sys.stderr) if not fresh: report.append((label, None, [], [])) continue added, removed = self.health.replace_list(name, label, fresh) report.append((label, len(fresh), added, removed)) return report def opencode_models(self): models = list(getattr(self.config, "opencode_models", []) or []) if not models: models = self.health.cached_list("opencode:free") if models is None: models = opencode_free_models() or [item for item in ZEN_FREE_MODELS if is_free_model(item)] self.health.store_list("opencode:free", models) return prefer_muse(models) def zen_models(self): models = list(getattr(self.config, "zen_models", []) or []) if models: return prefer_muse(models) cached = self.health.cached_list("zen:free") if cached is None: fetched = [item for item in fetch_model_ids(ZEN_BASE + "/models", extra_headers=opencode_headers()) or [] if is_free_model(item)] cached = fetched or list(ZEN_FREE_MODELS) if fetched: self.health.store_list("zen:free", fetched) return prefer_muse(cached) def pollinations_models(self): models = list(getattr(self.config, "pollinations_models", []) or []) if models: return models cached = self.health.cached_list("pollinations:free") if cached is None: fetched = fetch_pollinations_free() cached = fetched or list(POLLINATIONS_MODELS) if fetched: self.health.store_list("pollinations:free", fetched) return cached def ollama_models(self): if getattr(self.config, "ollama_enabled", True) is False: return [] models = list(getattr(self.config, "ollama_models", []) or []) if not models: models = self.health.cached_list("ollama:local", OLLAMA_LIST_TTL) if models is None: models = fetch_model_ids(OLLAMA_BASE + "/models", "", timeout=3) or [] if models: self.health.store_list("ollama:local", models) return models def devplace_models(self): models = list(getattr(self.config, "devplace_models", []) or []) if models: return prefer_muse(models) free = self.health.cached_list("devplace:free") if free is None: fetched = fetch_model_ids(self.config.devplace_base + "/models", self.config.devplace_key) if fetched is None: return list(DEVPLACE_FREE_ROUTES) free = [item for item in fetched if item in DEVPLACE_FREE_ROUTES or is_free_model(item)] if free: self.health.store_list("devplace:free", free) return prefer_muse(free or []) def candidates(self): found = [] if getattr(self.config, "opencode_enabled", True) and opencode_binary(): for model in self.opencode_models(): found.append({"provider": OPENCODE_LABEL, "chat_url": OPENCODE_CHAT_URL, "models_url": OPENCODE_MODELS_URL, "key": "", "model": model, "profile": ""}) for model in self.ollama_models(): found.append({"provider": OLLAMA_LABEL, "chat_url": OLLAMA_BASE + "/chat/completions", "models_url": OLLAMA_BASE + "/models", "key": "", "model": model, "profile": ""}) if getattr(self.config, "devplace_key", ""): for model in self.devplace_models(): found.append({"provider": DEVPLACE_LABEL, "chat_url": self.config.devplace_base + "/chat/completions", "models_url": self.config.devplace_base + "/models", "key": self.config.devplace_key, "model": model, "profile": ""}) for entry in self.config.backends: for model in split_models(entry.get("model")) or [self.config.model]: found.append({"provider": entry["label"], "chat_url": entry["base"] + "/chat/completions", "models_url": entry["base"] + "/models", "key": entry["key"], "model": model, "profile": entry.get("profile") or ""}) if getattr(self.config, "openrouter_enabled", True): for model in self.openrouter_models(): found.append({"provider": OPENROUTER_LABEL, "chat_url": OPENROUTER_BASE + "/chat/completions", "models_url": OPENROUTER_BASE + "/models", "key": self.config.openrouter_key, "model": model, "profile": ""}) if getattr(self.config, "pollinations_enabled", True): for model in self.pollinations_models(): found.append({"provider": POLLINATIONS_LABEL, "chat_url": POLLINATIONS_BASE + "/chat/completions", "models_url": POLLINATIONS_BASE + "/models", "key": "", "model": model, "profile": ""}) if getattr(self.config, "zen_enabled", False): for model in self.zen_models(): found.append({"provider": ZEN_LABEL, "chat_url": ZEN_BASE + "/chat/completions", "models_url": ZEN_BASE + "/models", "key": "", "model": model, "profile": "opencode"}) found.append({"provider": MUSE_LABEL, "chat_url": MUSE_CHAT_URL, "models_url": MUSE_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if grok_binary(): found.append({"provider": GROK_LABEL, "chat_url": GROK_CHAT_URL, "models_url": GROK_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if claude_binary(): found.append({"provider": CLAUDE_LABEL, "chat_url": CLAUDE_CHAT_URL, "models_url": CLAUDE_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if codex_binary(): found.append({"provider": CODEX_LABEL, "chat_url": CODEX_CHAT_URL, "models_url": CODEX_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if gemini_binary(): found.append({"provider": GEMINI_LABEL, "chat_url": GEMINI_CHAT_URL, "models_url": GEMINI_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) return found def ordered_candidates(self): http = [cand for cand in self.candidates() if cand["chat_url"] not in CLI_CHAT_URLS] cli = [cand for cand in self.candidates() if cand["chat_url"] in CLI_CHAT_URLS] ranked = sorted(enumerate(http), key=lambda pair: (openrouter_free_tier(pair[1]), -self.health.weight(pair[1]["provider"], pair[1]["model"]), pair[0])) usable = [cand for _, cand in ranked if not self.health.circuit_open(cand["provider"], cand["model"])] return self._apply_pins((usable or [cand for _, cand in ranked]) + cli) def _apply_pins(self, cands): pinned = cands if self.provider_pin: pinned = [cand for cand in pinned if (cand["provider"] or "").lower() == self.provider_pin] if self.model_pin: pinned = [cand for cand in pinned if cand["model"] == self.model_pin] if not pinned and self.provider_pin == DEVPLACE_LABEL and self.model_pin and getattr(self.config, "devplace_key", ""): base = self.config.devplace_base pinned = [{"provider": DEVPLACE_LABEL, "chat_url": base + "/chat/completions", "models_url": base + "/models", "key": self.config.devplace_key, "model": self.model_pin, "profile": ""}] if not pinned and cands and not self.pin_fallback_said: self.pin_fallback_said = True print(paint("%s: trying every other backend" % self.pin_note(), Ansi.DIM), file=sys.stderr) cands = pinned + [cand for cand in cands if not any(cand is item for item in pinned)] if self.prefer and not self.provider_pin and not self.model_pin: label, model = self.prefer first = [cand for cand in cands if cand["provider"] == label and cand["model"] == model] if not first and label == DEVPLACE_LABEL and getattr(self.config, "devplace_key", ""): base = self.config.devplace_base first = [{"provider": DEVPLACE_LABEL, "chat_url": base + "/chat/completions", "models_url": base + "/models", "key": self.config.devplace_key, "model": model, "profile": ""}] cands = first + [cand for cand in cands if not any(cand is item for item in first)] return cands def pin_note(self): bits = [] if self.provider_pin: bits.append("provider '%s'" % self.provider_pin) if self.model_pin: bits.append("model '%s'" % self.model_pin) return "no backend offers " + " and ".join(bits) if bits else "no backends available" def backends(self): rows = [] seen = set() for cand in self.candidates(): key = (cand["provider"], cand["chat_url"]) if key in seen: continue seen.add(key) rows.append((cand["provider"], cand["chat_url"], cand["models_url"], cand["key"], cand["model"])) return rows def headers(self, key, extra=None): headers = {"Content-Type": "application/json"} if key: headers["Authorization"] = "Bearer " + key if extra: headers.update(extra) return headers @staticmethod def summarize_errors(errors, shown=2, width=320): """One-line summary of a failed retry round. Missing-CLI entries are expected on machines without those CLIs, so actionable failures lead and the missing-CLI tail never takes the only display slot (CLI candidates run last, and the muse candidate is always appended even when its binary is absent). """ if not errors: return "no details" actionable = [entry for entry in errors if "CLI not found" not in entry] pool = actionable or errors unique = [] for entry in pool: if entry not in unique: unique.append(entry) text = "; ".join(unique[:shown]) if len(unique) > shown: text += "; +%d more" % (len(unique) - shown) skipped = len(errors) - len(pool) if skipped: text += " (%d missing CLI skipped)" % skipped return text[:width] def complete(self, messages, tools=None, stream_sink=None, ephemeral=False, deadline=None): messages = clean_history(messages) payload = {"model": self.config.model, "messages": messages} if tools: payload["tools"] = tools payload["tool_choice"] = "auto" limit = getattr(self.config, "retry_seconds", 0) give_up = time.time() + limit if limit > 0 else None if deadline is not None: give_up = deadline if give_up is None else min(give_up, deadline) round_no = 0 while True: errors = [] self.round_transient = False cands = self.ordered_candidates() if round_no == 0 else self.all_candidates() if not cands: raise BackendError(self.pin_note()) for cand in cands: reply = self.try_candidate(cand, messages, tools, payload, stream_sink, ephemeral, errors) if reply is not None: return reply if not self.round_transient: raise BackendError("no backend can take this request, retrying will not help (%s)" % self.summarize_errors(errors, shown=6, width=900)) pause = RETRY_BACKOFF[min(round_no, len(RETRY_BACKOFF) - 1)] round_no += 1 if give_up is not None and time.time() + pause > give_up: raise BackendError("all backends failed after %d rounds (%s)" % (round_no, self.summarize_errors(errors, shown=6, width=900))) providers = len(set((cand["provider"], cand["chat_url"]) for cand in cands)) if sys.stderr.isatty(): sys.stderr.write("\n") print(paint("all %d candidate%s across %d backend%s failed (round %d), retrying in %ds: %s" % (len(cands), "" if len(cands) == 1 else "s", providers, "" if providers == 1 else "s", round_no, pause, self.summarize_errors(errors)), Ansi.DIM), file=sys.stderr) if round_no == 1 and not opencode_binary(): engine = container_engine() if engine and box_state(engine) == "missing": print(paint( "no local opencode CLI, and no sandbox box set up yet either -- that box " "would also work as a last-resort backend (see box_opencode_candidates()). " "Run `tai.py --env sandbox` once to build it: a small image, podman or " "docker, and it only ever touches that one container, never the rest of " "this machine.", Ansi.DIM ), file=sys.stderr) time.sleep(pause) def all_candidates(self): http = [cand for cand in self.candidates() if cand["chat_url"] not in CLI_CHAT_URLS] cli = [cand for cand in self.candidates() if cand["chat_url"] in CLI_CHAT_URLS] ranked = sorted(enumerate(http), key=lambda pair: (openrouter_free_tier(pair[1]), -self.health.weight(pair[1]["provider"], pair[1]["model"]), pair[0])) return self._apply_pins([cand for _, cand in ranked] + cli + self.box_opencode_candidates()) def box_opencode_candidates(self): """Last-resort only -- complete() reaches this via all_candidates(), never via ordered_candidates() (the round-0 fast path), so a normal turn that succeeds through a real backend never touches the box at all. If there's already a local opencode CLI, self.candidates() covers it directly and this stays empty; it only fires when that's missing AND the sandbox box is already running (never auto-created here -- see ensure_box(), which stays an explicit, approval-gated action via --env sandbox or the install tool).""" if not getattr(self.config, "opencode_enabled", True) or opencode_binary(): return [] engine = container_engine() if not engine or box_state(engine) != "running": return [] if not box_opencode_binary(engine): return [] models = self.box_opencode_models(engine) return [ {"provider": OPENCODE_LABEL, "chat_url": OPENCODE_CHAT_URL, "models_url": OPENCODE_MODELS_URL, "key": "", "model": model, "profile": "", "runner": "box", "engine": engine} for model in models ] def box_opencode_models(self, engine): cached = self.health.cached_list("opencode:box:free") if cached is not None: return prefer_muse(cached) try: done = box_exec(engine, ["opencode", "models", "opencode"], timeout=MODEL_LIST_TIMEOUT * 4) except (OSError, subprocess.SubprocessError): return [] text = done.stdout.decode("utf-8", "replace") models = [line.strip().split("/", 1)[1] for line in text.splitlines() if line.strip().startswith("opencode/")] models = [item for item in models if is_free_model(item)] if models: self.health.store_list("opencode:box:free", models) return prefer_muse(models) def call_candidate(self, cand, messages, tools, attempt, stream_sink, ephemeral): label, chat_url, key, model = cand["provider"], cand["chat_url"], cand["key"], cand["model"] extra = opencode_headers() if cand["profile"] == "opencode" else None timeout = STREAM_TIMEOUT if stream_sink is not None else HTTP_TIMEOUT if chat_url == MUSE_CHAT_URL: return self.muse_turn(messages, tools, stream_sink, label, timeout=timeout, ephemeral=ephemeral) if chat_url == GROK_CHAT_URL: return self.grok_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == CLAUDE_CHAT_URL: return self.claude_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == CODEX_CHAT_URL: return self.codex_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == GEMINI_CHAT_URL: return self.gemini_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == OPENCODE_CHAT_URL: if cand.get("runner") == "box": return self.box_opencode_turn(messages, tools, stream_sink, label, model, cand["engine"], timeout=timeout) return self.opencode_turn(messages, tools, stream_sink, label, model, timeout=timeout) if stream_sink is None: return self.single_shot(chat_url, key, attempt, label, extra) return self.streaming(chat_url, key, attempt, label, stream_sink, extra) def try_candidate(self, cand, messages, tools, payload, stream_sink, ephemeral, errors): label, model = cand["provider"], cand["model"] attempt = dict(payload, model=model) tries = [(tools, attempt)] if attempt.get("tools"): tries.append((None, {name: value for name, value in attempt.items() if name not in ("tools", "tool_choice")})) for pos, (use_tools, body) in enumerate(tries): started = time.time() try: reply = self.call_candidate(cand, messages, use_tools, body, stream_sink, ephemeral) if not isinstance(reply, dict): raise BackendError("backend returned no reply") self.health.record(label, model, True, (time.time() - started) * 1000, max(1, len(reply.get("content") or "") // 4) / max(time.time() - started, 0.001)) return reply except BackendError as exc: if pos == 0 and exc.status == 400 and len(tries) > 1: continue self.health.record(label, model, False) self.round_transient = self.round_transient or transient_failure(exc) errors.append("%s/%s: %s" % (label, model, exc)) return None except Exception as exc: self.health.record(label, model, False) self.round_transient = True errors.append("%s/%s: %s" % (label, model, short_error(exc))) return None return None def single_shot(self, url, key, payload, label, extra=None): body = json.dumps(payload).encode("utf-8") request = urllib.request.Request(url, data=body, headers=self.headers(key, extra), method="POST") try: with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response: data = json.loads(response.read().decode("utf-8", "replace")) except urllib.error.HTTPError as exc: raise BackendError("HTTP %d: %s" % (exc.code, exc.read().decode("utf-8", "replace")[:300]), exc.code) except urllib.error.URLError as exc: raise BackendError(short_error(exc)) failure = embedded_failure_message(data) if failure: raise BackendError("upstream error: %s" % failure[:300]) try: message = data["choices"][0]["message"] except (KeyError, IndexError, TypeError): raise BackendError("upstream returned no choices: %s" % json.dumps(data)[:300]) return self.normalize(message, label) def normalize(self, message, label): calls = [] for call in message.get("tool_calls") or []: func = call.get("function") or {} calls.append({"id": call.get("id") or uuid.uuid4().hex, "name": tool_name_clean(func.get("name")), "arguments": tool_args_text(func.get("arguments"))}) return {"role": "assistant", "content": message.get("content") or "", "reasoning": message.get("reasoning") or message.get("reasoning_content") or "", "tool_calls": calls, "backend": label} def streaming(self, url, key, payload, label, sink, extra=None): body = json.dumps(dict(payload, stream=True)).encode("utf-8") request = urllib.request.Request(url, data=body, headers=self.headers(key, extra), method="POST") try: response = urllib.request.urlopen(request, timeout=STREAM_TIMEOUT) except urllib.error.HTTPError as exc: raise BackendError("HTTP %d: %s" % (exc.code, exc.read().decode("utf-8", "replace")[:300]), exc.code) except urllib.error.URLError as exc: raise BackendError(short_error(exc)) content_parts = [] reasoning_parts = [] calls = {} last_pos = 0 try: for raw in response: line = raw.decode("utf-8", "replace").strip() if not line.startswith("data:"): continue data = line[5:].strip() if data == "[DONE]": break try: event = json.loads(data) except ValueError: continue choices = event.get("choices") or [] if not choices: continue delta = choices[0].get("delta") or {} piece = delta.get("content") if piece: content_parts.append(piece) sink(piece) think = delta.get("reasoning") or delta.get("reasoning_content") if think: reasoning_parts.append(think) for call in delta.get("tool_calls") or []: func = call.get("function") or {} pos = call.get("index", 0) if pos not in calls and calls and not call.get("id") and not func.get("name"): # Off-spec streams send an argument fragment under a fresh index # with no id or name: it continues the open call. pos = last_pos last_pos = pos slot = calls.setdefault(pos, {"id": "", "name": "", "arguments": ""}) if call.get("id"): slot["id"] = call["id"] if func.get("name"): slot["name"] += func["name"] if func.get("arguments"): slot["arguments"] += tool_args_text(func["arguments"]) except OSError as exc: if not content_parts and not calls: raise BackendError(short_error(exc)) finally: response.close() ordered = [calls[pos] for pos in sorted(calls)] for slot in ordered: slot["name"] = tool_name_clean(slot["name"]) slot["arguments"] = tool_args_valid(slot["arguments"]) if not slot["id"]: slot["id"] = uuid.uuid4().hex return {"role": "assistant", "content": "".join(content_parts), "reasoning": "".join(reasoning_parts), "tool_calls": ordered, "backend": label} def muse_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT, ephemeral=False): binary = muse_binary() if not binary: raise BackendError("muse CLI not found (MUSE_BIN or PATH)") home = getattr(self.config, "home", None) if ephemeral or home is None: with tempfile.TemporaryDirectory(prefix="tai-muse-") as folder: schema_path = os.path.join(folder, "schema.json") prompt_path = os.path.join(folder, "prompt.txt") muse_write_file(schema_path, json.dumps(muse_output_schema())) muse_write_file(prompt_path, muse_prompt(messages, tools)) answer = muse_run(muse_argv(self.config, schema_path, prompt_path), folder, stream_sink, timeout) return self.muse_reply(answer, label) key = self.muse_key() release = muse_acquire(home, "session:" + key) def on_reap(ok): try: if not ok: self.reset_muse() finally: release() gated = muse_gate(on_reap) try: return self.muse_persistent(messages, tools, stream_sink, label, timeout, home, key, gated) except BaseException: if not gated.armed.is_set(): release() raise def muse_persistent(self, messages, tools, stream_sink, label, timeout, home, key, gated): def plan(data): entry = data.get(key) pending = None session_id = None if isinstance(entry, dict) and isinstance(entry.get("id"), str) and isinstance(entry.get("hashes"), list): pending = muse_session_split(messages, entry["hashes"]) if pending is not None and not muse_session_alive(entry["id"]): pending = None if pending is not None: session_id = entry["id"] if pending is None or not pending: session_id = str(uuid.uuid4()) pending = list(messages) return session_id, pending session_id, pending = muse_transact(home, plan) try: with tempfile.TemporaryDirectory(prefix="tai-muse-") as folder: schema_path = os.path.join(folder, "schema.json") prompt_path = os.path.join(folder, "prompt.txt") muse_write_file(schema_path, json.dumps(muse_output_schema())) muse_write_file(prompt_path, muse_prompt(pending, tools)) answer = muse_run(muse_argv(self.config, schema_path, prompt_path, session_id), folder, stream_sink, timeout, gated) except BackendError: self.reset_muse() raise def commit(data): data[key] = {"id": session_id, "hashes": [muse_message_hash(item) for item in messages], "updated": now_iso()} muse_transact(home, commit) return self.muse_reply(answer, label) def cli_json_turn(self, label, messages, tools, stream_sink, timeout, run, parse, raw_text): # Shared by the prompt-JSON CLI backends (opencode, grok, claude, # codex, gemini): the model must answer with one JSON object, and a # reply with no JSON envelope while tools are offered is a structural # contract miss, never a verdict on the prose itself. Repair once with # an explicit correction prompt, then accept prose as the final answer. # A failed repair keeps the first answer: it was already usable. raw = run(opencode_prompt(messages, tools), timeout) answer = parse(raw) text = raw_text(raw) if tools and text is not None and opencode_find_answer(text) is None: try: raw = run(cli_json_repair_prompt(messages, tools, text), timeout) except BackendError: pass else: answer = parse(raw) if stream_sink is not None and answer.get("content"): stream_sink(answer["content"]) return self.muse_reply(answer, label) def opencode_turn(self, messages, tools, stream_sink, label, model, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, lambda prompt, secs: opencode_run(model, prompt, secs), opencode_parse, lambda raw: raw) def box_opencode_turn(self, messages, tools, stream_sink, label, model, engine, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label + " (box)", messages, tools, stream_sink, timeout, lambda prompt, secs: box_opencode_run(engine, model, prompt, secs), opencode_parse, lambda raw: raw) def grok_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, grok_run, grok_parse_output, lambda raw: raw) def claude_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, claude_run, claude_parse_output, lambda raw: raw) def codex_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, codex_run, lambda raw: codex_parse_output(raw[0], raw[1], raw[2]), lambda raw: raw[1] if raw[1].strip() else raw[0]) def gemini_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, gemini_run, gemini_parse_output, lambda raw: raw) def muse_reply(self, answer, label): calls = [] for call in answer.get("tool_calls") or []: calls.append({"id": uuid.uuid4().hex, "name": tool_name_clean(call.get("name")), "arguments": tool_args_text(call.get("arguments"))}) return {"role": "assistant", "content": answer.get("content") or "", "reasoning": "", "tool_calls": calls, "backend": label} def probe_backend(models_url, key): if models_url == MUSE_MODELS_URL: return muse_health() if models_url == GROK_MODELS_URL: return grok_health() if models_url == CLAUDE_MODELS_URL: return claude_health() if models_url == CODEX_MODELS_URL: return codex_health() if models_url == GEMINI_MODELS_URL: return gemini_health() if models_url == OPENCODE_MODELS_URL: return "ok, %s" % opencode_binary() if opencode_binary() else "opencode CLI not found" request = urllib.request.Request(models_url, headers={"Authorization": "Bearer " + key}) started = time.time() try: with urllib.request.urlopen(request, timeout=6) as response: try: payload = json.loads(response.read().decode("utf-8", "replace")) except ValueError: return "bad response" data = payload.get("data", []) if isinstance(payload, dict) else [] count = len(data) if isinstance(data, list) else 0 return "ok, %d models, %dms" % (count, (time.time() - started) * 1000) except urllib.error.HTTPError as exc: if exc.code == 401: return "needs key" return "http %d" % exc.code except OSError as exc: return "fail: %s" % short_error(exc)[:60] except Exception as exc: return "fail: %s" % short_error(exc)[:60] def edge_token(skew=0): ticks = int(time.time()) + skew + 11644473600 ticks -= ticks % 300 ticks *= 10000000 return hashlib.sha256(("%d%s" % (ticks, EDGE_TRUSTED_TOKEN)).encode("ascii")).hexdigest().upper() def edge_timestamp(): return time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()) def edge_ssml(text, voice): safe = html.escape(text, quote=False).replace("\r", " ").replace("\n", " ") return "%s" % (voice, safe) def edge_chunks(text, limit=4000): sentences = re.split(r"(?<=[.!?])\s+", text.strip()) parts = [] current = "" for sentence in sentences: trial = (current + " " + sentence).strip() if len(trial.encode("utf-8")) > limit and current: parts.append(current) current = sentence else: current = trial if current: parts.append(current) return parts or [text] def ws_send(sock, payload): if isinstance(payload, str): payload = payload.encode("utf-8") mask = os.urandom(4) header = bytes([0x81]) length = len(payload) if length < 126: header += bytes([0x80 | length]) elif length < 65536: header += bytes([0x80 | 126]) + struct.pack(">H", length) else: header += bytes([0x80 | 127]) + struct.pack(">Q", length) masked = bytes(piece ^ mask[pos % 4] for pos, piece in enumerate(payload)) sock.sendall(header + mask + masked) def ws_recv_exact(sock, count): data = b"" while len(data) < count: piece = sock.recv(count - len(data)) if not piece: raise BackendError("edge connection closed") data += piece return data def ws_recv(sock): opcode = 0 payload = b"" first = True while True: head = ws_recv_exact(sock, 2) done = head[0] & 0x80 part = head[0] & 0x0F masked = head[1] & 0x80 length = head[1] & 0x7F if length == 126: length = struct.unpack(">H", ws_recv_exact(sock, 2))[0] elif length == 127: length = struct.unpack(">Q", ws_recv_exact(sock, 8))[0] key = ws_recv_exact(sock, 4) if masked else b"" body = ws_recv_exact(sock, length) if length else b"" if masked: body = bytes(piece ^ key[pos % 4] for pos, piece in enumerate(body)) if part == 0x8: raise BackendError("edge closed stream") if part == 0x9: sock.sendall(bytes([0x8A, 0x00])) continue if part in (0x1, 0x2) and first: opcode = part first = False payload += body if done: return opcode, payload def edge_skew(header_text): match = re.search(r"Date:\s*(.+?)\r?$", header_text, re.MULTILINE | re.IGNORECASE) if not match: return 0 try: stamp = time.mktime(time.strptime(match.group(1).strip(), "%a, %d %b %Y %H:%M:%S %Z")) return int(stamp - time.time()) except (ValueError, OverflowError): return 0 def edge_connect(): skew = 0 for attempt in range(2): path = "%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=1-%s" % (EDGE_WS_PATH, EDGE_TRUSTED_TOKEN, uuid.uuid4().hex, edge_token(skew), EDGE_CHROMIUM) raw = socket.create_connection((EDGE_HOST, 443), timeout=20) sock = ssl.create_default_context().wrap_socket(raw, server_hostname=EDGE_HOST) sock.settimeout(30) key = base64.b64encode(os.urandom(16)).decode("ascii") muid = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) handshake = "\r\n".join([ "GET %s HTTP/1.1" % path, "Host: %s" % EDGE_HOST, "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Version: 13", "Sec-WebSocket-Key: %s" % key, "Pragma: no-cache", "Cache-Control: no-cache", "Origin: chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", "Accept-Encoding: gzip, deflate, br, zstd", "Accept-Language: en-US,en;q=0.9", "Cookie: muid=%s;" % muid, "", "", ]) sock.sendall(handshake.encode("ascii")) answer = b"" while b"\r\n\r\n" not in answer: piece = sock.recv(4096) if not piece: break answer += piece header_text = answer.decode("latin-1", "replace") if " 101 " in header_text.split("\r\n", 1)[0]: return sock sock.close() if " 403 " in header_text and attempt == 0: skew = edge_skew(header_text) continue raise BackendError("edge handshake failed: %s" % header_text.split("\r\n", 1)[0][:200]) raise BackendError("edge handshake failed after retry") def edge_synthesize(text, voice): sock = edge_connect() audio = bytearray() try: config = "X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{\"sentenceBoundaryEnabled\":\"false\",\"wordBoundaryEnabled\":\"true\"},\"outputFormat\":\"audio-24khz-48kbitrate-mono-mp3\"}}}}" % edge_timestamp() ws_send(sock, config) for chunk in edge_chunks(text): message = "X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n%s" % (uuid.uuid4().hex, edge_timestamp(), edge_ssml(chunk, voice)) ws_send(sock, message) while True: opcode, payload = ws_recv(sock) if opcode == 0x2 and len(payload) > 2: head_len = int.from_bytes(payload[0:2], "big") audio += payload[2 + head_len:] elif opcode == 0x1 and "Path:turn.end" in payload.decode("utf-8", "replace"): break return bytes(audio) finally: try: sock.close() except OSError: pass def play_audio(path): if sys.platform == "darwin" and shutil.which("afplay"): runner = ["afplay", path] elif shutil.which("paplay"): runner = ["paplay", path] elif shutil.which("aplay"): runner = ["aplay", "-q", path] elif shutil.which("pw-play"): runner = ["pw-play", path] elif sys.platform == "win32": runner = ["powershell", "-c", "(New-Object Media.SoundPlayer '%s').PlaySync();" % path] else: return False try: subprocess.run(runner, timeout=300, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) return True except (OSError, subprocess.SubprocessError): return False def listen_audio(seconds, workdir): recorder = next((item for item in RECORDERS if shutil.which(item[0])), None) transcriber = next((name for name in TRANSCRIBERS if shutil.which(name)), "") if recorder is None or not transcriber: missing = [] if recorder is None: missing.append("audio recorder (install arecord, sox, or ffmpeg)") if not transcriber: missing.append("transcriber (install whisper-cpp or whisper)") return "listening unavailable, missing: " + ", ".join(missing) path = os.path.join(workdir, "listen-%d.wav" % int(time.time())) command = [part.replace("{seconds}", str(seconds)).replace("{path}", path) for part in recorder[1]] try: subprocess.run(command, timeout=seconds + 15, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) done = subprocess.run([transcriber, path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=180) except (OSError, subprocess.SubprocessError) as exc: return "listening failed: " + short_error(exc) finally: try: os.remove(path) except OSError: pass if done.returncode != 0: return "transcriber error: " + (done.stderr or "")[:500] text = (done.stdout or "").strip() return truncate(text, 3000, 500) if text else "heard nothing" def sysinfo_check_os(): try: uname = os.uname() base = "%s %s %s" % (uname.sysname, uname.release, uname.machine) except (AttributeError, OSError): base = sys.platform try: with open("/etc/os-release", "r", encoding="utf-8") as handle: fields = dict(line.strip().split("=", 1) for line in handle if "=" in line) pretty = fields.get("PRETTY_NAME", "").strip('"') if pretty: return "%s (%s)" % (pretty, base) except OSError: pass return base def sysinfo_check_python(): return "%s (%s)" % (sys.version.split()[0], sys.executable) def sysinfo_check_venv(): env_path = os.environ.get("VIRTUAL_ENV") or "" if env_path: return "yes (%s)" % env_path if sys.prefix != sys.base_prefix: return "yes (%s)" % sys.prefix return "no (system python at %s)" % sys.prefix def sysinfo_check_root(): geteuid = getattr(os, "geteuid", None) if geteuid is None: return "unknown (no geteuid on %s)" % sys.platform uid = geteuid() return "yes (uid 0)" if uid == 0 else "no (uid %d)" % uid def sysinfo_check_container(): engine = container_engine() if not engine: return "no engine (install podman or docker)" return "%s, box %s" % (engine, box_state(engine)) def sysinfo_check_binaries(): found = [] lost = [] for name in SYSINFO_BINARIES: (found if shutil.which(name) else lost).append(name) return "present: %s; missing: %s" % (", ".join(found) or "none", ", ".join(lost) or "none") def sysinfo_check_cpu(): count = os.cpu_count() or 0 try: one, five, fifteen = os.getloadavg() return "%d cores, load %.2f/%.2f/%.2f" % (count, one, five, fifteen) except OSError: return "%d cores" % count def sysinfo_check_disk(): usage = shutil.disk_usage(os.getcwd()) return "%.1f GiB free of %.1f GiB at %s" % (usage.free / 2 ** 30, usage.total / 2 ** 30, os.getcwd()) SYSINFO_CHECKS = ( ("os", sysinfo_check_os), ("python", sysinfo_check_python), ("venv", sysinfo_check_venv), ("root", sysinfo_check_root), ("container", sysinfo_check_container), ("binaries", sysinfo_check_binaries), ("cpu", sysinfo_check_cpu), ("disk", sysinfo_check_disk), ) class SysInfo: """Host inspection probes (static; tests patch collect_sysinfo live).""" @staticmethod def collect_sysinfo(names=None): wanted = [pair for pair in SYSINFO_CHECKS if names is None or pair[0] in names] results = {} def target(name, func): started = time.time() try: value = func() except Exception as exc: value = "error: " + short_error(exc) results[name] = (value, int((time.time() - started) * 1000)) workers = [threading.Thread(target=target, args=(name, func), daemon=True) for name, func in wanted] for worker in workers: worker.start() deadline = time.time() + Consts.SYSINFO_TIMEOUT for worker in workers: worker.join(timeout=max(0, deadline - time.time())) lines = [] for name, _func in wanted: if name in results: value, elapsed = results[name] lines.append("%s: %s (%dms)" % (name, value, elapsed)) else: lines.append("%s: timed out after %ds" % (name, Consts.SYSINFO_TIMEOUT)) return "\n".join(lines) def collect_sysinfo(names=None): if names is None: return SysInfo.collect_sysinfo() return SysInfo.collect_sysinfo(names) def tool_schema(name, description, properties, required): return {"type": "function", "function": {"name": name, "description": description, "parameters": {"type": "object", "properties": properties, "required": required}}} TOOL_SCHEMAS = [ tool_schema("shell", "Run a shell command. Output is truncated. On home, destructive commands ask the user first. On sandbox, commands run isolated in the container and need no approval. Pass secrets to expose named vault secrets as TAI_SECRET_ variables for that command only; using secrets asks the user first. For local commands only: use web_fetch for HTTP(S), never curl or wget.", {"command": {"type": "string"}, "workdir": {"type": "string"}, "secrets": {"type": "array"}}, ["command"]), tool_schema("read_file", "Read a text file. Large files are truncated.", {"path": {"type": "string"}}, ["path"]), tool_schema("write_file", "Write content to a file, creating parent directories. Overwrites existing files.", {"path": {"type": "string"}, "content": {"type": "string"}}, ["path", "content"]), tool_schema("edit_file", "Replace one exact text match in a file. Fails unless the match is unique.", {"path": {"type": "string"}, "find": {"type": "string"}, "replace": {"type": "string"}}, ["path", "find", "replace"]), tool_schema("web_search", "Search the web. Set images for image search, content to include fetched page text.", {"query": {"type": "string"}, "images": {"type": "boolean"}, "content": {"type": "boolean"}}, ["query"]), tool_schema("describe_image", "Describe an image with a vision model (rsearch). Pass an http(s) url or a local file path (PNG, JPEG, GIF, WebP, BMP, max 10 MB).", {"source": {"type": "string"}}, ["source"]), tool_schema("web_fetch", "HTTP client: fetch a URL with any method and return status, content type, size, and content. GET by default; pass method (POST, PUT, PATCH, DELETE, HEAD, OPTIONS), headers, and a string body for APIs. HTML becomes readable text, JSON stays raw, raw forces the raw body. Use this for all HTTP(S), never curl or wget via shell. Pass auth_secret to send a vault secret as an Authorization header; the value stays sealed and secret use asks the user first.", {"url": {"type": "string"}, "method": {"type": "string"}, "headers": {"type": "object"}, "body": {"type": "string"}, "timeout": {"type": "integer"}, "raw": {"type": "boolean"}, "auth_secret": {"type": "string"}, "auth_header": {"type": "string"}, "auth_prefix": {"type": "string"}}, ["url"]), tool_schema("speak", "Synthesize text to speech with a free voice, save the MP3, and play it when a player exists.", {"text": {"type": "string"}, "voice": {"type": "string"}}, ["text"]), tool_schema("listen", "Record from the microphone for some seconds and transcribe it. Needs OS audio tools.", {"seconds": {"type": "integer"}}, []), tool_schema("remember", "Update your own system message with new knowledge or behavior. The instruction is merged into the existing system message, unrelated parts stay intact. Use it to forget as well by instructing what to remove. Execute by default when behavior, preferences, or durable facts need updating. Never pass passwords, tokens, or secret values here; store those with store_secret instead.", {"instruction": {"type": "string"}}, ["instruction"]), tool_schema("recall", "Search past session memory of the current profile by keyword, optionally filtered by tags.", {"query": {"type": "string"}, "tags": {"type": "array"}}, ["query"]), tool_schema("load_skill", "Load a skill by name. Returns full instructions plus bundled file paths. Missing skills with a blueprint are researched and built on demand.", {"name": {"type": "string"}}, ["name"]), tool_schema("get_current_terminal_content", "Capture visible text of the current tmux pane including scrollback, plus recent shell history. Pass target (a pane id like %3 or session:window.pane from list_terminals) to capture another open terminal instead. Works inside tmux or against a running tmux server.", {"lines": {"type": "integer"}, "target": {"type": "string"}}, []), tool_schema("list_terminals", "List every open tmux terminal: all panes in all sessions with ids, current commands, and working directories, plus session/window/pane counts. Use a pane id as target for get_current_terminal_content. Needs a running tmux server.", {}, []), tool_schema("tail_terminals", "One overview of all open tmux terminals: the last lines (default 3) of each at once, labeled with pane ids so you can spot which terminal holds what, then capture it fully with get_current_terminal_content. Needs a running tmux server.", {"lines": {"type": "integer"}}, []), tool_schema("export_terminal_log", "Only available inside a live tmux session. Exports the full pane scrollback (tmux capture-pane -pS -, the entire history, not just what's visible) to a log file under tai's home dir, then chunks it and indexes the chunks for retrieval with SQLite FTS5 + bm25 (lexical only, no embeddings). From then on, use the search tool (kind chunk) to answer questions about anything that happened in this terminal; it returns the actual chunk text, not just a snippet.", {"tags": {"type": "array"}}, []), tool_schema("plexer_send", "Only available when a tai-managed terminal session is active (either tai's own native wrapper, or an active tmux pane). Sends literal keystrokes into that session's shell, as if typed. Set enter to also press Enter and actually run it. No output is returned by this call; follow up with plexer_read.", {"text": {"type": "string"}, "enter": {"type": "boolean"}}, ["text"]), tool_schema("plexer_read", "Only available when a tai-managed terminal session is active. Reads back the last N lines the session's shell has produced (its actual rendered output, not a tool-call summary).", {"lines": {"type": "integer"}}, []), tool_schema("plexer_type", "Only available when a tai-managed terminal session is active. Types text into the session one character at a time with a delay between keystrokes, for a demo/animated-typing effect, then presses Enter unless enter is set to false.", {"text": {"type": "string"}, "delay": {"type": "number"}, "enter": {"type": "boolean"}}, ["text"]), tool_schema("plexer_status", "Only available when a tai-managed terminal session is active. Reports which backend is managing the current session (tai's own native pty wrapper, or tmux) and backend-specific details (pid/socket/log, or pane id).", {}, []), tool_schema("tui_screen", "Only available while a grr TUI (tai.py --tui) runs on this machine. Returns what its screen shows now (route, size, focus, the text). Set until to wait for that text to appear (timeout seconds), set async to get a job id at once and collect it with tui_result.", {"until": {"type": "string"}, "timeout": {"type": "integer"}, "async": {"type": "boolean"}}, []), tool_schema("tui_keys", "Only available while a grr TUI runs. Presses keys in it like a user: names such as down, up, enter, escape, space, tab, f1, ctrl+n, ctrl+p, or single characters. Async calls return a job id for tui_result.", {"keys": {"type": "array", "items": {"type": "string"}}, "async": {"type": "boolean"}}, ["keys"]), tool_schema("tui_type", "Only available while a grr TUI runs. Types text into the focused box (a newline presses Enter). Async calls return a job id for tui_result.", {"text": {"type": "string"}, "async": {"type": "boolean"}}, ["text"]), tool_schema("tui_click", "Only available while a grr TUI runs. Clicks the cell at column x, row y (0 based, as in tui_screen text). Async calls return a job id for tui_result.", {"x": {"type": "integer"}, "y": {"type": "integer"}, "async": {"type": "boolean"}}, ["x", "y"]), tool_schema("tui_goto", "Only available while a grr TUI runs. Opens a route such as /chat/new, /bots, /settings, /automations, /bot/. Async calls return a job id for tui_result.", {"route": {"type": "string"}, "async": {"type": "boolean"}}, ["route"]), tool_schema("tui_result", "Only available while a grr TUI runs. Collects an async tui_* job by id, waiting up to wait seconds; the state is running, done or error.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]), tool_schema("fork", "Spawn a background subagent with its own context that works while you continue. Returns an agent id immediately. Collect its summarized result with poll. Subagents get a smaller step budget and a time limit.", {"task": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}}, ["task"]), tool_schema("poll", "Collect a background subagent result by id. Waits up to wait seconds, then reports running or the result.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]), tool_schema("ask_user", "Ask the user one question mid-turn and wait for their answer. Use this instead of ending your turn with questions in prose: an empty tool_calls list ends the turn at once and one-shot runs then exit, so prose questions are never answered. Pass the question plus optional options; the user picks an option (by number or text) or types free input, and their answer comes back as the tool result. Only for real blockers a human must decide; in autonomous mode and for background workers it is unavailable, so decide yourself and state the assumption.", {"question": {"type": "string"}, "options": {"type": "array"}}, ["question"]), tool_schema("sysinfo", "Inspect the host machine where tai runs. Runs os, python, venv, root, container, binaries, cpu, and disk checks in parallel and reports each with timing. Pass checks to run a named subset.", {"checks": {"type": "array"}}, []), tool_schema("create_skill", "Create a new agent skill by name from a brief. Runs a dedicated deep-research worker on this machine (it calls sysinfo first, then researches with web search and shell until it has verified information) and writes SKILL.md plus supporting files into the skill directory. Runs synchronously and can take many minutes. Scope project writes under ./.tai/skills (the default), scope home under ~/.tai/skills.", {"name": {"type": "string"}, "brief": {"type": "string"}, "scope": {"type": "string"}, "timeout": {"type": "integer"}}, ["name", "brief"]), tool_schema("install_skill", "Install a skill that already exists somewhere else, instead of building one from scratch. source can be a local directory containing SKILL.md (or one subdirectory that does), a bare SKILL.md file, a local .zip, or a git URL (needs git installed; most sources need nothing beyond the standard library). Validates the SKILL.md frontmatter, then lands it under tai's own skill directories: copy vendors a private snapshot, symlink points at the source in place (the default when the source already sits in a recognized skills folder like .claude/skills or .agents/skills, so there is one canonical copy instead of a drifting duplicate). Scope defaults like create_skill: project when the cwd looks like a project, else home. Overwriting an existing skill of the same name asks the user first unless force is set.", {"source": {"type": "string"}, "name": {"type": "string"}, "scope": {"type": "string"}, "mode": {"type": "string"}, "force": {"type": "boolean"}}, ["source"]), tool_schema("store_secret", "Store a password, token, or secret in the sealed vault under a name. Values are encrypted at rest, never shown back, and only usable by reference; prefer /secret set in the REPL so the value never enters the conversation. Optional username, host, port, notes, expires (ISO datetime), and tags describe it; name and value suffice, never pester the user for more.", {"name": {"type": "string"}, "value": {"type": "string"}, "username": {"type": "string"}, "host": {"type": "string"}, "port": {"type": "integer"}, "notes": {"type": "string"}, "expires": {"type": "string"}, "tags": {"type": "array"}}, ["name", "value"]), tool_schema("list_secrets", "List vault secret names with metadata and tags. Values are never revealed.", {}, []), tool_schema("delete_secret", "Delete a vault secret by name. Asks the user first.", {"name": {"type": "string"}}, ["name"]), tool_schema("schedule", "Schedule a prompt to run at a future time or on a repeating interval. Exactly one of at (ISO datetime, naive means local time) or every (interval seconds, minimum 60) is required. When due, a background subagent runs the prompt without blocking anyone; inspect outcomes with schedules. Asks the user first. Optional tags label it.", {"at": {"type": "string"}, "every": {"type": "integer"}, "prompt": {"type": "string"}, "name": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}, "tags": {"type": "array"}}, ["prompt"]), tool_schema("unschedule", "Delete a scheduled prompt by id. Asks the user first.", {"id": {"type": "integer"}}, ["id"]), tool_schema("schedules", "List scheduled prompts with due times and last outcomes.", {}, []), tool_schema("record_save", "Save text as a tagged record in the vault and get back a mem: id. Use for anything too big or too durable for chat: research notes, command output, transcripts. Kind is one of note, output, file, research, transcript. Tag rules: lowercase singular, reuse tags from the tags tool, at most 5; known words in the text attach automatically and the record links to recent same-tag records.", {"title": {"type": "string"}, "content": {"type": "string"}, "kind": {"type": "string"}, "tags": {"type": "array"}}, ["content"]), tool_schema("record_read", "Read a slice of a record by mem: id. Offset and limit page through huge records without loading them whole.", {"id": {"type": "string"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, ["id"]), tool_schema("record_search", "Search records by text, kind, and tags. Returns mem: ids with sizes so you can page in only what you need.", {"query": {"type": "string"}, "kind": {"type": "string"}, "tags": {"type": "array"}, "limit": {"type": "integer"}}, []), tool_schema("record_delete", "Delete a record by mem: id. Asks the user first.", {"id": {"type": "string"}}, ["id"]), tool_schema("graph_link", "Link two vault nodes with a relation: mem: records, secret: secrets, sched: schedules. Both endpoints must exist.", {"src": {"type": "string"}, "dst": {"type": "string"}, "relation": {"type": "string"}}, ["src", "dst"]), tool_schema("graph_query", "Show the neighborhood of a vault node (mem:, secret:, sched:) by breadth-first traversal. Depth caps at 4.", {"node": {"type": "string"}, "depth": {"type": "integer"}, "limit": {"type": "integer"}}, ["node"]), tool_schema("delete_file", "Delete a file. Requires reading it first; the full pre-image stays in the audit trail so restore can undelete. Asks the user first.", {"path": {"type": "string"}}, ["path"]), tool_schema("audit", "Show the file audit trail for time travel: every write, edit, delete, shell snapshot, release, and restore with sizes and messages. Filter by exact path or tag, or rank by a full-text query over paths and messages.", {"path": {"type": "string"}, "tag": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}, []), tool_schema("restore", "Restore a file to an audit row: the post-image for writes and edits, the pre-image for deletes and shell snapshots. Refuses truncated images. Asks the user first.", {"id": {"type": "integer"}}, ["id"]), tool_schema("release", "Cut a release of the running script: bump VERSION by part (patch for fixes, minor for features, major for breaking changes), keep a versioned backup, log the message in the audit trail. Asks the user first.", {"part": {"type": "string"}, "message": {"type": "string"}}, ["part", "message"]), tool_schema("tags", "List vault tags with usage counts, most used first. Consult before tagging so new items reuse established tags instead of inventing synonyms.", {"prefix": {"type": "string"}, "limit": {"type": "integer"}}, []), tool_schema("install", "Manage tai installations: binary, command-not-found hook and plexer auto-wrap hook for bash/zsh/fish, venv, scheduler service, telegram service, container. Status reports what exists. Install adds missing pieces (skipping a shell hook loudly if that shell isn't on the machine), upgrade refreshes in place and restarts services, reinstall rebuilds artifacts, uninstall removes them. Service changes take effect immediately. The vault is never touched by any action. Non-status actions ask the user first.", {"action": {"type": "string"}, "targets": {"type": "array"}}, ["action"]), tool_schema("create_bot", "Create a bot in the current profile: a name plus its own system message and history. Give at least one of description, rules, or behavior; optional nicknames register as @mention aliases (a short form of the name registers automatically). Names are lowercase.", {"name": {"type": "string"}, "description": {"type": "string"}, "rules": {"type": "string"}, "behavior": {"type": "string"}, "nicknames": {"type": "array"}}, ["name"]), tool_schema("search", "Search everything at once with ranked full-text matching (SQLite FTS5 + bm25, no vectors): records, episodic events, the audit trail, and RAG chunks. Chunks are the retrieval-grade granular hits (e.g. one slice of an exported terminal log) and come back with their actual text, not just a snippet; use the chunk's parent id with record_read to see more surrounding context. One call replaces paging through each store separately. expand adds one graph hop per record hit.", {"query": {"type": "string"}, "kinds": {"type": "array"}, "limit": {"type": "integer"}, "expand": {"type": "boolean"}}, ["query"]), ] CORE_TOOLS = ("shell", "read_file", "write_file", "remember", "recall", "load_skill", "fork", "poll", "ask_user") ASK_USER_MAX_OPTIONS = 12 ASK_USER_MAX_ANSWER = 2000 LAZY_NAME_STOP = ("get", "set", "list", "add") # Tools gated on top of the lazy keyword match: a gate must pass before a tool can even be # offered to the model, regardless of relevance. Used for tools that only make sense in a # specific environment (e.g. a tmux pane to capture). TOOL_ENV_GATES = { "export_terminal_log": lambda: bool(os.environ.get("TMUX")), "plexer_send": lambda: PlexerControllerFactory.detect() is not None, "plexer_read": lambda: PlexerControllerFactory.detect() is not None, "plexer_type": lambda: PlexerControllerFactory.detect() is not None, "plexer_status": lambda: PlexerControllerFactory.detect() is not None, "tui_screen": lambda: os.path.exists(grr_control_path()), "tui_keys": lambda: os.path.exists(grr_control_path()), "tui_type": lambda: os.path.exists(grr_control_path()), "tui_click": lambda: os.path.exists(grr_control_path()), "tui_goto": lambda: os.path.exists(grr_control_path()), "tui_result": lambda: os.path.exists(grr_control_path()), } def tool_env_ok(name): gate = TOOL_ENV_GATES.get(name) return gate is None or gate() TOOL_TAGS = { "shell": ("run", "execute", "command", "bash", "terminal", "script"), "read_file": ("read", "open", "view", "contents"), "write_file": ("write", "create", "overwrite"), "edit_file": ("edit", "change", "modify", "replace", "patch", "fix", "alter"), "web_search": ("search", "web", "internet", "lookup", "google"), "describe_image": ("image", "describe", "vision", "picture", "photo", "screenshot"), "web_fetch": ("fetch", "download", "url", "link", "page", "curl", "wget"), "speak": ("speak", "say", "voice", "talk", "tts"), "listen": ("listen", "hear", "microphone", "mic", "transcribe", "stt", "dictate"), "remember": ("remember", "memorize", "preference"), "recall": ("recall", "memory", "history", "past"), "load_skill": ("skill", "capability"), "get_current_terminal_content": ("terminal", "tmux", "pane", "scrollback", "screen", "history", "bash"), "list_terminals": ("terminal", "tmux", "panes", "sessions", "windows", "list", "open", "count"), "tail_terminals": ("terminal", "tmux", "panes", "tail", "peek", "overview", "every"), "export_terminal_log": ("terminal", "tmux", "export", "log", "capture", "history", "rag", "index"), "plexer_send": ("plexer", "terminal", "tmux", "keys", "send-keys", "type", "keystroke"), "plexer_read": ("plexer", "terminal", "tmux", "pane", "read", "capture"), "plexer_type": ("plexer", "terminal", "tmux", "type", "animate", "keystroke", "demo"), "plexer_status": ("plexer", "terminal", "tmux", "status", "session"), "tui_screen": ("tui", "gui", "screen", "interface", "ui"), "tui_keys": ("tui", "gui", "keys", "press", "keyboard", "navigate"), "tui_type": ("tui", "gui", "type", "input", "text"), "tui_click": ("tui", "gui", "click", "mouse"), "tui_goto": ("tui", "gui", "open", "route", "navigate", "goto"), "tui_result": ("tui", "gui", "result", "async", "job"), "fork": ("fork", "subagent", "background", "parallel", "delegate", "spawn"), "poll": ("poll", "collect"), "ask_user": ("ask", "question", "clarify", "confirm", "choose", "answer", "input"), "sysinfo": ("sysinfo", "system", "host", "machine", "hardware", "specs", "installed", "python", "container"), "create_skill": ("skill", "create", "author", "blueprint"), "install_skill": ("skill", "install", "import", "adopt", "copy", "symlink"), "store_secret": ("secret", "password", "token", "credential", "passwd", "vault", "apikey"), "list_secrets": ("secrets", "vault", "credentials"), "delete_secret": ("secret", "remove", "revoke"), "schedule": ("schedule", "cron", "appointment", "reminder", "recurring", "periodic", "later", "daily", "hourly", "interval"), "unschedule": ("unschedule", "cancel"), "schedules": ("schedules", "upcoming", "planned", "agenda"), "record_save": ("record", "note", "document", "memo"), "record_read": ("record", "page"), "record_search": ("record", "find"), "record_delete": ("record", "remove"), "graph_link": ("graph", "link", "connect", "relate", "edge", "relation"), "graph_query": ("graph", "neighbors", "neighborhood", "traverse", "related"), "delete_file": ("delete", "remove", "erase"), "audit": ("audit", "trail", "changes", "blame", "history", "version"), "restore": ("restore", "undo", "revert", "recover", "rollback", "undelete", "previous"), "release": ("release", "version", "bump", "changelog", "publish"), "tags": ("tags", "tag", "label", "categorize", "taxonomy", "vocabulary"), "install": ("install", "uninstall", "reinstall", "upgrade", "setup", "service", "systemd", "venv", "hook", "deploy", "zsh", "fish"), "create_bot": ("bot", "bots", "persona", "character", "mention"), "search": ("search", "find", "lookup", "everything"), } def tool_catalog(): lines = ["", "", "## Tool catalog (core loads always; name a lazy tool or topic to load it)"] for schema in TOOL_SCHEMAS: name = schema["function"]["name"] if not tool_env_ok(name): continue if name in CORE_TOOLS: continue first = schema["function"]["description"].split(".")[0][:80] lines.append("- %s: %s" % (name, first)) return "\n".join(lines) def conversation_text(messages): parts = [] for item in messages: content = item.get("content") if item.get("role") in ("user", "assistant", "tool") and isinstance(content, str) and content: parts.append(content) return "\n".join(parts)[-6000:] def select_tools(text): words = set(re.findall(r"[a-z0-9]+", (text or "").lower())) picked = [] for schema in TOOL_SCHEMAS: name = schema["function"]["name"] if not tool_env_ok(name): continue if name in CORE_TOOLS: picked.append(schema) continue tokens = [item for item in name.split("_") if len(item) >= 4 and item not in LAZY_NAME_STOP] if tokens and set(tokens) & words: picked.append(schema) continue if set(TOOL_TAGS.get(name, ())) & words: picked.append(schema) continue desc = set(word for word in re.findall(r"[a-z0-9]+", schema["function"]["description"].lower()) if len(word) >= 6) if desc & words: picked.append(schema) return picked def secret_env_name(name): return "TAI_SECRET_" + re.sub(r"[^A-Z0-9_]", "_", name.upper()) def normalize_tag(raw): return re.sub(r"[^a-z0-9]+", "-", str(raw or "").strip().lower()).strip("-")[:32] def normalize_tags(items): seen = [] for item in items: cleaned = normalize_tag(item) if cleaned and cleaned not in seen: seen.append(cleaned) return seen[:20] TAG_AUTO_SKIP = ("record", "file") TAG_AUTO_MAX = 5 TAG_LINK_MAX = 3 SINGULAR_KEEP = ("news", "means", "series", "species", "physics") def singular_noun(word): if word in SINGULAR_KEEP or len(word) <= 3: return word if word.endswith("ies"): return word[:-3] + "y" if word.endswith(("ses", "xes", "zes", "ches", "shes")): return word[:-2] if word.endswith("s") and not word.endswith(("ss", "us", "is", "os")): return word[:-1] return word def tag_variants(tag): found = {tag, singular_noun(tag)} if not tag.endswith("s"): found.add(tag + "s") return sorted(found) def parse_node_id(raw): match = re.fullmatch(r"(mem|secret|sched):(.+)", str(raw or "").strip()) if not match: return None kind, key = match.group(1), match.group(2) if kind == "mem" and re.fullmatch(r"[0-9a-f]{16}", key): return kind, key if kind == "secret" and SECRET_RE.match(key): return kind, key if kind == "sched" and key.isdigit(): return kind, key return None def create_skill_prompt(name, brief, skill_dir): return ( "Create a new agent skill named '%s' in %s.\n" "Brief: %s\n\n" "Follow this process exactly:\n" "1. Call sysinfo first so the skill matches this machine (os, installed tools, root, container, venv).\n" "2. Research the subject deeply with web_search, web_fetch, and shell. Keep researching until you literally " "have enough verified information to write complete instructions. Verify factual claims against at least two " "independent sources. Never invent commands, paths, versions, or API details.\n" "3. Write SKILL.md in the skill directory with this exact frontmatter:\n" "---\nname: %s\ndescription: \n---\n" "followed by clear markdown instructions grounded in your research.\n" "4. Add scripts/, references/, or assets/ files only when they carry real weight; keep every file focused.\n" "5. Read back every file you wrote, fix mistakes, and confirm the frontmatter holds a name plus a description.\n" "6. Reply with a short summary: what the skill covers, which sources verified it, and which files you wrote.\n" "Do not stop early: an incomplete or unverified skill is a failure. Do not ask questions; decide and record assumptions in the skill body." % (name, skill_dir, brief, name) ) PLEXER_DIR_NAME = "plexer" PLEXER_PANE_ENV = "TAI_PLEXER_PANE" PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET" PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE" PLEXER_RECV_MAX = 1 << 20 def plexer_dir(home): path = os.path.join(home, PLEXER_DIR_NAME) os.makedirs(path, exist_ok=True) try: os.chmod(path, 0o700) except OSError: pass return path class PlexerError(Exception): """Raised by a PlexerController when the backend can't be reached or refuses a command.""" # --------------------------------------------------------------------------- # # Controller side: what Tools.run_plexer_* actually calls. One small # interface, two backends, chosen by which environment variable is set — # TAI_PLEXER_SOCKET (our own NativePlexerBackend already wrapping this shell) # or TMUX_PANE (tmux is already the active manager of this shell). Neither # set means no plexer session is active and the plexer_* tools stay hidden # (see TOOL_ENV_GATES below) rather than erroring. # --------------------------------------------------------------------------- # class PlexerController: kind = "abstract" def send_keys(self, text, enter=False): raise NotImplementedError def read_pane(self, lines=200): raise NotImplementedError def type_text(self, text, delay=0.03, enter=True): for ch in text: self.send_keys(ch) time.sleep(delay) if enter: self.send_keys("", enter=True) def status(self): raise NotImplementedError class NativePlexerController(PlexerController): kind = "native" def __init__(self, socket_path, pane=None): self.socket_path = socket_path self.pane = pane def _call(self, payload): if self.pane: payload = dict(payload, pane=self.pane) try: conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) conn.settimeout(10) conn.connect(self.socket_path) except OSError as exc: raise PlexerError("cannot reach native plexer backend: " + short_error(exc)) try: conn.sendall((json.dumps(payload) + "\n").encode("utf-8")) chunks = [] while True: chunk = conn.recv(65536) if not chunk: break chunks.append(chunk) if chunk.endswith(b"\n"): break except OSError as exc: raise PlexerError("native plexer backend read/write failed: " + short_error(exc)) finally: conn.close() raw = b"".join(chunks).decode("utf-8", "replace").strip() try: reply = json.loads(raw) if raw else {} except ValueError: raise PlexerError("native plexer backend sent an unparseable reply") if not reply.get("ok", False): raise PlexerError(reply.get("error") or "native plexer backend refused the command") return reply.get("data") def send_keys(self, text, enter=False): return self._call({"cmd": "send", "text": text, "enter": bool(enter)}) def read_pane(self, lines=200): return self._call({"cmd": "read", "lines": int(lines)}) def status(self): return self._call({"cmd": "status"}) class TmuxPlexerController(PlexerController): kind = "tmux" def __init__(self, pane): self.pane = pane def _tmux(self, *args): try: done = subprocess.run(["tmux"] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: raise PlexerError("tmux call failed: " + short_error(exc)) if done.returncode != 0: raise PlexerError("tmux error: " + (done.stderr or "").strip()[:300]) return done.stdout def send_keys(self, text, enter=False): if text: self._tmux("send-keys", "-t", self.pane, "-l", text) if enter: self._tmux("send-keys", "-t", self.pane, "Enter") return True def read_pane(self, lines=200): return self._tmux("capture-pane", "-p", "-t", self.pane, "-S", "-%d" % max(1, int(lines))) def status(self): # list-panes, not display-message -p: display-message -p silently # falls back to tmux's default/current pane when -t names a target # that doesn't exist (confirmed empirically -- it still exits 0 with # blank format substitutions), while list-panes -t actually validates # the target and errors like send-keys/capture-pane already do. info = self._tmux("list-panes", "-t", self.pane, "-F", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}") return {"backend": "tmux", "pane": self.pane, "info": info.strip()} class PlexerControllerFactory: """The whole detection flow lives in one place: which env var is set decides which backend is already managing this shell. Nothing here ever spawns a backend — spawning only happens via `tai.py --plexer-wrap`, invoked by the bashrc hook, and only when neither var is already set.""" @staticmethod def detect(): socket_path = os.environ.get(PLEXER_SOCKET_ENV) if socket_path: return NativePlexerController(socket_path, os.environ.get(PLEXER_PANE_ENV)) pane = os.environ.get("TMUX_PANE") if pane: return TmuxPlexerController(pane) return None # --------------------------------------------------------------------------- # # Backend side: PlexerBackend is the extension point the shell hook execs into. # NativePlexerBackend (bottom of the Mplex block) starts or joins the mplex # server and attaches this terminal to a fresh session. tmux itself needs no # backend: when it is active there is nothing for tai to wrap (see # PlexerControllerFactory). # --------------------------------------------------------------------------- # class PlexerBackend: def run(self): raise NotImplementedError # --------------------------------------------------------------------------- # # Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns # every pty, window layout and scrollback; clients attach over a Unix socket # and receive finished frames. Key bindings, command names and option names # follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`), # and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md. # --------------------------------------------------------------------------- # MPLEX_SOCKET_NAME = "server.sock" MPLEX_LOCK_NAME = "server.lock" MPLEX_LOG_NAME = "server.log" MPLEX_CONFIG_NAME = "mplex.toml" MPLEX_MAGIC = b"\x01" MPLEX_FRAME_MAX = 1 << 22 MPLEX_CLIENT_OUT_MAX = 1 << 22 MPLEX_PANE_IN_MAX = 1 << 22 MPLEX_MAX_SESSIONS = 64 MPLEX_MAX_WINDOWS = 256 MPLEX_MAX_PANES = 256 MPLEX_MAX_CLIENTS = 32 MPLEX_MESSAGES_MAX = 200 MPLEX_HISTORY_CAP = 200000 MPLEX_BUFFERS_CAP = 100 MPLEX_PROMPT_HISTORY_CAP = 100 MPLEX_COMMAND_DEPTH_MAX = 16 MPLEX_SHELL_TIMEOUT = 5.0 MPLEX_SHELL_OUTPUT_MAX = 65536 MPLEX_PASTE_MAX = 1 << 22 MPLEX_RENDER_INTERVAL = 0.012 MPLEX_REAP_KILL_AFTER = 2.0 MPLEX_IDLE_EXIT = 15.0 MPLEX_LOOP_ERRORS_MAX = 50 MPLEX_LOG_MAX = 1 << 24 MPLEX_CONFIG_MAX = 1 << 20 MPLEX_UNSUPPORTED = ("display-menu", "display-popup", "customize-mode", "lock-server", "lock-session", "lock-client", "pipe-pane", "join-pane", "move-pane", "link-window", "unlink-window", "respawn-pane", "respawn-window", "resize-window", "set-hook", "show-hooks", "wait-for", "server-access", "choose-tree-mouse") # (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings. MPLEX_DEFAULT_KEYS = ( ('copy-mode', 'Escape', 'send-keys -X cancel', False, ''), ('copy-mode', 'Space', 'send-keys -X page-down', False, ''), ('copy-mode', ',', 'send-keys -X jump-reverse', False, ''), ('copy-mode', ';', 'send-keys -X jump-again', False, ''), ('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), ('copy-mode', 'N', 'send-keys -X search-reverse', False, ''), ('copy-mode', 'P', 'send-keys -X toggle-position', False, ''), ('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''), ('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), ('copy-mode', 'X', 'send-keys -X set-mark', False, ''), ('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), ('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), ('copy-mode', 'n', 'send-keys -X search-again', False, ''), ('copy-mode', 'q', 'send-keys -X cancel', False, ''), ('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''), ('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), ('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''), ('copy-mode', 'End', 'send-keys -X end-of-line', False, ''), ('copy-mode', 'NPage', 'send-keys -X page-down', False, ''), ('copy-mode', 'PPage', 'send-keys -X page-up', False, ''), ('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''), ('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''), ('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''), ('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''), ('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-<', 'send-keys -X history-top', False, ''), ('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''), ('copy-mode', 'M-R', 'send-keys -X top-line', False, ''), ('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''), ('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''), ('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''), ('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''), ('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''), ('copy-mode', 'M-v', 'send-keys -X page-up', False, ''), ('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''), ('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''), ('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''), ('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''), ('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''), ('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''), ('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''), ('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''), ('copy-mode', 'C-c', 'send-keys -X cancel', False, ''), ('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''), ('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''), ('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''), ('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), ('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''), ('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''), ('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''), ('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''), ('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''), ('copy-mode', 'C-v', 'send-keys -X page-down', False, ''), ('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''), ('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''), ('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''), ('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''), ('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''), ('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''), ('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''), ('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''), ('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''), ('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''), ('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''), ('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''), ('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''), ('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), ('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''), ('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''), ('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''), ('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''), ('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), ('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''), ('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), ('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''), ('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''), ('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''), ('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''), ('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''), ('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''), ('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''), ('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''), ('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), ('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''), ('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''), ('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''), ('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''), ('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''), ('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''), ('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), ('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''), ('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''), ('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''), ('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''), ('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''), ('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''), ('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''), ('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''), ('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), ('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''), ('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''), ('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''), ('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''), ('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''), ('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''), ('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''), ('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''), ('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''), ('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''), ('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''), ('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''), ('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''), ('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''), ('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''), ('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''), ('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''), ('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''), ('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''), ('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''), ('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''), ('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''), ('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''), ('prefix', 'Space', 'next-layout', False, 'Select next layout'), ('prefix', '!', 'break-pane', False, 'Break pane to a new window'), ('prefix', '"', 'split-window', False, 'Split window vertically'), ('prefix', '#', 'list-buffers', False, 'List all paste buffers'), ('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'), ('prefix', '%', 'split-window -h', False, 'Split window horizontally'), ('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'), ('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'), ('prefix', '(', 'switch-client -p', False, 'Switch to previous client'), ('prefix', ')', 'switch-client -n', False, 'Switch to next client'), ('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'), ('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'), ('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'), ('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'), ('prefix', '0', 'select-window -t :=0', False, 'Select window 0'), ('prefix', '1', 'select-window -t :=1', False, 'Select window 1'), ('prefix', '2', 'select-window -t :=2', False, 'Select window 2'), ('prefix', '3', 'select-window -t :=3', False, 'Select window 3'), ('prefix', '4', 'select-window -t :=4', False, 'Select window 4'), ('prefix', '5', 'select-window -t :=5', False, 'Select window 5'), ('prefix', '6', 'select-window -t :=6', False, 'Select window 6'), ('prefix', '7', 'select-window -t :=7', False, 'Select window 7'), ('prefix', '8', 'select-window -t :=8', False, 'Select window 8'), ('prefix', '9', 'select-window -t :=9', False, 'Select window 9'), ('prefix', ':', 'command-prompt', False, 'Prompt for a command'), ('prefix', ';', 'last-pane', False, 'Move to the previously active pane'), ('prefix', '<', 'display-menu', False, 'Display window menu'), ('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'), ('prefix', '>', 'display-menu', False, 'Display pane menu'), ('prefix', '?', 'list-keys -N', False, 'List key bindings'), ('prefix', 'C', 'customize-mode -Z', False, 'Customize options'), ('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'), ('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'), ('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'), ('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'), ('prefix', '[', 'copy-mode', False, 'Enter copy mode'), ('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'), ('prefix', 'c', 'new-window', False, 'Create a new window'), ('prefix', 'd', 'detach-client', False, 'Detach the current client'), ('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'), ('prefix', 'i', 'display-message', False, 'Display window information'), ('prefix', 'l', 'last-window', False, 'Select the previously current window'), ('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'), ('prefix', 'n', 'next-window', False, 'Select the next window'), ('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'), ('prefix', 'p', 'previous-window', False, 'Select the previous window'), ('prefix', 'q', 'display-panes', False, 'Display pane numbers'), ('prefix', 'r', 'refresh-client', False, 'Redraw the current client'), ('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'), ('prefix', 't', 'clock-mode', False, 'Show a clock'), ('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'), ('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'), ('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'), ('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'), ('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'), ('prefix', '~', 'show-messages', False, 'Show messages'), ('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'), ('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'), ('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'), ('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'), ('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'), ('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'), ('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'), ('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'), ('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'), ('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'), ('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'), ('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'), ('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'), ('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'), ('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'), ('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'), ('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'), ('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'), ('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'), ('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'), ('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'), ('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'), ('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'), ('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'), ('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'), ('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'), ('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'), ('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'), ('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'), ('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'), ('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'), ) MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled") class MplexError(Exception): """A user-facing failure: the text is shown verbatim as the command's error.""" class MplexKeys: """Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what a terminal sends, and byte encoding for send-keys.""" ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC", "Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"} NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace", "Escape", "Space") + tuple("F%d" % n for n in range(1, 13)) FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End", "pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab", "enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"} CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"} CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24} SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"} CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F} @classmethod def canonical(cls, spec): if spec == " ": return "Space" text = str(spec).strip() if not text: raise MplexError("empty key name") mods = set() while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": mods.add(text[0]) text = text[2:] if len(text) == 2 and text[0] == "^": mods.add("C") text = text[1] text = cls.ALIASES.get(text, text) if len(text) == 1: if text == " ": text = "Space" elif "C" in mods and text.isalpha(): text = text.lower() elif "S" in mods and text.isalpha(): text = text.upper() mods.discard("S") elif text not in cls.NAMED: raise MplexError("unknown key: %s" % spec) if "C" in mods: if text == "i": mods.discard("C") text = "Tab" elif text == "m": mods.discard("C") text = "Enter" elif text == "[": mods.discard("C") text = "Escape" return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text @classmethod def name_of_char(cls, raw): """Canonical name for one decoded character (no escape prefix).""" if len(raw) == 1: byte = raw[0] if byte == 0x00: return "C-Space" if byte == 0x09: return "Tab" if byte == 0x0D: return "Enter" if byte == 0x1B: return "Escape" if byte == 0x20: return "Space" if byte == 0x7F: return "BSpace" if 0x01 <= byte <= 0x1A: return "C-" + chr(byte + 0x60) if 0x1C <= byte <= 0x1F: return "C-" + "\\]^_"[byte - 0x1C] return chr(byte) return raw.decode("utf-8", "replace") @classmethod def split(cls, data, final=False): """bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys (they are still forwarded). A lone ESC waits until final=True.""" tokens = [] buf = bytes(data) while buf: if buf[0] != 0x1B: length = 1 lead = buf[0] if lead >= 0xF0: length = 4 elif lead >= 0xE0: length = 3 elif lead >= 0xC0: length = 2 if length > len(buf): if not final: break length = len(buf) raw = buf[:length] tokens.append((cls.name_of_char(raw), raw)) buf = buf[length:] continue if len(buf) == 1: if not final: break tokens.append(("Escape", b"\x1b")) buf = b"" break event, rest = tui_parse_one(buf, final) if event == "need_more": if not final: break tokens.append(("Escape", b"\x1b")) buf = buf[1:] continue if event == "escape": tokens.append(("Escape", b"\x1b")) buf = buf[1:] continue consumed = len(buf) - len(rest) if consumed <= 0: consumed = 1 raw = buf[:consumed] buf = buf[consumed:] tokens.append((cls._name_of_event(event, raw), raw)) return tokens, buf @classmethod def _name_of_event(cls, event, raw): if not isinstance(event, TuiKey): return "" if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"): inner = cls.name_of_char(raw[1:]) try: return cls.canonical("M-" + inner) except MplexError: return "" key = event.key base = key.split("+")[-1] if not key.endswith("+") else "+" if base in cls.FROM_TUI: base = cls.FROM_TUI[base] elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit(): base = "F" + base[1:] else: return "" prefix = "" mods = set(getattr(event, "modifiers", None) or []) if "ctrl" in mods: prefix += "C-" if "alt" in mods or "meta" in mods: prefix += "M-" if "shift" in mods: prefix += "S-" try: return cls.canonical(prefix + base) except MplexError: return "" @classmethod def to_bytes(cls, name, app_cursor=False): """Bytes a terminal would send for a canonical key name.""" mods = "" text = name while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": mods += text[0] text = text[2:] if len(text) == 1 or text == "Space": char = " " if text == "Space" else text data = char.encode("utf-8") if "C" in mods: if char == " ": data = b"\x00" elif char.isalpha() and char.isascii(): data = bytes([ord(char.upper()) & 0x1F]) elif char in cls.CTRL_PUNCT: data = bytes([cls.CTRL_PUNCT[char]]) return (b"\x1b" if "M" in mods else b"") + data simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"} if text in simple and not ("S" in mods and text not in ("BTab",)): return (b"\x1b" if "M" in mods else b"") + simple[text] mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0) if text in cls.CSI_FINAL: if mod_code > 1: return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii") return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii") if text in cls.CSI_TILDE: if mod_code > 1: return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii") return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii") if text in cls.SS3_FUNC: if mod_code > 1: return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii") return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii") raise MplexError("unknown key: %s" % name) @classmethod def app_cursor_form(cls, raw): """Plain CSI cursor keys become SS3 when the pane asked for application cursor mode.""" if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"): return b"\x1bO" + raw[2:3] return raw class MplexArgs: """getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value.""" def __init__(self, flags, positional): self.flags = flags self.positional = positional def has(self, flag): return flag in self.flags def get(self, flag, default=None): values = self.flags.get(flag) if not values: return default return values[-1] def all(self, flag): return list(self.flags.get(flag) or []) def int(self, flag, default=None): value = self.get(flag) if value is None or value is True: return default try: return int(value) except ValueError: raise MplexError("-%s expects a number: %s" % (flag, value)) @classmethod def parse(cls, name, spec, words, min_args=0, max_args=None): takes = set() known = set() index = 0 while index < len(spec): known.add(spec[index]) if index + 1 < len(spec) and spec[index + 1] == ":": takes.add(spec[index]) index += 1 index += 1 flags = {} positional = [] pos = 0 while pos < len(words): word = words[pos] if word == "--": positional.extend(words[pos + 1:]) break if len(word) < 2 or word[0] != "-": positional.extend(words[pos:]) break inner = 1 while inner < len(word): letter = word[inner] if letter not in known: raise MplexError("command %s: unknown flag -%s" % (name, letter)) if letter in takes: if inner + 1 < len(word): value = word[inner + 1:] else: pos += 1 if pos >= len(words): raise MplexError("command %s: -%s expects an argument" % (name, letter)) value = words[pos] flags.setdefault(letter, []).append(value) break flags.setdefault(letter, []).append(True) inner += 1 pos += 1 if len(positional) < min_args or (max_args is not None and len(positional) > max_args): raise MplexError("command %s: wrong number of arguments" % name) return cls(flags, positional) class MplexCommandParser: """Splits tmux-syntax command text into commands of words: quotes, backslash, {braces} as one argument, ';' and newlines between commands.""" @staticmethod def parse(text): commands = [] words = [] cur = [] has = False i = 0 n = len(text) def end_word(): nonlocal cur, has if has: words.append("".join(cur)) cur = [] has = False def end_command(): nonlocal words end_word() if words: commands.append(words) words = [] while i < n: c = text[i] if c in " \t\r": end_word() i += 1 elif c == "\n" or c == ";": end_command() i += 1 elif c == "\\": if i + 1 < n: if text[i + 1] == "\n": i += 2 continue cur.append(text[i + 1]) has = True i += 2 elif c == "'": j = text.find("'", i + 1) if j < 0: raise MplexError("unterminated single quote") cur.append(text[i + 1:j]) has = True i = j + 1 elif c == '"': j = i + 1 piece = [] while j < n and text[j] != '"': if text[j] == "\\" and j + 1 < n: nxt = text[j + 1] piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt)) j += 2 else: piece.append(text[j]) j += 1 if j >= n: raise MplexError("unterminated double quote") cur.append("".join(piece)) has = True i = j + 1 elif c == "{" and not has: depth = 0 j = i quote = "" while j < n: ch = text[j] if quote: if ch == "\\" and quote == '"': j += 1 elif ch == quote: quote = "" elif ch in "'\"": quote = ch elif ch == "\\": j += 1 elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: break j += 1 if depth != 0 or j >= n: raise MplexError("unterminated { block") cur.append(text[i + 1:j].strip()) has = True i = j + 1 else: cur.append(c) has = True i += 1 end_command() return commands @staticmethod def quote(word): word = str(word) if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word): return word return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"' class MplexStyle: """tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes.""" NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white") ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64, "reverse": 8, "hidden": 128, "strikethrough": 16} @classmethod def color(cls, text, background): text = text.strip().lower() if text in ("default", "terminal", ""): return None base = 40 if background else 30 if text in cls.NAMES: return str(base + cls.NAMES.index(text)) if text.startswith("bright") and text[6:] in cls.NAMES: return str(base + 60 + cls.NAMES.index(text[6:])) if text.startswith("colour") or text.startswith("color"): digits = text[6:] if text.startswith("colour") else text[5:] if digits.isdigit() and int(digits) < 256: return "%d;5;%d" % (48 if background else 38, int(digits)) if text.startswith("#") and len(text) == 7: try: red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16) except ValueError: raise MplexError("bad colour: %s" % text) return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue) raise MplexError("bad colour: %s" % text) @classmethod def parse(cls, text, base=(None, None, 0), reset=None): """Apply a style string on top of base; "default" returns to reset (base when omitted).""" fg, bg, attr = base reset = base if reset is None else reset for item in re.split(r"[,\s]+", str(text).strip()): if not item: continue low = item.lower() if low == "default": fg, bg, attr = reset elif low in ("none", "noattr"): attr = 0 elif low.startswith("fg="): fg = cls.color(item[3:], False) elif low.startswith("bg="): bg = cls.color(item[3:], True) elif low in cls.ATTRS: attr |= cls.ATTRS[low] elif low.startswith("no") and low[2:] in cls.ATTRS: attr &= ~cls.ATTRS[low[2:]] elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")): continue else: raise MplexError("bad style: %s" % item) return (fg, bg, attr) @staticmethod def sgr(fg, bg, attr): codes = ["0"] for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")): if attr & bit: codes.append(code) if fg: codes.append(fg) if bg: codes.append(bg) return "\x1b[" + ";".join(codes) + "m" class MplexOptions: """Typed option store with a parent chain: window, session, then global.""" WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~" DEFS = { "prefix": ("key", "C-b"), "prefix2": ("key", "None"), "base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0), "escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500), "history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000), "mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""), "status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"), "status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"), "status-left": ("str", "[#{session_name}] "), "status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"), "status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40), "status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"), "message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"), "mode-style": ("style", "noattr,bg=yellow,fg=black"), "copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"), "copy-mode-mark-style": ("style", "bg=red,fg=black"), "display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000), "display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"), "window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), "window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), "window-status-separator": ("str", " "), "window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"), "pane-border-style": ("style", "default"), "pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"), "pane-border-lines": ("choice:single|double|heavy|simple", "single"), "clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"), "main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24), "renumber-windows": ("bool", False), "automatic-rename": ("bool", True), "automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"), "allow-set-title": ("bool", True), "default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"), "detach-on-destroy": ("bool", True), "wrap-search": ("bool", True), "word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False), "synchronize-panes": ("bool", False), "copy-command": ("str", ""), "buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50), "set-clipboard": ("choice:on|external|off", "off"), } def __init__(self, parent=None): self.parent = parent self.values = {} @classmethod def check(cls, name, raw): if name not in cls.DEFS: raise MplexError("unknown or unsupported option: %s" % name) kind = cls.DEFS[name][0] text = str(raw) if kind == "str": return text if kind == "bool": low = text.strip().lower() if low in ("on", "yes", "true", "1"): return True if low in ("off", "no", "false", "0"): return False raise MplexError("value is invalid: %s" % text) if kind == "key": return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text) if kind == "colour": MplexStyle.color(text, False) return text if kind == "style": if "#{" not in text: MplexStyle.parse(text) return text if kind.startswith("int:"): _tag, low, high = kind.split(":") try: value = int(text) except ValueError: raise MplexError("value is invalid: %s" % text) if value < int(low) or value > int(high): raise MplexError("value out of range %s..%s: %s" % (low, high, text)) return value if kind.startswith("choice:"): choices = kind.split(":", 1)[1].split("|") if text not in choices: raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text)) return text raise MplexError("bad option kind for %s" % name) def get(self, name): node = self while node is not None: if name in node.values: return node.values[name] node = node.parent default = self.DEFS[name][1] if name in ("mode-keys", "status-keys"): editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" return "vi" if "vi" in editor else "emacs" return default def is_set(self, name): return name in self.values def set(self, name, raw, append=False): value = self.check(name, raw) if append and isinstance(value, str) and name in self.values: value = self.values[name] + value self.values[name] = value return value def toggle(self, name): if self.DEFS.get(name, ("",))[0] != "bool": raise MplexError("only boolean options can be toggled: %s" % name) self.values[name] = not self.get(name) def unset(self, name): if name not in self.DEFS: raise MplexError("unknown or unsupported option: %s" % name) self.values.pop(name, None) @staticmethod def show(value): if value is True: return "on" if value is False: return "off" return str(value) class MplexBinding: __slots__ = ("command", "repeat", "note") def __init__(self, command, repeat=False, note=""): self.command = command self.repeat = repeat self.note = note class MplexConfig: """Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load: each bad entry is skipped, counted in errors, and shown to the first client.""" ERRORS_MAX = 50 TOP_KEYS = ("options", "keys") def __init__(self): self.options = MplexOptions() self.tables = {} self.errors = [] self.path = "" self.skipped = 0 for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS: self.bind(table, MplexKeys.canonical(key), command, repeat, note) def error(self, text): self.skipped += 1 if len(self.errors) < self.ERRORS_MAX: self.errors.append(text) def bind(self, table, key, command, repeat=False, note=""): self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note) def unbind(self, table, key): return self.tables.get(table, {}).pop(key, None) is not None def lookup(self, table, key): return self.tables.get(table, {}).get(key) @staticmethod def default_path(home): return os.path.join(home, MPLEX_CONFIG_NAME) @classmethod def load(cls, path): config = cls() config.path = path config.apply_file(path) return config def apply_file(self, path): try: if os.path.getsize(path) > MPLEX_CONFIG_MAX: self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX)) return with open(path, "rb") as handle: raw = handle.read() except FileNotFoundError: return except OSError as exc: self.error("%s: cannot read: %s" % (path, short_error(exc))) return try: import tomllib except ImportError: self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path) return try: data = tomllib.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError) as exc: self.error("%s: not valid TOML: %s" % (path, exc)) return self.apply_data(data) def apply_data(self, data): for key in data: if key not in self.TOP_KEYS: self.error("unknown top-level key [%s] (use options, keys)" % key) options = data.get("options", {}) if not isinstance(options, dict): self.error("[options] must be a table") options = {} for name, value in options.items(): try: if isinstance(value, bool): value = "on" if value else "off" elif not isinstance(value, (str, int)): raise MplexError("value must be a string, number or boolean") self.options.set(name, value) except MplexError as exc: self.error("options.%s: %s" % (name, exc)) keys = data.get("keys", {}) if not isinstance(keys, dict): self.error("[keys] must be a table") return clear = keys.get("clear", []) if not isinstance(clear, list): self.error("keys.clear must be a list of table names") clear = [] for table in clear: self.tables[str(table)] = {} for table, entries in keys.items(): if table == "clear": continue if not isinstance(entries, dict): self.error("keys.%s must be a table of key = command" % table) continue for key, spec in entries.items(): self._apply_binding(table, key, spec) def _apply_binding(self, table, key, spec): where = "keys.%s.%s" % (table, key) try: name = MplexKeys.canonical(key) if spec is False or spec == "": self.unbind(table, name) return note = "" repeat = False if isinstance(spec, dict): unknown = [item for item in spec if item not in ("command", "repeat", "note")] if unknown: raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0]) command = spec.get("command") repeat = bool(spec.get("repeat", False)) note = str(spec.get("note", "")) else: command = spec if not isinstance(command, str) or not command.strip(): raise MplexError("command must be a non-empty string, or false to unbind") for words in MplexCommandParser.parse(command): MplexCommands.resolve(words[0]) self.bind(table, name, command, repeat, note) except MplexError as exc: self.error("%s: %s" % (where, exc)) class MplexFormat: """tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as =N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through.""" SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index", "S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"} DEPTH_MAX = 12 @staticmethod def match_close(text, start, opener, closer): """Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1.""" depth = 1 pos = start + 1 while pos < len(text): ch = text[pos] if ch == "#" and pos + 1 < len(text): if text[pos + 1] == opener: depth += 1 pos += 2 continue if ch == closer: depth -= 1 if depth == 0: return pos pos += 1 return -1 @classmethod def split_commas(cls, text): parts = [] cur = [] depth = 0 pos = 0 while pos < len(text): ch = text[pos] if ch == "#" and pos + 1 < len(text): nxt = text[pos + 1] if nxt == "," and depth == 0: cur.append("#,") pos += 2 continue if nxt in "{(": depth += 1 cur.append(text[pos:pos + 2]) pos += 2 continue cur.append(text[pos:pos + 2]) pos += 2 continue if ch == "}" or ch == ")": depth = max(0, depth - 1) if ch == "," and depth == 0: parts.append("".join(cur)) cur = [] else: cur.append(ch) pos += 1 parts.append("".join(cur)) return parts @classmethod def expand(cls, text, resolve, depth=0): if depth > cls.DEPTH_MAX or not text: return "" out = [] pos = 0 n = len(text) while pos < n: ch = text[pos] if ch != "#" or pos + 1 >= n: out.append(ch) pos += 1 continue nxt = text[pos + 1] if nxt == "#": out.append("#") pos += 2 elif nxt == "{": end = cls.match_close(text, pos + 1, "{", "}") if end < 0: out.append(ch) pos += 1 else: out.append(cls.evaluate(text[pos + 2:end], resolve, depth)) pos = end + 1 elif nxt == "(": end = cls.match_close(text, pos + 1, "(", ")") if end < 0: out.append(ch) pos += 1 else: out.append(resolve("(" + text[pos + 2:end]) or "") pos = end + 1 elif nxt == "[": end = text.find("]", pos + 2) if end < 0: out.append(ch) pos += 1 else: out.append(text[pos:end + 1]) pos = end + 1 elif nxt in cls.SHORT: out.append(resolve(cls.SHORT[nxt]) or "") pos += 2 elif nxt in ",}": out.append(nxt) pos += 2 else: out.append(ch) pos += 1 return "".join(out) @staticmethod def truthy(value): return value not in ("", "0", None) @classmethod def value_of(cls, text, resolve, depth): """A variable name or a nested format: its text value.""" if "#" in text: return cls.expand(text, resolve, depth + 1) value = resolve(text) return "" if value is None else str(value) @classmethod def evaluate(cls, inner, resolve, depth): if inner.startswith("?"): parts = cls.split_commas(inner[1:]) index = 0 while index + 1 < len(parts): cond = cls.value_of(parts[index], resolve, depth) if cls.truthy(cond): return cls.expand(parts[index + 1], resolve, depth + 1) index += 2 if index < len(parts): return cls.expand(parts[index], resolve, depth + 1) return "" colon = cls.modifier_colon(inner) if colon < 0: value = resolve(inner) return "" if value is None else str(value) mods = inner[:colon] rest = inner[colon + 1:] return cls.apply_modifiers(mods, rest, resolve, depth) @staticmethod def modifier_colon(inner): """Index of the ':' that ends a modifier list, or -1 when inner is a plain name.""" colon = inner.find(":") if colon <= 0: return -1 head = inner[:colon] if "#" in head or "{" in head or " " in head: return -1 return colon @classmethod def apply_modifiers(cls, mods, rest, resolve, depth): specs = [item for item in mods.split(";") if item] text = None for spec in specs: if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"): parts = cls.split_commas(rest) left = cls.expand(parts[0], resolve, depth + 1) if parts else "" right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" return cls.compare(spec, left, right) if spec in ("m", "m/r", "m/ri", "m/i"): parts = cls.split_commas(rest) pattern = cls.expand(parts[0], resolve, depth + 1) if parts else "" subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" flags = re.IGNORECASE if spec.endswith("i") else 0 if spec.startswith("m/r"): try: return "1" if re.search(pattern, subject, flags) else "0" except re.error: return "0" import fnmatch return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0" if spec == "l": return rest if text is None: text = cls.value_of(rest, resolve, depth) text = cls.apply_one(spec, text, resolve, depth) return text or "" @classmethod def apply_one(cls, spec, text, resolve, depth): if spec == "E" or spec == "T": return cls.expand(text, resolve, depth + 1) if spec == "q": return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text) if spec == "t": try: return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text))) except (ValueError, OverflowError, OSError): return text if spec == "n": return str(len(text)) if spec == "b": return os.path.basename(text) if spec == "d": return os.path.dirname(text) if spec == "!": return "0" if cls.truthy(text) else "1" if spec == "!!": return "1" if cls.truthy(text) else "0" if spec.startswith("="): body = spec[1:] marker = "" if body.startswith("/"): _blank, count, marker = (body.split("/", 2) + ["", ""])[:3] body = count try: limit = int(body) except ValueError: return text if limit >= 0 and len(text) > limit: return text[:limit] + marker if limit < 0 and len(text) > -limit: return marker + text[limit:] return text if spec.startswith("s/"): fields = spec.split("/") if len(fields) >= 3: try: return re.sub(fields[1], fields[2], text) except re.error: return text return text @staticmethod def compare(op, left, right): if op.startswith("e|"): kind = op.split("|")[1] if "|" in op else "" try: a, b = float(left), float(right) except ValueError: return "0" table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0, "==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b} if kind not in table: return "0" value = table[kind] if isinstance(value, bool): return "1" if value else "0" return str(int(value)) if float(value).is_integer() else str(value) if op == "==": return "1" if left == right else "0" if op == "!=": return "1" if left != right else "0" if op == "&&": return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0" if op == "||": return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0" try: a, b = float(left), float(right) except ValueError: a, b = left, right return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0" @staticmethod def with_time(text, stamp=None): if "%" not in text: return text try: return time.strftime(text, time.localtime(stamp)) except (ValueError, OverflowError): return text @staticmethod def strip_styles(text): return re.sub(r"#\[[^\]]*\]", "", text) class MplexLayoutNode: __slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse") def __init__(self, kind, pane=None, w=1, h=1): self.reverse = False self.kind = kind self.children = [] self.parent = None self.pane = pane self.x = 0 self.y = 0 self.w = w self.h = h def leaves(self): if self.kind == "pane": return [self] found = [] for child in self.children: found.extend(child.leaves()) return found def adopt(self, child): child.parent = self self.children.append(child) return child class MplexLayout: """Split tree: a node is a pane or a container laid out side by side ("lr") or stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are rescaled proportionally when the window changes size.""" @staticmethod def size_of(node, kind): return node.w if kind == "lr" else node.h @staticmethod def set_size(node, kind, value): if kind == "lr": node.w = value else: node.h = value @classmethod def assign(cls, node, x, y, w, h): node.x, node.y, node.w, node.h = x, y, w, h if node.kind == "pane": return kids = node.children count = len(kids) kind = node.kind total = (w if kind == "lr" else h) - (count - 1) if total < count: sizes = [1] * count else: sizes = [max(1, cls.size_of(kid, kind)) for kid in kids] weight = sum(sizes) or count if weight != total: sizes = [max(1, int(round(size * total / weight))) for size in sizes] diff = total - sum(sizes) while diff != 0: moved = False for index in (range(count) if diff > 0 else range(count - 1, -1, -1)): if diff > 0: sizes[index] += 1 diff -= 1 moved = True elif sizes[index] > 1: sizes[index] -= 1 diff += 1 moved = True if diff == 0: break if not moved: break order = list(zip(kids, sizes)) if node.reverse: order.reverse() pos = x if kind == "lr" else y for kid, size in order: if kind == "lr": cls.assign(kid, pos, y, size, h) else: cls.assign(kid, x, pos, w, size) pos += size + 1 @classmethod def split(cls, root, leaf, kind, before, new_pane, size=None): """Insert new_pane beside leaf. Returns (root, new_leaf).""" total = cls.size_of(leaf, kind) if total < 3: raise MplexError("no space for new pane") avail = total - 1 if size is None: new_size = avail - avail // 2 if before else avail // 2 else: new_size = max(1, min(avail - 1, size)) old_size = avail - new_size fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h) parent = leaf.parent if parent is not None and parent.kind == kind: index = parent.children.index(leaf) parent.children.insert(index if before else index + 1, fresh) fresh.parent = parent else: box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h) box.x, box.y = leaf.x, leaf.y box.parent = parent if parent is None: root = box else: parent.children[parent.children.index(leaf)] = box for item in ((fresh, leaf) if before else (leaf, fresh)): box.adopt(item) cls.set_size(leaf, kind, old_size) cls.set_size(fresh, kind, new_size) return root, fresh @classmethod def remove(cls, root, leaf): """Drop leaf, hand its space to a neighbour. Returns the new root (None when empty).""" parent = leaf.parent if parent is None: return None index = parent.children.index(leaf) parent.children.remove(leaf) neighbour = parent.children[index - 1] if index > 0 else parent.children[0] kind = parent.kind cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1) if len(parent.children) == 1: only = parent.children[0] grand = parent.parent only.w, only.h = parent.w, parent.h only.parent = grand if grand is None: return only slot = grand.children.index(parent) if only.kind == grand.kind: grand.children[slot:slot + 1] = only.children for child in only.children: child.parent = grand else: grand.children[slot] = only return root @classmethod def resize_by(cls, leaf, kind, delta, prefer_after): """Move the border of leaf's nearest enclosing "kind" container by delta cells.""" node = leaf while node.parent is not None and node.parent.kind != kind: node = node.parent parent = node.parent if parent is None: raise MplexError("no border to resize") kids = parent.children index = kids.index(node) use_after = (index < len(kids) - 1) if prefer_after else (index == 0) first = index if use_after else index - 1 a, b = kids[first], kids[first + 1] size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind) new_a = max(1, min(size_a + size_b - 1, size_a + delta)) cls.set_size(a, kind, new_a) cls.set_size(b, kind, size_a + size_b - new_a) @staticmethod def shares(total, count, spare_first): """Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last.""" room = total - (count - 1) base, extra = divmod(room, count) sizes = [base] * count for index in (range(extra) if spare_first else range(count - extra, count)): sizes[index] += 1 return sizes @classmethod def resize_to(cls, leaf, kind, size): """Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before).""" change = size - cls.size_of(leaf, kind) node = leaf while node.parent is not None and node.parent.kind != kind: node = node.parent if node.parent is None: raise MplexError("no border to resize") has_next = node.parent.children.index(node) < len(node.parent.children) - 1 cls.resize_by(leaf, kind, change if has_next else -change, True) @classmethod def preset(cls, name, panes, width, height, main_width, main_height): count = len(panes) leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes] mirrored = name.endswith("-mirrored") base = name[:-len("-mirrored")] if mirrored else name if count == 1: leaves[0].w, leaves[0].h = width, height return leaves[0] def box(kind, items, w, h, spare_first=True): node = MplexLayoutNode(kind, w=w, h=h) for item in items: node.adopt(item) for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)): if kind == "lr": item.w, item.h = size, h else: item.w, item.h = w, size return node if base == "even-horizontal": return box("lr", leaves, width, height) if base == "even-vertical": return box("tb", leaves, width, height) if base in ("main-horizontal", "main-vertical"): horizontal = base == "main-horizontal" span = height if horizontal else width main_size = max(1, min(span - 2, main_height if horizontal else main_width)) main = leaves[0] rest = leaves[1:] other_size = span - main_size - 1 if horizontal: others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size) main.w, main.h, others.w, others.h = width, main_size, width, other_size root = box("tb", [main, others], width, height) else: others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height) main.w, main.h, others.w, others.h = main_size, height, other_size, height root = box("lr", [main, others], width, height) if horizontal: main.h, others.h = main_size, other_size else: main.w, others.w = main_size, other_size root.reverse = mirrored return root rows = columns = 1 while rows * columns < count: rows += 1 if rows * columns < count: columns += 1 lines = [] for start in range(0, count, columns): chunk = leaves[start:start + columns] lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False)) if len(lines) == 1: return lines[0] root = box("tb", lines, width, height, spare_first=False) for line in lines: line.w = width return root @staticmethod def neighbour(panes, current, direction, width, height): left, top = current.x, current.y right, bottom = current.x + current.w, current.y + current.h def overlaps(other): if direction in ("L", "R"): return other.y < bottom and other.y + other.h > top return other.x < right and other.x + other.w > left wanted = [] for other in panes: if other is current or not overlaps(other): continue if direction == "L" and other.x + other.w + 1 == left: wanted.append(other) elif direction == "R" and other.x == right + 1: wanted.append(other) elif direction == "U" and other.y + other.h + 1 == top: wanted.append(other) elif direction == "D" and other.y == bottom + 1: wanted.append(other) if not wanted: for other in panes: if other is current or not overlaps(other): continue if direction == "L" and other.x + other.w >= width: wanted.append(other) elif direction == "R" and other.x == 0: wanted.append(other) elif direction == "U" and other.y + other.h >= height: wanted.append(other) elif direction == "D" and other.y == 0: wanted.append(other) if not wanted: return None return max(wanted, key=lambda item: item.last_active) class MplexPane: """One pty-backed shell: its screen, scrollback, output queue and mode stack.""" def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"): self.id = ident self.window = None self.x = 0 self.y = 0 self.w = max(1, cols) self.h = max(1, rows) self.screen = TuiVtScreen(self.h, self.w) self.screen.history = collections.deque(maxlen=max(0, history_limit)) self.master_fd = None self.pid = None self.dead = False self.exit_status = None self.pending_in = bytearray() self.dropped = 0 self.modes = [] self.prefix_count = 1 self.created = time.time() self.last_active = 0.0 self.shell_name = "" self.last_search = None self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident)) self._log = None self._log_size = 0 self.input_bytes = 0 @property def tag(self): return "%%%d" % self.id @property def title(self): return self.screen.title or socket.gethostname() def spawn(self, file, argv, cwd, env): master, slave = os.openpty() try: fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) attrs = termios.tcgetattr(slave) attrs[0] |= getattr(termios, "IUTF8", 0) termios.tcsetattr(slave, termios.TCSANOW, attrs) except (OSError, termios.error): pass log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) self._log = os.fdopen(log_fd, "ab", buffering=0) pid = os.fork() if pid == 0: try: os.setsid() fcntl.ioctl(slave, termios.TIOCSCTTY, 0) for fd in (0, 1, 2): os.dup2(slave, fd) if slave > 2: os.close(slave) os.close(master) for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM): signal.signal(sig, signal.SIG_DFL) try: os.chdir(cwd) except OSError: pass os.execvpe(file, argv, env) except BaseException: pass finally: os._exit(127) os.close(slave) os.set_blocking(master, False) self.master_fd = master self.pid = pid self.shell_name = os.path.basename(file) def place(self, x, y, w, h): self.x, self.y = x, y w, h = max(1, w), max(1, h) if (w, h) == (self.w, self.h): return self.w, self.h = w, h self.screen.resize(h, w) self.apply_winsize() for mode in self.modes: mode.on_resize() def apply_winsize(self): if self.master_fd is None: return try: fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) except OSError: pass def feed(self, data): history = self.screen.history before = self.screen.history_pushed before_len = len(history) if history is not None else 0 self.screen.feed(data) pushed = self.screen.history_pushed - before evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0 if self._log is not None: self.log(data) replies = self.screen.take_replies() if replies: self.write(replies) for mode in self.modes: mode.on_output(pushed, evicted) def log(self, data): """Append to the session log; at MPLEX_LOG_MAX it rolls over to .1 (one generation kept).""" try: if self._log_size + len(data) > MPLEX_LOG_MAX: self._log.close() os.replace(self.log_path, self.log_path + ".1") fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) self._log = os.fdopen(fd, "ab", buffering=0) self._log_size = 0 self._log.write(data) self._log_size += len(data) except OSError: pass def write(self, data): if not data: return if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX: self.dropped += len(data) return self.pending_in += data def drain(self): if not self.pending_in or self.master_fd is None: return try: written = os.write(self.master_fd, bytes(self.pending_in[:4096])) del self.pending_in[:written] except BlockingIOError: pass except OSError: self.pending_in.clear() def top_mode(self): return self.modes[-1] if self.modes else None def push_mode(self, mode): self.modes.append(mode) return mode def pop_mode(self, mode=None): if mode is None: if self.modes: self.modes.pop() elif mode in self.modes: self.modes.remove(mode) def find_mode(self, cls): for mode in self.modes: if isinstance(mode, cls): return mode return None def foreground_command(self): try: group = os.tcgetpgrp(self.master_fd) with open("/proc/%d/comm" % group, "r") as handle: return handle.read().strip() or self.shell_name except (OSError, TypeError, ValueError): return self.shell_name def current_path(self): try: return os.readlink("/proc/%d/cwd" % self.pid) except (OSError, TypeError): return "" def history_size(self): history = self.screen.history return len(history) if history is not None else 0 def clear_history(self): if self.screen.history is not None: self.screen.history.clear() def close(self, hard=False): """Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead.""" if self.pid is not None: sig = signal.SIGKILL if hard else signal.SIGHUP try: os.killpg(self.pid, sig) except OSError: try: os.kill(self.pid, sig) except OSError: pass if self.master_fd is not None: try: os.close(self.master_fd) except OSError: pass self.master_fd = None if self._log is not None: try: self._log.close() except OSError: pass self._log = None def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24): """Last lines of the raw session log, reading only the tail in growing chunks.""" try: size = os.path.getsize(self.log_path) except OSError: return "" with open(self.log_path, "rb") as handle: read_from = size data = b"" while True: read_from = max(0, read_from - chunk_size) handle.seek(read_from) data = handle.read(size - read_from) if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes: break return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:]) class MplexWindow: def __init__(self, ident, name, width, height, parent_options): self.id = ident self.name = name self.root = None self.active = None self.last_pane = None self.zoomed = None self.width = width self.height = height self.layout_index = -1 self.options = MplexOptions(parent_options) self.auto_rename = True self.created = time.time() self.session = None @property def tag(self): return "@%d" % self.id def panes(self): return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else [] def relayout(self): if self.root is None: return MplexLayout.assign(self.root, 0, 0, self.width, self.height) for leaf in self.root.leaves(): leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h) if self.zoomed is not None and self.zoomed in self.panes(): self.zoomed.place(0, 0, self.width, self.height) else: self.zoomed = None def visible_panes(self): return [self.zoomed] if self.zoomed is not None else self.panes() def leaf_of(self, pane): for leaf in self.root.leaves(): if leaf.pane is pane: return leaf return None def set_active(self, pane): if pane is not self.active: self.last_pane = self.active self.active = pane pane.last_active = time.monotonic() class MplexSession: def __init__(self, ident, name, parent_options, cwd, env): self.id = ident self.name = name self.windows = {} self.current = None self.history = [] self.created = time.time() self.options = MplexOptions(parent_options) self.cwd = cwd self.env = env self.last_attached = time.time() @property def tag(self): return "$%d" % self.id def window(self): return self.windows.get(self.current) def indexes(self): return sorted(self.windows) def free_index(self, base): index = base while index in self.windows: index += 1 return index @property def last(self): """Index of the window that was current before this one, or None.""" for window in self.history: index = self.index_of(window) if index is not None: return index return None def select(self, index): if index not in self.windows: return False if index != self.current: previous = self.window() target = self.windows[index] self.history = [item for item in self.history if item is not previous and item is not target] if previous is not None: self.history.insert(0, previous) self.current = index return True def index_of(self, window): for index, item in self.windows.items(): if item is window: return index return None class MplexCanvas: """A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that turns two successive grids into the escape sequences that repaint only what changed.""" BLANK = (" ", None, None, 0) GLYPHS = { "single": "─│┌┐└┘├┤┬┴┼", "double": "═║╔╗╚╝╠╣╦╩╬", "heavy": "━┃┏┓┗┛┣┫┳┻╋", "simple": "-|+++++++++", } def __init__(self, rows, cols): self.rows = rows self.cols = cols self.cells = [[self.BLANK] * cols for _row in range(rows)] self.cursor = None def put(self, x, y, text, style=(None, None, 0), limit=None): if y < 0 or y >= self.rows: return x fg, bg, attr = style row = self.cells[y] for char in text: if limit is not None and limit <= 0: break if 0 <= x < self.cols: row[x] = (char, fg, bg, attr) x += 1 if limit is not None: limit -= 1 return x def put_cells(self, x, y, cells): if y < 0 or y >= self.rows: return row = self.cells[y] for cell in cells: if 0 <= x < self.cols: row[x] = cell x += 1 def blit(self, x, y, block, width): for index, source in enumerate(block): target = y + index if target < 0 or target >= self.rows: continue row = self.cells[target] take = min(width, len(source), self.cols - x) if take > 0: row[x:x + take] = source[:take] def fill(self, x, y, w, h, style=(None, None, 0), char=" "): cell = (char, style[0], style[1], style[2]) for target in range(max(0, y), min(self.rows, y + h)): row = self.cells[target] for col in range(max(0, x), min(self.cols, x + w)): row[col] = cell @classmethod def border_char(cls, up, down, left, right, lines): glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"]) horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs if (up or down) and not (left or right): return vert if (left or right) and not (up or down): return horiz if up and down and left and right: return cross if up and down and right: return tee_r if up and down and left: return tee_l if left and right and down: return tee_d if left and right and up: return tee_u if down and right: return tl if down and left: return tr if up and right: return bl if up and left: return br return cross def encode(self, previous): """Escape bytes repainting this grid over previous (a list of row tuples, or None).""" snapshot = [tuple(row) for row in self.cells] out = ["\x1b[?25l"] if previous is None or len(previous) != len(snapshot): out.append("\x1b[0m\x1b[2J") previous = None for index, row in enumerate(snapshot): if previous is not None and previous[index] == row: continue out.append("\x1b[%d;1H" % (index + 1)) current = None for char, fg, bg, attr in row: style = (fg, bg, attr) if style != current: out.append(MplexStyle.sgr(fg, bg, attr)) current = style out.append(char) out.append("\x1b[0m") if self.cursor is not None: out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1)) return "".join(out).encode("utf-8", "replace"), snapshot class MplexStatusText: """Format-expanded text with #[...] style markers turned into styled cells.""" @staticmethod def cells(text, base, reset=None): reset = base if reset is None else reset current = base out = [] pos = 0 while pos < len(text): if text.startswith("#[", pos): end = text.find("]", pos + 2) if end > 0: try: current = MplexStyle.parse(text[pos + 2:end], current, reset) except MplexError: pass pos = end + 1 continue out.append((text[pos], current[0], current[1], current[2])) pos += 1 return out class MplexMode: """Pane mode base: owns the pane's keyboard and its drawing while it is on top.""" name = "mode" def __init__(self, server, pane): self.server = server self.pane = pane def render(self, client): raise NotImplementedError def key(self, name, raw, client): return None def on_output(self, pushed, evicted): return None def on_resize(self): return None def close(self): self.pane.pop_mode(self) self.server.mark_dirty(self.pane.window) class MplexCopyMode(MplexMode): """Scrollback browser: movement, search, selection and copy, driven by the copy-mode / copy-mode-vi key tables through "send-keys -X ".""" name = "copy-mode" def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True): super().__init__(server, pane) self.static_rows = None if lines is not None: self.name = "view-mode" self.static_rows = [[(char, None, None, 0) for char in line] for line in lines] while len(self.static_rows) < pane.h: self.static_rows.append([]) self.oy = 0 self.cx = 0 if lines is not None else pane.screen.cx self.cy = 0 if lines is not None else pane.screen.cy self.anchor = None self.rect = False self.line_select = False self.mark = None self.jump = None self.inc_origin = None self.show_position = show_position self.exit_at_bottom = exit_at_bottom self.count = 1 self.lastcx = 0 self.lastsx = 0 # a search string outlives copy mode; re-entering it starts searching upward, as tmux does self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None self._line_cache = (-1, "") if lines is not None: self.oy = self.hist() @property def rows(self): return self.pane.h @property def cols(self): return self.pane.w def hist(self): if self.static_rows is not None: return max(0, len(self.static_rows) - self.rows) return self.pane.history_size() def total(self): return self.hist() + self.rows def top(self): return self.hist() - self.oy def abs_y(self): return self.top() + self.cy def row(self, ay): if self.static_rows is not None: return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else [] hist = self.hist() if ay < hist: return self.pane.screen.history[ay] grid = self.pane.screen.grid index = ay - hist return grid[index] if 0 <= index < len(grid) else [] def text(self, ay): return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip() def clamp(self): self.oy = max(0, min(self.hist(), self.oy)) self.cy = max(0, min(self.rows - 1, self.cy)) self.cx = max(0, min(self.cols, self.cx)) def goto(self, x, ay): ay = max(0, min(self.total() - 1, ay)) top = self.top() if ay < top: top = ay elif ay >= top + self.rows: top = ay - self.rows + 1 self.oy = max(0, min(self.hist(), self.hist() - top)) self.cy = ay - self.top() self.cx = max(0, min(self.cols, x)) def on_output(self, pushed, evicted): if self.static_rows is not None: return if self.oy > 0 and pushed: self.oy += pushed if evicted: if self.anchor: self.anchor = (self.anchor[0], self.anchor[1] - evicted) if self.mark: self.mark = (self.mark[0], self.mark[1] - evicted) self.clamp() def on_resize(self): self.clamp() # ---- rendering ------------------------------------------------------- def vi_keys(self): return self.server.pane_options(self.pane).get("mode-keys") == "vi" def selected(self, x, ay): if self.anchor is None: return False ax, ay0 = self.anchor cx, cy = self.cx, self.abs_y() extra = 1 if self.vi_keys() else 0 if self.rect: return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra if self.line_select: return min(ay0, cy) <= ay <= max(ay0, cy) first, last = sorted(((ay0, ax), (cy, cx))) return first <= (ay, x) < (last[0], last[1] + extra) def pattern(self): if not self.search: return None return self.compile(self.search[0], len(self.search) > 2 and self.search[2]) def render(self, client): opts = self.server.pane_options(self.pane) mode_style = MplexStyle.parse(opts.get("mode-style")) match_style = MplexStyle.parse(opts.get("copy-mode-match-style")) pattern = self.pattern() top = self.top() out = [] for view_row in range(self.rows): ay = top + view_row source = self.row(ay) cells = [] for col in range(self.cols): cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK cells.append(cell) if pattern is not None: line = "".join(cell[0] for cell in cells) for match in pattern.finditer(line): if match.end() == match.start(): continue for col in range(match.start(), match.end()): cells[col] = self.tint(cells[col], match_style) if self.anchor is not None: for col in range(self.cols): if self.selected(col, ay): cells[col] = self.tint(cells[col], mode_style) out.append(cells) if self.show_position and out: label = "[%d/%d]" % (self.oy, self.hist()) start = max(0, self.cols - len(label)) for index, char in enumerate(label[:self.cols]): out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2]) return out, (self.cx, self.cy) @staticmethod def tint(cell, style): char, fg, bg, attr = cell return (char, style[0] or fg, style[1] or bg, attr | style[2]) # ---- keys ------------------------------------------------------------ def key(self, name, raw, client): table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode" binding = self.server.config.lookup(table, name) if binding is not None: self.server.run_binding(client, binding, self.pane) return True def command(self, name, words, client): """Run one copy-mode command (send-keys -X name args) count times.""" method = getattr(self, "c_" + name.replace("-", "_"), None) if method is None: raise MplexError("unknown copy mode command: %s" % name) count = max(1, min(self.pane.prefix_count, 10000)) self.pane.prefix_count = 1 self.count = count self._line_cache = (-1, "") result = method(words, client) self.clamp() self.server.mark_dirty(self.pane.window) if result == "cancel": self.close() # ---- cursor and scrolling -------------------------------------------- # The cursor sits between characters like tmux's: it may stand one past the # last character of a line (x == line length), and vertical moves remember the # column they started from (lastcx / lastsx), snapping to the line end the way # tmux does. These rules were checked against tmux 3.6 with a differential fuzz. def llen(self, ay): return len(self.text(ay)) def snap(self): px = self.llen(self.abs_y()) if (self.cx >= self.lastsx and self.cx != px) or self.cx > px: self.cx = px def c_cancel(self, words, client): return "cancel" def step_up(self, scroll_only): ox = self.llen(self.abs_y()) if self.cx != ox: self.lastcx, self.lastsx = self.cx, ox self.cx = self.lastcx if scroll_only or self.cy == 0: if self.oy < self.hist(): self.oy += 1 else: self.cy -= 1 self.snap() def step_down(self, scroll_only): ox = self.llen(self.abs_y()) if self.cx != ox: self.lastcx, self.lastsx = self.cx, ox self.cx = self.lastcx if scroll_only or self.cy == self.rows - 1: if self.oy > 0: self.oy -= 1 else: self.cy += 1 self.snap() def c_cursor_up(self, words, client): for _i in range(self.count): self.step_up(False) def c_cursor_down(self, words, client): for _i in range(self.count): self.step_down(False) return self.bottom_exit() def c_cursor_down_and_cancel(self, words, client): for _i in range(self.count): self.step_down(False) if self.oy == 0 and self.cy == self.rows - 1: return "cancel" def bottom_exit(self): return "cancel" if self.exit_at_bottom and self.oy == 0 else None def c_cursor_left(self, words, client): for _i in range(self.count): ay = self.abs_y() if self.cx == 0 and ay > 0: self.goto(self.llen(ay - 1), ay - 1) elif self.cx > 0: self.cx -= 1 def c_cursor_right(self, words, client): for _i in range(self.count): ay = self.abs_y() if self.cx >= self.llen(ay): if ay < self.total() - 1: self.goto(0, ay + 1) else: self.cx += 1 def c_start_of_line(self, words, client): self.cx = 0 def c_end_of_line(self, words, client): self.cx = self.llen(self.abs_y()) def c_back_to_indentation(self, words, client): line = self.text(self.abs_y()) if line: self.cx = len(line) - len(line.lstrip(" ")) def c_top_line(self, words, client): self.cx, self.cy = 0, 0 def c_middle_line(self, words, client): self.cx, self.cy = 0, (self.rows - 1) // 2 def c_bottom_line(self, words, client): self.cx, self.cy = 0, self.rows - 1 def c_history_top(self, words, client): self.oy, self.cx, self.cy = self.hist(), 0, 0 def c_history_bottom(self, words, client): self.oy, self.cy = 0, self.rows - 1 self.cx = self.llen(self.abs_y()) return self.bottom_exit() def page_size(self, half): return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1 def remember_column(self): ox = self.llen(self.abs_y()) if self.cx != ox: self.lastcx, self.lastsx = self.cx, ox self.cx = self.lastcx def page_up(self, half): size = self.page_size(half) self.remember_column() if self.oy + size > self.hist(): self.oy = self.hist() self.cy = self.cy - size if self.cy > size else 0 else: self.oy += size self.snap() def page_down(self, half): size = self.page_size(half) self.remember_column() if self.oy < size: self.cy = min(self.rows - 1, self.cy + size) self.oy = 0 else: self.oy -= size self.snap() def c_page_up(self, words, client): for _i in range(self.count): self.page_up(False) def c_page_down(self, words, client): for _i in range(self.count): self.page_down(False) return self.bottom_exit() def c_page_down_and_cancel(self, words, client): for _i in range(self.count): self.page_down(False) if self.oy == 0: return "cancel" def c_halfpage_up(self, words, client): for _i in range(self.count): self.page_up(True) def c_halfpage_down(self, words, client): for _i in range(self.count): self.page_down(True) return self.bottom_exit() def c_halfpage_down_and_cancel(self, words, client): for _i in range(self.count): self.page_down(True) if self.oy == 0: return "cancel" def c_scroll_up(self, words, client): for _i in range(self.count): self.step_up(True) def c_scroll_down(self, words, client): for _i in range(self.count): self.step_down(True) return self.bottom_exit() def c_scroll_down_and_cancel(self, words, client): for _i in range(self.count): self.step_down(True) if self.oy == 0: return "cancel" def c_scroll_middle(self, words, client): mid = (self.rows - 1) // 2 delta = self.cy - mid if delta > 0 and self.oy >= delta: self.oy -= delta self.cy = mid elif delta < 0 and self.hist() - self.oy >= -delta: self.oy += -delta self.cy = mid def c_cursor_centre_vertical(self, words, client): self.cy = self.rows // 2 def c_cursor_centre_horizontal(self, words, client): return None def c_scroll_top(self, words, client): if self.oy >= self.cy: self.oy -= self.cy self.cy = 0 def c_scroll_bottom(self, words, client): need = self.rows - 1 - self.cy if self.hist() - self.oy >= need: self.oy += need self.cy = self.rows - 1 def c_goto_line(self, words, client): try: line = int(words[0]) except (IndexError, ValueError): raise MplexError("goto-line needs a line number") self.oy = max(0, min(self.hist(), line)) def c_refresh_from_pane(self, words, client): return None def c_toggle_position(self, words, client): self.show_position = not self.show_position # ---- words, paragraphs, brackets ------------------------------------- def in_ws(self, x, ay): return self.char_at(x, ay) == " " def in_sep(self, x, ay, separators): return self.char_at(x, ay) in separators def separators(self, big): return "" if big else self.server.pane_options(self.pane).get("word-separators") def char_at(self, x, ay): if self._line_cache[0] != ay: self._line_cache = (ay, self.text(ay)) line = self._line_cache[1] return line[x] if x < len(line) else " " def advance_blanks(self, x, ay, xx): """Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom).""" last = self.total() - 1 while x > xx or self.in_ws(x, ay): if x > xx: if ay == last: return x, ay, xx, True ay += 1 x = 0 xx = self.llen(ay) else: x += 1 return x, ay, xx, False def run_end(self, x, ay, xx, separators): """x just past the run of separators (or of word characters) that starts at x.""" if self.in_sep(x, ay, separators) and not self.in_ws(x, ay): x += 1 while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay): x += 1 else: x += 1 while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)): x += 1 return x def c_next_word(self, words, client, big=False): separators = self.separators(big) x, ay = self.cx, self.abs_y() for _i in range(self.count): xx = self.llen(ay) if x > xx: # past the line end (reachable after centre-vertical or goto-line): tmux carries on skipping # the first word of the next line before it looks for a word start if ay == self.total() - 1: break ay += 1 x = 0 xx = self.llen(ay) if not self.in_ws(x, ay): x = self.run_end(x, ay, xx, separators) x, ay, xx, _bottom = self.advance_blanks(x, ay, xx) self.goto(x, ay) def c_next_space(self, words, client): self.c_next_word(words, client, True) def c_next_word_end(self, words, client, big=False): separators = self.separators(big) vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" x, ay = self.cx, self.abs_y() for _i in range(self.count): xx = self.llen(ay) if vi: if x < xx: x += 1 elif ay < self.total() - 1: self.goto(0, ay + 1) x, ay = 0, ay + 1 xx = self.llen(ay) x, ay, xx, bottom = self.advance_blanks(x, ay, xx) if not bottom: x = self.run_end(x, ay, xx, separators) if vi and x > 0: x -= 1 if bottom: break self.goto(x, ay) def c_next_space_end(self, words, client): self.c_next_word_end(words, client, True) def c_previous_word(self, words, client, big=False): separators = self.separators(big) x, ay = self.cx, self.abs_y() for _i in range(self.count): # one step back, then over whitespace (and empty lines) to a word character while True: if x == 0: if ay == 0: break ay -= 1 x = self.llen(ay) else: x -= 1 if not self.in_ws(x, ay): break if self.in_ws(x, ay): break letters = not self.in_sep(x, ay, separators) while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)): x -= 1 self.goto(x, ay) def c_previous_space(self, words, client): self.c_previous_word(words, client, True) def c_next_paragraph(self, words, client): ay = self.abs_y() last = self.total() - 1 for _i in range(self.count): while ay < last and not self.llen(ay): ay += 1 while ay < last and self.llen(ay): ay += 1 self.scroll_to(self.llen(ay), ay) def c_previous_paragraph(self, words, client): ay = self.abs_y() for _i in range(self.count): while ay > 0 and not self.llen(ay): ay -= 1 while ay > 0 and self.llen(ay): ay -= 1 self.scroll_to(0, ay) def match_bracket(self, x, ay, forward): """Position of the bracket matching the one at (x, ay), or None.""" pairs = {"(": ")", "[": "]", "{": "}"} reverse = {value: key for key, value in pairs.items()} opener = self.char_at(x, ay) closer = pairs.get(opener) if forward else reverse.get(opener) if closer is None: return None depth = 0 pos = (x, ay) for _i in range(20000): char = self.char_at(*pos) if char == opener: depth += 1 elif char == closer: depth -= 1 if depth == 0: return pos pos = self.step(pos[0], pos[1], forward) if pos is None: return None return None def step(self, x, ay, forward): """Next position, line ends counting as one blank cell. None at the buffer edge.""" if forward: if x < self.llen(ay): return x + 1, ay return (0, ay + 1) if ay + 1 < self.total() else None if x > 0: return x - 1, ay return (self.llen(ay - 1), ay - 1) if ay > 0 else None def c_next_matching_bracket(self, words, client): ay = self.abs_y() if not self.llen(ay): return here = self.char_at(self.cx, ay) found = None if self.vi_keys(): if here in "([{": found = self.match_bracket(self.cx, ay, True) elif here in ")]}": found = self.match_bracket(self.cx, ay, False) else: for x in (self.cx, self.cx + 1): if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "): found = self.match_bracket(x, ay, True) if found is not None: self.goto(*found) else: self.c_next_word_end([], None, True) def c_previous_matching_bracket(self, words, client): ay = self.abs_y() if not self.llen(ay): return here = self.char_at(self.cx, ay) found = None if here in ")]}": found = self.match_bracket(self.cx, ay, False) elif self.vi_keys(): if here in "([{" or self.char_at(self.cx + 1, ay) in "([{": return elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}": found = self.match_bracket(self.cx - 1, ay, False) if found is not None: self.goto(*found) else: self.c_previous_word([], None, True) # ---- jump ------------------------------------------------------------ def do_jump(self, kind, char): line = self.text(self.abs_y()) x = self.cx for _i in range(self.count): if kind == "f": found = line.find(char, x + 1) elif kind == "t": found = line.find(char, x + 2) found = found - 1 if found >= 0 else -1 elif kind == "F": found = line.rfind(char, 0, x) else: found = line.rfind(char, 0, max(0, x - 1)) found = found + 1 if found >= 0 else -1 if found < 0: return x = found self.cx = x def set_jump(self, kind, words): if not words or not words[0]: raise MplexError("jump needs a character") self.jump = (kind, words[0][0]) self.do_jump(*self.jump) def c_jump_forward(self, words, client): self.set_jump("f", words) def c_jump_backward(self, words, client): self.set_jump("F", words) def c_jump_to_forward(self, words, client): self.set_jump("t", words) def c_jump_to_backward(self, words, client): self.set_jump("T", words) def c_jump_again(self, words, client): if self.jump: self.do_jump(*self.jump) def c_jump_reverse(self, words, client): if self.jump: opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]] self.do_jump(opposite, self.jump[1]) # ---- selection and copy ---------------------------------------------- def c_begin_selection(self, words, client): self.anchor = (self.cx, self.abs_y()) self.line_select = False def c_stop_selection(self, words, client): return None def c_clear_selection(self, words, client): self.anchor = None self.line_select = False def c_select_line(self, words, client): self.anchor = (0, self.abs_y()) self.line_select = True self.rect = False for _i in range(self.count - 1): self.step_down(False) self.cx = self.llen(self.abs_y()) def c_select_word(self, words, client): ay = self.abs_y() separators = self.separators(False) line = self.text(ay) if not line: return start = min(self.cx, len(line) - 1) while start > 0 and self.in_ws(start, ay): start -= 1 if self.in_ws(start, ay): return letters = not self.in_sep(start, ay, separators) end = start while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)): start -= 1 while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)): end += 1 self.anchor = (start, ay) self.line_select = False self.cx = end if self.vi_keys() else end + 1 def c_rectangle_toggle(self, words, client): self.rect = not self.rect def c_rectangle_on(self, words, client): self.rect = True def c_rectangle_off(self, words, client): self.rect = False def c_other_end(self, words, client): if self.anchor is None: return old = (self.cx, self.abs_y()) self.goto(*self.anchor) self.anchor = old def c_set_mark(self, words, client): self.mark = (self.cx, self.abs_y()) def c_jump_to_mark(self, words, client): if self.mark is None: return old = (self.cx, self.abs_y()) self.goto(*self.mark) self.mark = old def selection_text(self): """The selected text. Emacs keys: the cursor stands between characters, so the end is exclusive; vi keys: the character under the cursor is included, and so is the line end (a newline) when the cursor stands on it. Both checked against tmux 3.6.""" if self.anchor is None: return "" vi = self.vi_keys() ax, ay0 = self.anchor cx, cy = self.cx, self.abs_y() extra = 1 if vi else 0 if self.line_select: lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)] return "\n".join(lines) + ("\n" if vi else "") if self.rect: left, right = min(ax, cx), max(ax, cx) + extra return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1)) (sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx))) parts = [] for ay in range(sy, ey + 1): line = self.text(ay) first = sx if ay == sy else 0 last = ex + extra if ay == ey else len(line) piece = line[first:last] if ay == ey and vi and last > len(line): piece += "\n" parts.append(piece) return "\n".join(parts) def deliver(self, text, words, append=False): if not text: return command = words[0] if words else "" self.server.store_copy(text, command, append) def c_copy_selection(self, words, client): self.deliver(self.selection_text(), []) self.c_clear_selection(words, client) def c_copy_selection_and_cancel(self, words, client): self.deliver(self.selection_text(), []) return "cancel" def c_copy_pipe(self, words, client): self.deliver(self.selection_text(), words) self.c_clear_selection(words, client) def c_copy_pipe_and_cancel(self, words, client): self.deliver(self.selection_text(), words) return "cancel" def c_append_selection(self, words, client): self.deliver(self.selection_text(), [], True) self.c_clear_selection(words, client) def c_append_selection_and_cancel(self, words, client): self.deliver(self.selection_text(), [], True) return "cancel" def copy_span(self, from_start, words, cancel): """copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines.""" ay = self.abs_y() self.anchor = (0 if from_start else self.cx, ay) self.line_select = False self.rect = False for _i in range(self.count - 1): self.step_down(False) self.cx = self.llen(self.abs_y()) self.deliver(self.selection_text(), words) self.c_clear_selection(words, None) return "cancel" if cancel else None def c_copy_end_of_line(self, words, client): return self.copy_span(False, [], False) def c_copy_end_of_line_and_cancel(self, words, client): return self.copy_span(False, [], True) def c_copy_pipe_end_of_line(self, words, client): return self.copy_span(False, words, False) def c_copy_pipe_end_of_line_and_cancel(self, words, client): return self.copy_span(False, words, True) def c_copy_line(self, words, client): return self.copy_span(True, [], False) def c_copy_line_and_cancel(self, words, client): return self.copy_span(True, [], True) def c_copy_pipe_line(self, words, client): return self.copy_span(True, words, False) def c_copy_pipe_line_and_cancel(self, words, client): return self.copy_span(True, words, True) # ---- search ---------------------------------------------------------- def compile(self, text, literal=False): flags = re.IGNORECASE if text == text.lower() else 0 if literal: return re.compile(re.escape(text), flags) try: return re.compile(text, flags) except re.error: return None def find(self, pattern, forward, x, ay, wrap): """First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line).""" total = self.total() def matches_of(index): return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()] order = range(ay, total) if forward else range(ay, -1, -1) for index in order: found = matches_of(index) if index == ay: found = [m for m in found if (m.start() >= x if forward else m.start() < x)] if found: match = found[0] if forward else found[-1] return match.start(), match.end(), index if wrap: for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)): found = matches_of(index) if found: match = found[0] if forward else found[-1] return match.start(), match.end(), index return None def scroll_to(self, x, ay): """Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view.""" hist, rows = self.hist(), self.rows self.cx = max(0, min(self.cols, x)) top = hist - self.oy if top <= ay < top + rows: self.cy = ay - top return gap = rows // 4 if ay < rows: offset, self.cy = 0, ay elif ay > hist + rows - gap: offset, self.cy = hist, ay - hist else: offset, self.cy = ay + gap - rows, rows - gap self.oy = max(0, min(hist, hist - offset)) def run_search(self, text, forward, literal=False, origin=None): if not text: return False self.search = (text, forward, literal) if self.static_rows is None: self.pane.last_search = self.search pattern = self.compile(text, literal) if pattern is None: return False wrap = bool(self.server.pane_options(self.pane).get("wrap-search")) vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" x, ay = origin if origin is not None else (self.cx, self.abs_y()) if forward and vi and origin is None: x += 1 found = self.find(pattern, forward, x, ay, wrap) if found is None: return False start, end, line = found self.scroll_to(end if forward and not vi else start, line) return True def search_failed(self, client): """tmux reports a failed search as a status message, not as a command error.""" self.server.message(client, "Search failed") def search_command(self, words, forward, literal=False, client=None): text = words[0] if words else "" if not text and self.search: text = self.search[0] if not self.run_search(text, forward, literal): self.search_failed(client) def c_search_forward(self, words, client): self.search_command(words, True, False, client) def c_search_backward(self, words, client): self.search_command(words, False, False, client) def c_search_forward_text(self, words, client): self.search_command(words, True, True, client) def c_search_backward_text(self, words, client): self.search_command(words, False, True, client) def c_search_again(self, words, client): if not self.search: return text, forward, literal = self.search if not self.run_search(text, forward, literal): self.search_failed(client) def c_search_reverse(self, words, client): if not self.search: return text, forward, literal = self.search if not self.run_search(text, not forward, literal): self.search_failed(client) self.search = (text, forward, literal) def incremental(self, words, forward): arg = words[0] if words else "" if self.inc_origin is None: self.inc_origin = (self.cx, self.abs_y()) mark = arg[:1] if mark == "=": origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1]) self.run_search(arg[1:], forward, origin=origin) elif mark in ("+", "-"): self.run_search(arg[1:], mark == "+") else: self.inc_origin = None if arg: self.search = (arg, forward, False) def c_search_forward_incremental(self, words, client): self.incremental(words, True) def c_search_backward_incremental(self, words, client): self.incremental(words, False) def search_cancel(self): if self.inc_origin is not None: self.goto(*self.inc_origin) self.inc_origin = None self.server.mark_dirty(self.pane.window) class MplexPrompt: """The command line in the status row: emacs-style editing, history, and the single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants.""" def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False, on_change=None, on_cancel=None, on_step=None, confirm=False): self.label = label self.text = text self.pos = len(text) self.on_done = on_done self.history = history if history is not None else [] self.hist_index = None self.single = single self.numeric = numeric self.key_name = key_name self.on_change = on_change self.on_cancel = on_cancel self.on_step = on_step self.confirm = confirm def view(self): return self.label + self.text, len(self.label) + self.pos def finish(self, server, client, value): client.prompt = None if value and not (self.single or self.numeric or self.key_name or self.confirm): self.history.append(value) del self.history[:-MPLEX_PROMPT_HISTORY_CAP] self.on_done(value) def cancel(self, client): client.prompt = None if self.on_cancel: self.on_cancel() def changed(self): if self.on_change: self.on_change(self.text) @staticmethod def printable(name, raw): if name == "Space": return " " if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable(): return name return "" def key(self, server, client, name, raw): char = self.printable(name, raw) if self.confirm: if char == "y": self.finish(server, client, "y") else: self.cancel(client) return if self.key_name: self.finish(server, client, name) return if self.single: if char: self.finish(server, client, char) elif name in ("Escape", "C-c", "C-g"): self.cancel(client) return if self.numeric: if char.isdigit(): self.text += char self.pos = len(self.text) return self.finish(server, client, self.text) client.redispatch(name, raw) return text, pos = self.text, self.pos if name in ("Enter", "C-j"): self.finish(server, client, text) return if name in ("Escape", "C-c", "C-g"): self.cancel(client) return if name in ("C-a", "Home"): pos = 0 elif name in ("C-e", "End"): pos = len(text) elif name in ("C-b", "Left"): pos = max(0, pos - 1) elif name in ("C-f", "Right"): pos = min(len(text), pos + 1) elif name == "M-b": while pos > 0 and text[pos - 1] == " ": pos -= 1 while pos > 0 and text[pos - 1] != " ": pos -= 1 elif name == "M-f": while pos < len(text) and text[pos] == " ": pos += 1 while pos < len(text) and text[pos] != " ": pos += 1 elif name in ("BSpace", "C-h"): if pos > 0: text = text[:pos - 1] + text[pos:] pos -= 1 elif name in ("DC", "C-d"): text = text[:pos] + text[pos + 1:] elif name == "C-k": text = text[:pos] elif name == "C-u": text = text[pos:] pos = 0 elif name == "C-w": start = pos while start > 0 and text[start - 1] == " ": start -= 1 while start > 0 and text[start - 1] != " ": start -= 1 text = text[:start] + text[pos:] pos = start elif name == "C-y": piece = server.top_buffer_text() text = text[:pos] + piece + text[pos:] pos += len(piece) elif name == "C-t": if 0 < pos < len(text): text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:] pos += 1 elif name in ("Up", "C-p", "Down", "C-n"): if self.history: if name in ("Up", "C-p"): self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1) elif self.hist_index is not None: self.hist_index += 1 if self.hist_index is not None and self.hist_index >= len(self.history): self.hist_index = None text = "" elif self.hist_index is not None: text = self.history[self.hist_index] pos = len(text) elif name in ("C-s", "C-r") and self.on_step: self.on_step(name, text) return elif char: text = text[:pos] + char + text[pos:] pos += len(char) else: return edited = text != self.text self.text, self.pos = text, pos if edited: self.changed() class MplexFont: GLYPHS = { "0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."), "2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"), "4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"), "6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"), "8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"), ":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"), "P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"), } HEIGHT = 5 @classmethod def width(cls, text): return len(text) * 6 - 1 if text else 0 @classmethod def draw(cls, text, bg): """Five rows of cells for text in big glyphs, lit pixels painted with background bg.""" rows = [] lit = (" ", None, bg, 0) dark = MplexCanvas.BLANK for line in range(cls.HEIGHT): row = [] for index, char in enumerate(text): glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"]) row.extend(lit if pixel == "#" else dark for pixel in glyph[line]) if index != len(text) - 1: row.append(dark) rows.append(row) return rows class MplexClockMode(MplexMode): name = "clock-mode" def render(self, client): opts = self.server.pane_options(self.pane) bg = MplexStyle.color(opts.get("clock-mode-colour"), True) now = time.localtime() text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now) grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)] if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text): big = MplexFont.draw(text, bg) top = (self.pane.h - MplexFont.HEIGHT) // 2 left = (self.pane.w - MplexFont.width(text)) // 2 for index, row in enumerate(big): grid[top + index][left:left + len(row)] = row else: top = self.pane.h // 2 left = max(0, (self.pane.w - len(text)) // 2) for index, char in enumerate(text[:self.pane.w]): grid[top][left + index] = (char, None, bg, 0) return grid, None def key(self, name, raw, client): self.close() return True class MplexPaneNumbers: """display-panes overlay: a big index on every pane until a key or the timer ends it.""" def __init__(self, server, window, until): self.server = server self.window = window self.until = until def key(self, name, raw, client): client.overlay = None if len(name) == 1 and name.isdigit(): base = self.server.pane_options(self.window.active).get("pane-base-index") panes = self.window.panes() index = int(name) - base if 0 <= index < len(panes): self.server.select_pane(self.window, panes[index]) self.server.mark_dirty(self.window) def draw(self, canvas, area_y): opts = self.window.options base = opts.get("pane-base-index") normal = MplexStyle.color(opts.get("display-panes-colour"), True) active = MplexStyle.color(opts.get("display-panes-active-colour"), True) for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()): bg = active if pane is self.window.active else normal text = str(index + base) if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text): big = MplexFont.draw(text, bg) top = pane.y + (pane.h - MplexFont.HEIGHT) // 2 left = pane.x + (pane.w - MplexFont.width(text)) // 2 for offset, row in enumerate(big): canvas.put_cells(left, area_y + top + offset, row) else: canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0)) size = "%dx%d" % (pane.w, pane.h) if pane.w > len(size) + 1: canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0)) class MplexChoice: __slots__ = ("label", "payload", "children", "expanded", "depth", "tag") def __init__(self, label, payload=None, children=None, expanded=False): self.label = label self.payload = payload self.children = children or [] self.expanded = expanded self.depth = 0 self.tag = False class MplexChooser(MplexMode): """choose-tree / choose-buffer / choose-client: a list you move through, with an optional live preview of the selected item below it.""" name = "tree-mode" HOT_KEYS = "0123456789" def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None): super().__init__(server, pane) self.name = kind self.builder = builder self.items = builder() self.on_close = on_close self.on_choose = on_choose self.on_delete = on_delete self.preview = preview self.show_preview = preview is not None self.cursor = 0 self.visible = [] self.reflatten() if selected is not None: for index, item in enumerate(self.visible): if item.payload == selected: self.cursor = index def reflatten(self): self.visible = [] def walk(nodes, depth): for node in nodes: node.depth = depth self.visible.append(node) if node.children and node.expanded: walk(node.children, depth + 1) walk(self.items, 0) self.cursor = max(0, min(len(self.visible) - 1, self.cursor)) def selected(self): return self.visible[self.cursor] if self.visible else None def rebuild(self): opened = set() def collect(nodes): for node in nodes: if node.expanded: opened.add(repr(node.payload)) collect(node.children) def restore(nodes): for node in nodes: node.expanded = node.expanded or repr(node.payload) in opened restore(node.children) collect(self.items) self.items = self.builder() restore(self.items) self.reflatten() if not self.visible: self.close() def close(self): super().close() if self.on_close is not None: callback, self.on_close = self.on_close, None callback() def list_height(self): if self.show_preview and self.pane.h >= 8: return max(3, self.pane.h // 2) return self.pane.h def render(self, client): w, h = self.pane.w, self.pane.h opts = self.server.pane_options(self.pane) mode_style = MplexStyle.parse(opts.get("mode-style")) grid = [[MplexCanvas.BLANK] * w for _row in range(h)] list_h = self.list_height() top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0 for row in range(list_h): index = top + row if index >= len(self.visible): break node = self.visible[index] hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " " marker = ("- " if node.expanded else "+ ") if node.children else " " line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w) style = mode_style if index == self.cursor else (None, None, 0) grid[row] = [(char, style[0], style[1], style[2]) for char in line] if self.show_preview and list_h < h: border = MplexCanvas.GLYPHS["single"][0] grid[list_h] = [(border, None, None, 0)] * w chosen = self.selected() if chosen is not None: block = self.preview(chosen, w, h - list_h - 1) or [] for offset, cells in enumerate(block[:h - list_h - 1]): grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w] return grid, None def move(self, delta): self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta)) def key(self, name, raw, client): node = self.selected() page = max(1, self.list_height() - 1) if name in ("q", "Escape", "C-c"): self.close() elif name in ("Up", "k", "C-p"): self.move(-1) elif name in ("Down", "j", "C-n"): self.move(1) elif name in ("PPage", "C-b"): self.move(-page) elif name in ("NPage", "C-f"): self.move(page) elif name in ("Home", "g"): self.cursor = 0 elif name in ("End", "G"): self.cursor = max(0, len(self.visible) - 1) elif name in ("Right", "l", "+") and node is not None and node.children: node.expanded = True self.reflatten() elif name in ("Left", "h", "-") and node is not None: if node.children and node.expanded: node.expanded = False else: for index in range(self.cursor - 1, -1, -1): if self.visible[index].depth < node.depth: self.cursor = index break self.reflatten() elif name == "t" and node is not None: node.tag = not node.tag elif name == "v" and self.preview is not None: self.show_preview = not self.show_preview elif name == "x" and node is not None and self.on_delete is not None: self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client)) elif name == "Enter" and node is not None: self.close() self.on_choose(node, client) elif len(name) == 1 and name in self.HOT_KEYS: index = self.HOT_KEYS.index(name) if index < len(self.visible): self.cursor = index self.close() self.on_choose(self.visible[index], client) self.server.mark_dirty(self.pane.window) return True def delete(self, node, client): self.on_delete(node, client) self.server.refresh_choosers() class MplexRenderer: """Composes one client's whole screen: panes, borders, status row, prompt, overlays.""" def __init__(self, server): self.server = server def block_of(self, pane, client): mode = pane.top_mode() if mode is not None: return mode.render(client) grid = pane.screen.grid blank = MplexCanvas.BLANK block = [[cell if cell is not None else blank for cell in row] for row in grid] cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None return block, cursor def frame(self, client): server = self.server canvas = MplexCanvas(client.rows, client.cols) session = client.session window = session.window() opts = window.options status_on = bool(opts.get("status")) on_top = opts.get("status-position") == "top" area_y = 1 if (status_on and on_top) else 0 cursor = None active = window.active for pane in window.visible_panes(): block, pane_cursor = self.block_of(pane, client) canvas.blit(pane.x, area_y + pane.y, block, pane.w) if pane is active and pane_cursor is not None: cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1]) if window.zoomed is None and len(window.panes()) > 1: self.borders(canvas, window, area_y) if isinstance(client.overlay, MplexPaneNumbers): client.overlay.draw(canvas, area_y) cursor = None status_row = 0 if on_top else client.rows - 1 if status_on: self.status(canvas, client, session, window, status_row) line_row = status_row if status_on else client.rows - 1 if client.prompt is not None: text, pos = client.prompt.view() style = MplexStyle.parse(opts.get("message-style")) canvas.fill(0, line_row, client.cols, 1, style) canvas.put(0, line_row, text, style, client.cols) cursor = (min(client.cols - 1, pos), line_row) elif client.message is not None and time.monotonic() < client.message[1]: style = MplexStyle.parse(opts.get("message-style")) canvas.fill(0, line_row, client.cols, 1, style) canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols) canvas.cursor = cursor return canvas def borders(self, canvas, window, area_y): W, H = window.width, window.height panes = window.panes() cells = set() for pane in panes: if pane.x + pane.w < W: for row in range(pane.y, min(H, pane.y + pane.h + 1)): cells.add((pane.x + pane.w, row)) if pane.y + pane.h < H: for col in range(pane.x, min(W, pane.x + pane.w + 1)): cells.add((col, pane.y + pane.h)) active = window.active hot = set() if active is not None: for col in range(active.x - 1, active.x + active.w + 1): hot.add((col, active.y - 1)) hot.add((col, active.y + active.h)) for row in range(active.y - 1, active.y + active.h + 1): hot.add((active.x - 1, row)) hot.add((active.x + active.w, row)) hot &= cells opts = window.options lines = opts.get("pane-border-lines") plain = MplexStyle.parse(opts.get("pane-border-style")) hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active)) hot_style = MplexStyle.parse(hot_text, plain) for (x, y) in cells: style = hot_style if (x, y) in hot else plain glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines) canvas.put(x, area_y + y, glyph, style) def status(self, canvas, client, session, window, row): server = self.server opts = window.options base = MplexStyle.parse(opts.get("status-style")) canvas.fill(0, row, client.cols, 1, base) resolver = server.resolver_for(session, window, window.active, client) left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)), MplexStyle.parse(opts.get("status-left-style"), base), base) right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)), MplexStyle.parse(opts.get("status-right-style"), base), base) left = left[:opts.get("status-left-length")] right = right[:opts.get("status-right-length")] if len(left) > client.cols: left = left[:client.cols] room_right = max(0, client.cols - len(left)) right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right] avail = client.cols - len(left) - len(right) entries = [] current_at = 0 total = 0 last_index = session.last separator = opts.get("window-status-separator") for index in session.indexes(): item = session.windows[index] wres = server.resolver_for(session, item, item.active, client) is_current = index == session.current fmt = item.options.get("window-status-current-format" if is_current else "window-status-format") style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style") style = MplexStyle.parse(item.options.get(style_name), base) cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style) if entries: cells = [(char, base[0], base[1], base[2]) for char in separator] + cells if is_current: current_at = total + (len(separator) if entries else 0) entries.append(cells) total += len(cells) listing = [cell for cells in entries for cell in cells] if len(listing) > avail: listing = self.fit_list(listing, avail, current_at, base) justify = opts.get("status-justify") offset = 0 if justify in ("centre", "center"): offset = max(0, (avail - len(listing)) // 2) elif justify == "right": offset = max(0, avail - len(listing)) canvas.put_cells(0, row, left) canvas.put_cells(len(left) + offset, row, listing) canvas.put_cells(client.cols - len(right), row, right) @staticmethod def fit_list(cells, avail, current_at, base): """The slice of the window list around the current window, with < and > markers.""" if avail <= 0: return [] start = max(0, min(current_at - avail // 3, len(cells) - avail)) piece = cells[start:start + avail] marker = lambda char: (char, base[0], base[1], base[2] | 1) if start > 0 and piece: piece[0] = marker("<") if start + avail < len(cells) and piece: piece[-1] = marker(">") return piece class MplexCtx: """What a command runs against: the issuing client and its current session, window, pane.""" def __init__(self, server, client=None, pane=None, interactive=False): self.server = server self.client = client self.interactive = interactive self.depth = 0 self.pane = pane self.window = pane.window if pane is not None else None self.session = self.window.session if self.window is not None else None if client is not None and client.session is not None and pane is None: self.session = client.session self.window = self.session.window() self.pane = self.window.active if self.window is not None else None elif self.session is None and client is not None and client.env_pane is not None: found = server.panes.get(client.env_pane) if found is not None and found.window is not None: self.pane = found self.window = found.window self.session = found.window.session if self.session is None: self.session = server.latest_session() if self.session is not None: self.window = self.session.window() self.pane = self.window.active if self.window is not None else None class MplexTargets: """tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session.""" @staticmethod def session(ctx, spec): server = ctx.server if not spec: if ctx.session is None: raise MplexError("no current session") return ctx.session found = None if spec.startswith("$") and spec[1:].isdigit(): found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None) else: spec = spec.lstrip("=") found = next((s for s in server.sessions.values() if s.name == spec), None) if found is None: matches = [s for s in server.sessions.values() if s.name.startswith(spec)] if len(matches) == 1: found = matches[0] if found is None: import fnmatch matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)] if len(matches) == 1: found = matches[0] if found is None: raise MplexError("can't find session: %s" % spec) return found @classmethod def window(cls, ctx, spec): """Returns (session, index, window).""" server = ctx.server spec = spec or "" if spec.startswith("@") and spec[1:].isdigit(): window = server.windows.get(int(spec[1:])) if window is None: raise MplexError("can't find window: %s" % spec) return window.session, window.session.index_of(window), window if ":" in spec: sess_part, _sep, win_part = spec.partition(":") session = cls.session(ctx, sess_part) else: sess_part, win_part, session = "", spec, ctx.session if session is None: raise MplexError("no current session") index = cls.window_index(session, win_part) if index is None and ":" not in spec and win_part: try: session = cls.session(ctx, win_part) index = session.current except MplexError: index = None if index is None or index not in session.windows: raise MplexError("can't find window: %s" % (win_part or spec)) return session, index, session.windows[index] @staticmethod def window_index(session, text): indexes = session.indexes() if not indexes: return None text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text if text == "": return session.current if text in ("!", "{last}"): return session.last if text in ("^", "{start}"): return indexes[0] if text in ("$", "{end}"): return indexes[-1] if text == "{next}": text = "+" if text == "{previous}": text = "-" if text[0] in "+-": step = int(text[1:]) if text[1:].isdigit() else 1 here = indexes.index(session.current) if session.current in indexes else 0 return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)] if text.isdigit(): return int(text) for index in indexes: if session.windows[index].name == text: return index matches = [index for index in indexes if session.windows[index].name.startswith(text)] if len(matches) == 1: return matches[0] import fnmatch matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)] return matches[0] if len(matches) == 1 else None @classmethod def pane(cls, ctx, spec): """Returns (session, window, pane).""" server = ctx.server spec = spec or "" if spec.startswith("%") and spec[1:].isdigit(): pane = server.panes.get(int(spec[1:])) if pane is None or pane.window is None: raise MplexError("can't find pane: %s" % spec) return pane.window.session, pane.window, pane if ":" in spec: sess_part, _sep, rest = spec.partition(":") if "." in rest: win_part, _dot, pane_part = rest.rpartition(".") else: win_part, pane_part = rest, "" ctx_for_window = sess_part + ":" + win_part session, _index, window = cls.window(ctx, ctx_for_window) elif "." in spec: win_part, _dot, pane_part = spec.rpartition(".") session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window) else: pane_part = spec session, window = ctx.session, ctx.window if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"): try: session, _index, window = cls.window(ctx, pane_part) pane_part = "" except MplexError: raise MplexError("can't find pane: %s" % spec) if window is None: raise MplexError("no current window") panes = window.panes() if not pane_part: return session, window, window.active base = window.options.get("pane-base-index") if pane_part.isdigit(): index = int(pane_part) - base if 0 <= index < len(panes): return session, window, panes[index] raise MplexError("can't find pane: %s" % pane_part) here = panes.index(window.active) if window.active in panes else 0 if pane_part in ("!", "{last}"): if window.last_pane in panes: return session, window, window.last_pane raise MplexError("can't find pane: %s" % pane_part) if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"): sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+" amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1 return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)] edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height, "{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width, "{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width, "{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0, "{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width} if pane_part in edges: for pane in panes: if edges[pane_part](pane): return session, window, pane raise MplexError("can't find pane: %s" % pane_part) class MplexClientConn: """Server-side state of one connection: framing, key table position, prompt, overlay, last frame.""" def __init__(self, server, sock, ident): self.server = server self.sock = sock self.id = ident self.inbuf = bytearray() self.out = bytearray() self.rows = 24 self.cols = 80 self.session = None self.attached = False self.table = "root" self.repeating = False self.repeat_until = 0.0 self.keybuf = b"" self.esc_deadline = None self.prompt = None self.message = None self.overlay = None self.prev = None self.cursor = None self.dirty = True self.last_render = 0.0 self.pid = 0 self.tty = "" self.term = "" self.cwd = os.getcwd() self.env = {} self.env_pane = None self.closing = False self.created = time.time() def send(self, kind, payload=b""): if len(self.out) > MPLEX_CLIENT_OUT_MAX: self.prev = None return False self.out += kind + struct.pack(">I", len(payload)) + payload return True def frames(self, data): """Complete (kind, payload) frames in what arrived; None when the stream is corrupt.""" self.inbuf += data found = [] while len(self.inbuf) >= 5: length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0] if length > MPLEX_FRAME_MAX: return None if len(self.inbuf) < 5 + length: break found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length]))) del self.inbuf[:5 + length] return found def redispatch(self, name, raw): self.server.handle_key(self, name, raw) def flush(self): if not self.out: return try: sent = self.sock.send(bytes(self.out[:65536])) del self.out[:sent] except (BlockingIOError, InterruptedError): pass except OSError: self.out.clear() self.closing = True def close(self): try: self.sock.close() except OSError: pass class MplexServer: """Owns every session, window, pane and client. One select loop, no threads.""" def __init__(self, home): self.home = home self.dir = plexer_dir(home) self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME) self.config = MplexConfig.load(MplexConfig.default_path(home)) self.options = self.config.options self.sessions = {} self.windows = {} self.panes = {} self.clients = [] self.conns = [] self.buffers = [] self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX) self.prompt_history = {} self.timers = [] self.reaping = [] self.marked = None self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0} self.had_session = False self.running = False self.listener = None self.renderer = MplexRenderer(self) self.commands = MplexCommands(self) self.shell_cache = {} self.started = time.monotonic() self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") self.next_tick = 0.0 self.notice = None for problem in self.config.errors: self.log_message("mplex.toml: " + problem) if self.config.skipped: self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped # ---- bookkeeping ----------------------------------------------------- def next_id(self, kind): value = self.ids[kind] self.ids[kind] += 1 return value def log_message(self, text): self.messages.append((time.time(), text)) def latest_session(self): if not self.sessions: return None return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id)) def attached_clients(self, session=None): return [c for c in self.clients if c.attached and (session is None or c.session is session)] def pane_options(self, pane): return pane.window.options if pane.window is not None else self.options def message(self, client, text, milliseconds=None): self.log_message(text) if client is None: return if milliseconds is None: milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time") client.message = (text, time.monotonic() + milliseconds / 1000.0) client.dirty = True self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True)) def mark_dirty(self, window=None): for client in self.clients: if client.attached and (window is None or client.session.window() is window): client.dirty = True def mark_all_dirty(self): for client in self.clients: client.dirty = True def add_timer(self, deadline, callback): if len(self.timers) < 1024: self.timers.append((deadline, callback)) # ---- buffers and copy ------------------------------------------------ def add_buffer(self, text, name=None): if name is None: name = "buffer%d" % self.next_id("buffer") self.buffers = [item for item in self.buffers if item["name"] != name] self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]}) del self.buffers[self.options.get("buffer-limit"):] return name def top_buffer_text(self): return self.buffers[0]["data"] if self.buffers else "" def find_buffer(self, name): if not name: if not self.buffers: raise MplexError("no buffers") return self.buffers[0] for item in self.buffers: if item["name"] == name: return item raise MplexError("unknown buffer: %s" % name) def run_shell_input(self, command, text): try: done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT) if done.returncode != 0: raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200]) except (OSError, subprocess.SubprocessError) as exc: raise MplexError("copy command failed: " + short_error(exc)) def store_copy(self, text, command="", append=False): if append and self.buffers: self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX] text = self.buffers[0]["data"] else: self.add_buffer(text) pipe = command or self.options.get("copy-command") if pipe: self.run_shell_input(pipe, text) if self.options.get("set-clipboard") != "off": sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07" for client in self.attached_clients(): client.send(b"O", sequence.encode("ascii")) # ---- sizing ---------------------------------------------------------- def resize_session(self, session): sizes = [(c.cols, c.rows) for c in self.attached_clients(session)] if sizes: session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes)) elif not hasattr(session, "size"): session.size = (80, 24) cols, rows = session.size for window in session.windows.values(): window.width = max(1, cols) window.height = max(1, rows - (1 if window.options.get("status") else 0)) window.relayout() self.mark_dirty() def resize_all(self): for session in self.sessions.values(): self.resize_session(session) # ---- model: panes, windows, sessions --------------------------------- def default_shell(self, options): return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh" def resolve_term(self, name): wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"] roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"] for term in wanted: for root in roots: if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))): return term return "xterm" def spawn_pane(self, window, width, height, words, cwd, env_extra=None): if len(self.panes) >= MPLEX_MAX_PANES: raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES) session = window.session options = window.options pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp) shell = self.default_shell(options) if words: file = shell if len(words) == 1 else words[0] argv = [shell, "-c", words[0]] if len(words) == 1 else list(words) elif options.get("default-command"): file, argv = shell, [shell, "-c", options.get("default-command")] else: file, argv = shell, ["-" + os.path.basename(shell)] env = dict(session.env) env.pop("LINES", None) env.pop("COLUMNS", None) env["TERM"] = self.resolve_term(options.get("default-terminal")) env[PLEXER_ACTIVE_ENV] = "1" env[PLEXER_SOCKET_ENV] = self.sock_path env[PLEXER_PANE_ENV] = str(pane.id) env.update(env_extra or {}) pane.window = window pane.spawn(file, argv, cwd or session.cwd, env) self.panes[pane.id] = pane return pane def register_window(self, session, window, index, select): window.session = session window.options.parent = session.options session.windows[index] = window self.windows[window.id] = window if select or session.current is None: session.select(index) def create_window(self, session, name, cwd, words, env_extra, index, select): if len(self.windows) >= MPLEX_MAX_WINDOWS: raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS) cols, rows = getattr(session, "size", (80, 24)) window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options) window.session = session pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra) window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height) window.active = pane pane.last_active = time.monotonic() window.auto_rename = not name if not name: window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0]) self.register_window(session, window, index, select) window.relayout() self.mark_dirty() return window def create_session(self, name, cwd, env, words, window_name, size, index_hint=None): if len(self.sessions) >= MPLEX_MAX_SESSIONS: raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS) session = MplexSession(self.next_id("session"), name, self.options, cwd, env) session.size = size self.sessions[session.id] = session self.had_session = True try: self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True) except Exception: self.sessions.pop(session.id, None) raise return session def auto_session_name(self): taken = {s.name for s in self.sessions.values()} number = 0 while str(number) in taken: number += 1 return str(number) def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select): leaf = window.leaf_of(target) if leaf is None: raise MplexError("pane is not in this window") if window.zoomed is not None: window.zoomed = None window.relayout() leaf = window.leaf_of(target) span = leaf.w if kind == "lr" else leaf.h if span < 3: raise MplexError("no space for new pane") pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra) try: window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size) except MplexError: self.discard_pane(pane) raise window.layout_index = -1 window.relayout() if select: window.set_active(pane) self.mark_dirty(window) return pane def discard_pane(self, pane): pane.close() self.panes.pop(pane.id, None) if pane.pid is not None: self.reaping.append((pane.pid, time.monotonic())) pane.pid = None def select_pane(self, window, pane): if window.zoomed is not None and pane is not window.zoomed: window.zoomed = None window.relayout() window.set_active(pane) self.mark_dirty(window) def kill_pane(self, pane): window = pane.window if window is None: return if len(window.panes()) == 1: self.kill_window(window) return was_active = window.active is pane window.root = MplexLayout.remove(window.root, window.leaf_of(pane)) if window.zoomed is pane: window.zoomed = None if self.marked is pane: self.marked = None self.discard_pane(pane) rest = window.panes() if window.last_pane is pane: window.last_pane = None if was_active: window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active) window.last_pane = None window.layout_index = -1 window.relayout() self.mark_dirty(window) def kill_window(self, window): session = window.session index = session.index_of(window) for pane in window.panes(): if self.marked is pane: self.marked = None self.discard_pane(pane) self.windows.pop(window.id, None) if index is not None: session.windows.pop(index, None) if not session.windows: self.kill_session(session) return session.history = [item for item in session.history if item is not window] if session.current == index: nxt = session.last if nxt is None: lower = [i for i in session.indexes() if i < index] nxt = lower[-1] if lower else session.indexes()[-1] session.current = nxt session.history = [item for item in session.history if item is not session.windows[nxt]] if session.options.get("renumber-windows"): self.renumber(session) self.mark_dirty() def renumber(self, session): base = self.options.get("base-index") current = session.window() ordered = [session.windows[i] for i in session.indexes()] session.windows = {base + n: window for n, window in enumerate(ordered)} session.current = session.index_of(current) if current else None def kill_session(self, session): for window in list(session.windows.values()): for pane in window.panes(): if self.marked is pane: self.marked = None self.discard_pane(pane) self.windows.pop(window.id, None) session.windows.clear() self.sessions.pop(session.id, None) for client in list(self.attached_clients(session)): others = [s for s in self.sessions.values()] if others and not self.options.get("detach-on-destroy"): self.attach_client(client, self.latest_session()) else: self.detach_client(client, "[exited]", b"X") self.mark_dirty() def attach_client(self, client, session): old = client.session client.session = session session.last_attached = time.time() client.table = "root" client.prompt = None client.overlay = None client.prev = None if not client.attached: client.attached = True client.send(b"A") if old is not None and old is not session: self.resize_session(old) self.resize_session(session) client.dirty = True if self.notice: self.message(client, self.notice, 4000) self.notice = None def detach_client(self, client, text="", kind=b"D"): old = client.session client.send(kind, text.encode("utf-8")) client.attached = False client.session = None client.closing = True if old is not None and old.id in self.sessions: self.resize_session(old) # ---- formats --------------------------------------------------------- def window_flags(self, session, window): flags = "" index = session.index_of(window) if index == session.current: flags += "*" elif index == session.last: flags += "-" if window.zoomed is not None: flags += "Z" if self.marked is not None and self.marked.window is window: flags += "M" return flags def resolver_for(self, session, window, pane, client=None): server = self def get(name): if name.startswith("("): return server.shell_output(name[1:]) return server.variable(name, session, window, pane, client) return get def shell_output(self, command): now = time.monotonic() cached = self.shell_cache.get(command) interval = max(1, self.options.get("status-interval")) if cached and now - cached[0] < interval: return cached[1] try: done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT) text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0] except (OSError, subprocess.SubprocessError): text = "" if len(self.shell_cache) > 64: self.shell_cache.clear() self.shell_cache[command] = (now, text) return text def variable(self, name, session, window, pane, client): if name in MplexOptions.DEFS: scope = window.options if window is not None else (session.options if session is not None else self.options) value = scope.get(name) if isinstance(value, bool): return "1" if value else "0" return str(value) if name == "host": return socket.gethostname() if name == "host_short": return socket.gethostname().split(".")[0] if name == "version": return VERSION if session is not None: if name == "session_name": return session.name if name == "session_id": return session.tag if name == "session_windows": return str(len(session.windows)) if name == "session_created": return str(int(session.created)) if name == "session_attached": return str(len(self.attached_clients(session))) if window is not None and session is not None: index = session.index_of(window) if name == "window_index": return str(index) if name == "window_name": return window.name if name == "window_id": return window.tag if name == "window_flags": return self.window_flags(session, window) if name == "window_active": return "1" if index == session.current else "0" if name == "window_last_flag": return "1" if index == session.last else "0" if name == "window_panes": return str(len(window.panes())) if name == "window_zoomed_flag": return "1" if window.zoomed is not None else "0" if name == "window_width": return str(window.width) if name == "window_height": return str(window.height) if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"): return "0" if name == "window_marked_flag": return "1" if self.marked is not None and self.marked.window is window else "0" if pane is not None: mode = pane.top_mode() screen = pane.screen if name == "pane_id": return pane.tag if name == "pane_index": return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0" if name == "pane_title": return pane.title if name == "pane_current_command": return pane.foreground_command() if name == "pane_current_path": return pane.current_path() if name == "pane_pid": return str(pane.pid or "") if name == "pane_width": return str(pane.w) if name == "pane_height": return str(pane.h) if name == "pane_active": return "1" if pane.window is not None and pane.window.active is pane else "0" if name == "pane_last": return "1" if pane.window is not None and pane.window.last_pane is pane else "0" if name == "pane_in_mode": return "1" if mode is not None else "0" if name == "pane_mode": return mode.name if mode is not None else "" if name == "pane_dead": return "1" if pane.dead else "0" if name == "pane_dead_status": return "" if pane.exit_status is None else str(pane.exit_status) if name == "pane_left": return str(pane.x) if name == "pane_top": return str(pane.y) if name == "pane_right": return str(pane.x + pane.w - 1) if name == "pane_bottom": return str(pane.y + pane.h - 1) if name == "pane_synchronized": return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0" if name == "pane_marked": return "1" if self.marked is pane else "0" if name == "pane_marked_set": return "1" if self.marked is not None else "0" if name == "alternate_on": return "1" if screen.alt_active else "0" if name == "history_size": return str(pane.history_size()) if name == "history_limit": return str(screen.history.maxlen if screen.history is not None else 0) if name == "cursor_x": return str(screen.cx) if name == "cursor_y": return str(screen.cy) if name == "pane_search_string": return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else "" if isinstance(mode, MplexCopyMode): if name == "scroll_position": return str(mode.oy) if name == "copy_cursor_x": return str(mode.cx) if name == "copy_cursor_y": return str(mode.cy) if name == "selection_present": return "1" if mode.anchor is not None else "0" if name == "copy_cursor_line": return mode.text(mode.abs_y()) if name == "copy_cursor_word": line = mode.text(mode.abs_y()) if not line: return "" x = min(mode.cx, len(line) - 1) seps = pane.window.options.get("word-separators") + " " start = end = x while start > 0 and line[start - 1] not in seps: start -= 1 while end + 1 < len(line) and line[end + 1] not in seps: end += 1 return line[start:end + 1] if name == "scroll_position": return "" if client is not None: if name == "client_width": return str(client.cols) if name == "client_height": return str(client.rows) if name == "client_session": return client.session.name if client.session else "" if name == "client_tty": return client.tty if name == "client_termname": return client.term if name == "client_pid": return str(client.pid) if name == "client_key_table": return client.table if name == "client_prefix": return "1" if client.table == "prefix" else "0" if name == "client_created": return str(int(client.created)) return None def expand(self, text, ctx, extra=None): """Format-expand text for a command context (strftime applied).""" base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client) if extra: def resolve(name): return extra[name] if name in extra else base(name) else: resolve = base return MplexFormat.with_time(MplexFormat.expand(text, resolve)) # ---- key handling ---------------------------------------------------- def is_prefix(self, session, name): opts = session.options return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None" def input_bytes(self, client, data): client.keybuf = (client.keybuf + data)[-65536:] self.process_keys(client, False) def process_keys(self, client, final): tokens, rest = MplexKeys.split(client.keybuf, final) client.keybuf = rest for name, raw in tokens: if not client.attached: break self.handle_key(client, name, raw) if client.keybuf and client.attached: client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0 else: client.esc_deadline = None def handle_key(self, client, name, raw): if not client.attached: return client.dirty = True if client.prompt is not None: client.prompt.key(self, client, name, raw) return if client.overlay is not None: client.overlay.key(name, raw, client) return client.message = None session = client.session window = session.window() pane = window.active if not name: self.forward(pane, window, raw) return table = client.table if table == "root": binding = self.config.lookup("root", name) if binding is not None: self.run_binding(client, binding, pane) return if self.is_prefix(session, name): client.table = "prefix" client.repeating = False return mode = pane.top_mode() if mode is not None: mode.key(name, raw, client) return self.forward(pane, window, raw) return binding = self.config.lookup(table, name) if client.repeating and not (binding is not None and binding.repeat): client.table = "root" client.repeating = False self.handle_key(client, name, raw) return client.table = "root" client.repeating = False if binding is not None: self.run_binding(client, binding, pane) if binding.repeat and client.attached and client.table == "root": client.table = table client.repeating = True client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0 def forward(self, pane, window, raw): data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw targets = window.panes() if window.options.get("synchronize-panes") else [pane] for target in targets: if not target.dead: target.write(data) target.input_bytes += len(data) def run_binding(self, client, binding, pane=None): self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command) def run_checked(self, ctx, text): try: output = self.run_line(ctx, text) if output: self.show_output(ctx, output) except MplexError as exc: self.message(ctx.client, str(exc)) except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down import traceback sys.stderr.write(traceback.format_exc()) self.message(ctx.client, "internal error: " + short_error(exc)) def show_output(self, ctx, text): pane = ctx.pane lines = text.rstrip("\n").split("\n") if pane is None or (len(lines) == 1 and len(lines[0]) < 200): self.message(ctx.client, lines[0] if lines else "") return old = pane.find_mode(MplexCopyMode) if old is not None and old.static_rows is not None: old.close() pane.push_mode(MplexCopyMode(self, pane, lines=lines)) self.mark_dirty(pane.window) def ask_confirm(self, client, text, callback): def done(value): callback() client.prompt = MplexPrompt(text + " ", "", done, confirm=True) client.dirty = True def refresh_choosers(self): for pane in list(self.panes.values()): for mode in list(pane.modes): if isinstance(mode, MplexChooser): mode.rebuild() self.mark_dirty() # ---- command execution ----------------------------------------------- def run_line(self, ctx, text, depth=0): return self.run_commands(ctx, MplexCommandParser.parse(text), depth) def run_commands(self, ctx, commands, depth=0): if depth > MPLEX_COMMAND_DEPTH_MAX: raise MplexError("commands nested too deeply") flat = [] for words in commands: piece = [] for word in words: if word == ";": if piece: flat.append(piece) piece = [] else: piece.append(word) if piece: flat.append(piece) output = [] for words in flat: text = self.run_one(ctx, words, depth) if text: output.append(text) return "\n".join(output) def run_words(self, ctx, words): return self.run_commands(ctx, [words], 0) def run_one(self, ctx, words, depth): name = MplexCommands.resolve(words[0]) aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] args = MplexArgs.parse(name, spec, words[1:], low, high) ctx.depth = depth handler = getattr(self.commands, "cmd_" + name.replace("-", "_")) return handler(ctx, args) or "" # ---- sockets --------------------------------------------------------- def open_listener(self): lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600) try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: os.close(lock_fd) return None self.lock_fd = lock_fd try: os.unlink(self.sock_path) except OSError: pass listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener.bind(self.sock_path) os.chmod(self.sock_path, 0o600) listener.listen(16) listener.setblocking(False) return listener def accept(self): try: sock, _addr = self.listener.accept() except OSError: return sock.setblocking(False) if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2: sock.close() return self.conns.append(MplexPending(sock)) def pending_read(self, pending): try: data = pending.sock.recv(65536) except (BlockingIOError, InterruptedError): return except OSError: data = b"" if not data: self.drop_pending(pending) return pending.buf += data if pending.buf[:1] == MPLEX_MAGIC: if len(self.clients) >= MPLEX_MAX_CLIENTS: self.drop_pending(pending) return self.conns.remove(pending) client = MplexClientConn(self, pending.sock, self.next_id("client")) self.clients.append(client) self.client_data(client, bytes(pending.buf[1:])) return if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX: self.control(pending) def drop_pending(self, pending): if pending in self.conns: self.conns.remove(pending) try: pending.sock.close() except OSError: pass def control(self, pending): """Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController).""" try: request = json.loads(bytes(pending.buf).decode("utf-8", "replace")) if not isinstance(request, dict): raise ValueError("request must be a JSON object") reply = self.dispatch_control(request) except Exception as exc: # noqa: BLE001 -- one bad request must not end the session reply = {"ok": False, "error": short_error(exc)} try: pending.sock.setblocking(True) pending.sock.settimeout(2) pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8")) except OSError: pass self.drop_pending(pending) def control_pane(self, request): ident = request.get("pane") if ident not in (None, ""): pane = self.panes.get(int(str(ident).lstrip("%"))) if pane is None: raise ValueError("no such pane: %s" % ident) return pane session = self.latest_session() if session is None or session.window() is None: raise ValueError("no session") return session.window().active def dispatch_control(self, request): cmd = request.get("cmd") if cmd == "send": pane = self.control_pane(request) text = str(request.get("text") or "") pane.write(text.encode("utf-8", "replace")) if request.get("enter"): pane.write(b"\r") return {"ok": True, "data": True} if cmd == "read": pane = self.control_pane(request) return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))} if cmd == "status": pane = self.control_pane(request) window = pane.window return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path, "pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}} return {"ok": False, "error": "unknown command %r" % cmd} def client_read(self, client): try: data = client.sock.recv(65536) except (BlockingIOError, InterruptedError): return except OSError: data = b"" if not data: self.drop_client(client) return self.client_data(client, data) def client_data(self, client, data): frames = client.frames(data) if frames is None: self.drop_client(client) return for kind, payload in frames: if kind == b"H": self.on_hello(client, payload) elif kind == b"I" and client.attached: self.input_bytes(client, payload) elif kind == b"R": try: size = json.loads(payload.decode("utf-8", "replace")) client.rows = max(2, min(1000, int(size["rows"]))) client.cols = max(2, min(2000, int(size["cols"]))) except (ValueError, KeyError, TypeError): continue client.prev = None if client.attached: self.resize_session(client.session) client.dirty = True def on_hello(self, client, payload): try: info = json.loads(payload.decode("utf-8", "replace")) if not isinstance(info, dict): raise ValueError("hello must be an object") client.rows = max(2, min(1000, int(info.get("rows") or 24))) client.cols = max(2, min(2000, int(info.get("cols") or 80))) client.term = str(info.get("term") or "")[:64] client.tty = str(info.get("tty") or "")[:128] client.cwd = str(info.get("cwd") or "/")[:4096] client.pid = int(info.get("pid") or 0) env = info.get("env") or {} client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {} pane = info.get("pane") client.env_pane = int(pane) if pane not in (None, "") else None commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item] except (ValueError, TypeError) as exc: client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8")) client.closing = True return ctx = MplexCtx(self, client=client) try: output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text) except MplexError as exc: client.send(b"E", str(exc).encode("utf-8")) client.closing = True return except Exception as exc: # noqa: BLE001 import traceback sys.stderr.write(traceback.format_exc()) client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8")) client.closing = True return if not client.attached: if output: client.send(b"M", output.encode("utf-8")) client.send(b"X", b"") client.closing = True def drop_client(self, client): if client in self.clients: self.clients.remove(client) session = client.session client.attached = False client.session = None client.close() if session is not None and session.id in self.sessions: self.resize_session(session) # ---- main loop ------------------------------------------------------- def serve(self): self.listener = self.open_listener() if self.listener is None: return 1 signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False)) signal.signal(signal.SIGHUP, signal.SIG_IGN) signal.signal(signal.SIGINT, signal.SIG_IGN) self.running = True try: self.loop() finally: self.shutdown() return 0 def stop(self): self.running = False def loop(self): """Run until stopped. One failing iteration is logged and survived, because it would otherwise end every pane; MPLEX_LOOP_ERRORS_MAX failures in a row end the server.""" failures = 0 while self.running: try: self.iterate() failures = 0 except Exception: # noqa: BLE001 -- see docstring import traceback sys.stderr.write(traceback.format_exc()) sys.stderr.flush() failures += 1 if failures >= MPLEX_LOOP_ERRORS_MAX: sys.stderr.write("mplex server: %d failed iterations in a row, exiting\n" % failures) self.running = False time.sleep(0.05) def iterate(self): now = time.monotonic() self.run_deadlines(now) fd_map = {self.listener.fileno(): ("listen", None)} for pending in self.conns: fd_map[pending.sock.fileno()] = ("pending", pending) for client in self.clients: fd_map[client.sock.fileno()] = ("client", client) for pane in self.panes.values(): if pane.master_fd is not None: fd_map[pane.master_fd] = ("pane", pane) writers = {} for client in self.clients: if client.out: writers[client.sock.fileno()] = client for pane in self.panes.values(): if pane.pending_in and pane.master_fd is not None: writers[pane.master_fd] = pane try: ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now)) except InterruptedError: return except (OSError, ValueError): self.sweep_bad_fds() return for fd in ready: kind, obj = fd_map[fd] if kind == "listen": self.accept() elif kind == "pending": self.pending_read(obj) elif kind == "client": self.client_read(obj) elif kind == "pane": self.pane_read(obj) for fd in can_write: target = writers[fd] if isinstance(target, MplexPane): target.drain() else: target.flush() now = time.monotonic() self.reap_children(now) self.render_clients(now) self.close_finished() if not self.sessions and (self.had_session or now - self.started > MPLEX_IDLE_EXIT) and not self.clients_busy(): self.running = False def clients_busy(self): return any(client.out for client in self.clients) def sweep_bad_fds(self): for pending in list(self.conns): if pending.sock.fileno() < 0: self.conns.remove(pending) for client in list(self.clients): if client.sock.fileno() < 0: self.drop_client(client) for pane in list(self.panes.values()): if pane.master_fd is not None: try: os.fstat(pane.master_fd) except OSError: self.pane_exited(pane) def wakeup(self, now): due = [now + 1.0] due.extend(deadline for deadline, _cb in self.timers) for client in self.clients: if client.esc_deadline: due.append(client.esc_deadline) if client.repeating: due.append(client.repeat_until) if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4: due.append(client.last_render + MPLEX_RENDER_INTERVAL) if client.overlay is not None and hasattr(client.overlay, "until"): due.append(client.overlay.until) return max(0.0, min(due) - now) def run_deadlines(self, now): if self.timers: ripe = [item for item in self.timers if item[0] <= now] self.timers = [item for item in self.timers if item[0] > now] for _deadline, callback in ripe: callback() for client in list(self.clients): if client.esc_deadline and now >= client.esc_deadline and client.attached: client.esc_deadline = None self.process_keys(client, True) if client.repeating and now >= client.repeat_until: client.repeating = False client.table = "root" client.dirty = True overlay = client.overlay if overlay is not None and hasattr(overlay, "until") and now >= overlay.until: client.overlay = None client.dirty = True if now >= self.next_tick: self.next_tick = now + 1.0 self.tick(now) for pending in list(self.conns): if now - pending.created > 5.0: self.drop_pending(pending) def tick(self, now): for window in list(self.windows.values()): if window.auto_rename and window.active is not None and window.session is not None: template = window.options.get("automatic-rename-format") if window.options.get("automatic-rename"): resolver = self.resolver_for(window.session, window, window.active) name = MplexFormat.expand(template, resolver) if name and name != window.name: window.name = name[:64] self.mark_all_dirty() for pane in window.panes(): if isinstance(pane.top_mode(), MplexClockMode): self.mark_dirty(window) interval = self.options.get("status-interval") if interval and int(now) % interval == 0: self.mark_all_dirty() def pane_read(self, pane): try: data = os.read(pane.master_fd, 65536) except (BlockingIOError, InterruptedError): return except OSError: data = b"" if not data: self.pane_exited(pane) return pane.feed(data) self.mark_dirty(pane.window) def pane_exited(self, pane): status = None if pane.pid is not None: try: done, raw = os.waitpid(pane.pid, os.WNOHANG) if done: status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw pane.pid = None except OSError: pane.pid = None if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead: pane.dead = True pane.exit_status = status pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8")) if pane.master_fd is not None: try: os.close(pane.master_fd) except OSError: pass pane.master_fd = None self.mark_dirty(pane.window) return if pane.window is not None and pane.id in self.panes: self.kill_pane(pane) def reap_children(self, now): keep = [] for pid, since in self.reaping: try: done, _status = os.waitpid(pid, os.WNOHANG) except OSError: continue if done: continue if now - since > MPLEX_REAP_KILL_AFTER: try: os.killpg(pid, signal.SIGKILL) except OSError: try: os.kill(pid, signal.SIGKILL) except OSError: pass keep.append((pid, since)) self.reaping = keep def render_clients(self, now): for client in self.clients: if not client.attached or not client.dirty: continue if now - client.last_render < MPLEX_RENDER_INTERVAL: continue if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4: continue canvas = self.renderer.frame(client) data, snapshot = canvas.encode(client.prev) client.last_render = now client.dirty = False if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor: continue client.prev = snapshot client.cursor = canvas.cursor client.send(b"O", data) def close_finished(self): for client in list(self.clients): if client.closing and not client.out: self.drop_client(client) def shutdown(self): for client in list(self.clients): if client.attached: client.send(b"X", b"[server exited]") for _try in range(20): if not client.out: break client.flush() if client.out: time.sleep(0.005) client.close() self.clients = [] for pane in list(self.panes.values()): pane.close(hard=True) if pane.pid is not None: try: os.waitpid(pane.pid, os.WNOHANG) except OSError: pass for item in (self.listener,): try: item.close() except (OSError, AttributeError): pass try: os.unlink(self.sock_path) except OSError: pass @staticmethod def probe(path): probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) probe.settimeout(1) try: probe.connect(path) return True except OSError: return False finally: probe.close() @classmethod def ensure_running(cls, home, wait=5.0): """Start the server as a detached daemon unless one already answers on its socket.""" path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) if cls.probe(path): return True pid = os.fork() if pid == 0: code = 1 try: os.setsid() if os.fork() > 0: os._exit(0) devnull = os.open(os.devnull, os.O_RDWR) log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) os.dup2(devnull, 0) os.dup2(log, 1) os.dup2(log, 2) os.chdir("/") code = cls(home).serve() except BaseException: # noqa: BLE001 import traceback traceback.print_exc() os._exit(code) os.waitpid(pid, 0) deadline = time.monotonic() + wait while time.monotonic() < deadline: if cls.probe(path): return True time.sleep(0.05) return False class MplexPending: """A connection that has not yet said whether it speaks frames or one JSON line.""" def __init__(self, sock): self.sock = sock self.buf = bytearray() self.created = time.monotonic() # name: (aliases, getopt spec, min positional, max positional) MPLEX_COMMAND_SPECS = { "attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0), "bind-key": (("bind",), "nrT:N:", 1, None), "break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0), "capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0), "choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1), "choose-client": ((), "NrZF:f:K:O:t:", 0, 1), "choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1), "clear-history": (("clearhist",), "Ht:", 0, 0), "clock-mode": ((), "t:", 0, 0), "command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1), "confirm-before": (("confirm",), "byc:p:t:", 1, 1), "copy-mode": ((), "deHMqSus:t:", 0, 0), "delete-buffer": (("deleteb",), "b:", 0, 0), "detach-client": (("detach",), "aPE:s:t:", 0, 0), "display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1), "display-panes": (("displayp",), "bNd:t:", 0, 1), "find-window": (("findw",), "CiNrTZt:", 1, 1), "has-session": (("has",), "t:", 0, 0), "if-shell": (("if",), "bFt:", 2, 3), "kill-pane": (("killp",), "at:", 0, 0), "kill-server": ((), "", 0, 0), "kill-session": ((), "aCt:", 0, 0), "kill-window": (("killw",), "at:", 0, 0), "last-pane": (("lastp",), "deZt:", 0, 0), "last-window": (("last",), "t:", 0, 0), "list-buffers": (("lsb",), "F:f:", 0, 0), "list-clients": (("lsc",), "F:f:t:", 0, 0), "list-commands": (("lscm",), "F:", 0, 1), "list-keys": (("lsk",), "1aNP:T:", 0, 1), "list-panes": (("lsp",), "asF:f:t:", 0, 0), "list-sessions": (("ls",), "F:f:", 0, 0), "list-windows": (("lsw",), "aF:f:t:", 0, 0), "load-buffer": (("loadb",), "b:t:", 1, 1), "move-window": (("movew",), "abdkrs:t:", 0, 0), "new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None), "new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None), "next-layout": (("nextl",), "t:", 0, 0), "next-window": (("next",), "at:", 0, 0), "paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0), "previous-layout": (("prevl",), "t:", 0, 0), "previous-window": (("prev",), "at:", 0, 0), "refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1), "rename-session": (("rename",), "t:", 1, 1), "rename-window": (("renamew",), "t:", 1, 1), "resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1), "rotate-window": (("rotatew",), "DUZt:", 0, 0), "run-shell": (("run",), "bCEc:d:t:", 0, 1), "save-buffer": (("saveb",), "ab:", 1, 1), "select-layout": (("selectl",), "Enopt:", 0, 1), "select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0), "select-window": (("selectw",), "lnpTt:", 0, 0), "send-keys": (("send",), "FHKlMRXc:N:t:", 0, None), "send-prefix": ((), "2t:", 0, 0), "set-buffer": (("setb",), "awb:n:t:", 0, 1), "set-option": (("set",), "aFgopqsuUwt:", 1, 2), "set-window-option": (("setw",), "aFgoqut:", 1, 2), "show-buffer": (("showb",), "b:", 0, 0), "show-messages": (("showmsgs",), "JTt:", 0, 0), "show-options": (("show",), "AgHpqsvwt:", 0, 1), "show-window-options": (("showw",), "gvt:", 0, 1), "source-file": (("source",), "Fnqvt:", 1, None), "split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None), "start-server": (("start",), "", 0, 0), "suspend-client": (("suspendc",), "t:", 0, 0), "swap-pane": (("swapp",), "dDUZs:t:", 0, 0), "swap-window": (("swapw",), "ds:t:", 0, 0), "switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0), "unbind-key": (("unbind",), "anqT:", 0, 1), } MPLEX_DEFAULT_FORMATS = { "list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}", "list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}", "list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}", "list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]", "list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"", "display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)", } class MplexCommands: """tmux's command set as methods named cmd_(ctx, args).""" def __init__(self, server): self.s = server @staticmethod def resolve(word): if word in MPLEX_COMMAND_SPECS: return word for name, spec in MPLEX_COMMAND_SPECS.items(): if word in spec[0]: return name if word in MPLEX_UNSUPPORTED: raise MplexError("command not supported by mplex: %s" % word) matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)] if len(matches) == 1: return matches[0] if len(matches) > 1: raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5]))) raise MplexError("unknown command: %s" % word) # ---- helpers --------------------------------------------------------- def need_client(self, ctx): if ctx.client is None or not ctx.client.attached: raise MplexError("no attached client") return ctx.client def pane_target(self, ctx, args, flag="t"): return MplexTargets.pane(ctx, args.get(flag)) def window_target(self, ctx, args, flag="t"): return MplexTargets.window(ctx, args.get(flag)) def session_target(self, ctx, args, flag="t"): return MplexTargets.session(ctx, args.get(flag)) def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None): base = self.s.resolver_for(session, window, pane, client) if extra: def resolve(name): return extra[name] if name in extra else base(name) else: resolve = base return MplexFormat.with_time(MplexFormat.expand(text, resolve)) def listing(self, command, args, rows): template = args.get("F") or MPLEX_DEFAULT_FORMATS[command] flt = args.get("f") lines = [] for session, window, pane, client, extra in rows: if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)): continue lines.append(self.fmt(template, session, window, pane, client, extra)) return "\n".join(lines) def substitute(self, template, answers): out = [] in_quote = False pos = 0 while pos < len(template): char = template[pos] if char == "\\" and pos + 1 < len(template): out.append(template[pos:pos + 2]) pos += 2 continue if char == '"': in_quote = not in_quote if char == "%" and pos + 1 < len(template): nxt = template[pos + 1] index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None) if index is not None: value = answers[index] if index < len(answers) else "" if in_quote: value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") out.append(value) pos += 2 continue out.append(char) pos += 1 return "".join(out) def run_later(self, ctx, template, answers): """Run template after a prompt finished, against the pane the prompt was opened for.""" pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True) fresh.depth = ctx.depth + 1 self.s.run_checked(fresh, self.substitute(template, answers)) # ---- sessions and clients -------------------------------------------- def cmd_new_session(self, ctx, args): s = self.s name = args.get("s") or s.auto_session_name() if ":" in name or "." in name: raise MplexError("bad session name: %s" % name) existing = next((item for item in s.sessions.values() if item.name == name), None) if existing is not None and args.has("A"): return self.attach(ctx, existing, args.has("D")) if existing is not None: raise MplexError("duplicate session: %s" % name) client = ctx.client cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd())) env = dict(client.env) if client is not None and client.env else dict(os.environ) for item in args.all("e"): key, _eq, value = item.partition("=") env[key] = value size = (80, 24 + (1 if s.options.get("status") else 0)) if args.get("x") or args.get("y"): size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0)) elif client is not None and not args.has("d"): size = (client.cols, client.rows) session = s.create_session(name, cwd, env, args.positional, args.get("n"), size) if not args.has("d") and client is not None: s.attach_client(client, session) if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active) return "" def attach(self, ctx, session, detach_others): client = self.need_client_for_attach(ctx) if detach_others: for other in list(self.s.attached_clients(session)): if other is not client: self.s.detach_client(other, "[detached (from session %s)]" % session.name) self.s.attach_client(client, session) return "" def need_client_for_attach(self, ctx): if ctx.client is None: raise MplexError("no client to attach") return ctx.client def cmd_attach_session(self, ctx, args): if args.get("t"): session = MplexTargets.session(ctx, args.get("t")) else: session = self.s.latest_session() if session is None: raise MplexError("no sessions") return self.attach(ctx, session, args.has("d")) def cmd_detach_client(self, ctx, args): s = self.s if args.get("s"): session = MplexTargets.session(ctx, args.get("s")) targets = list(s.attached_clients(session)) else: current = self.find_client(ctx, args.get("t")) targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current] for client in targets: s.detach_client(client, "[detached (from session %s)]" % client.session.name) return "" def find_client(self, ctx, spec): if not spec: return self.need_client(ctx) for client in self.s.attached_clients(): if spec in (client.tty, str(client.id), str(client.pid)): return client raise MplexError("can't find client: %s" % spec) def cmd_has_session(self, ctx, args): MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None)) return "" def cmd_kill_session(self, ctx, args): target = self.session_target(ctx, args) if args.has("a"): for session in [item for item in self.s.sessions.values() if item is not target]: self.s.kill_session(session) else: self.s.kill_session(target) return "" def cmd_kill_server(self, ctx, args): self.s.stop() return "" def cmd_start_server(self, ctx, args): return "" def cmd_list_sessions(self, ctx, args): rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)] return self.listing("list-sessions", args, rows) def cmd_list_windows(self, ctx, args): sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()] return self.listing("list-windows", args, rows) def cmd_list_panes(self, ctx, args): s = self.s if args.has("a") or args.has("s"): sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()] else: sess, _index, window = self.window_target(ctx, args) windows = [(sess, window)] rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()] return self.listing("list-panes", args, rows) def cmd_list_clients(self, ctx, args): rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()] return self.listing("list-clients", args, rows) def cmd_rename_session(self, ctx, args): session = self.session_target(ctx, args) name = args.positional[0] if not name or ":" in name or "." in name: raise MplexError("bad session name: %s" % name) if any(item.name == name and item is not session for item in self.s.sessions.values()): raise MplexError("duplicate session: %s" % name) session.name = name self.s.mark_all_dirty() return "" def cmd_switch_client(self, ctx, args): client = self.need_client(ctx) s = self.s if args.has("T"): client.table = args.get("T") client.repeating = False return "" ordered = sorted(s.sessions.values(), key=lambda x: x.id) here = ordered.index(client.session) window = None if args.has("n"): target = ordered[(here + 1) % len(ordered)] elif args.has("p"): target = ordered[(here - 1) % len(ordered)] elif args.has("l"): target = getattr(client, "last_session", None) if target is None or target.id not in s.sessions: raise MplexError("no last session") elif args.get("t"): spec = args.get("t") if spec.startswith("%"): target, window = MplexTargets.pane(ctx, spec)[:2] elif ":" in spec or spec.startswith("@"): target, _index, window = MplexTargets.window(ctx, spec) else: target = MplexTargets.session(ctx, spec) else: raise MplexError("switch-client needs -n, -p, -l, -t or -T") previous = client.session s.attach_client(client, target) if target is not previous: client.last_session = previous if window is not None: target.select(target.index_of(window)) return "" def cmd_suspend_client(self, ctx, args): self.need_client(ctx).send(b"Z") return "" def cmd_refresh_client(self, ctx, args): client = self.need_client(ctx) client.prev = None client.dirty = True return "" def cmd_list_commands(self, ctx, args): lines = [] for name in sorted(MPLEX_COMMAND_SPECS): aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else "")) return "\n".join(lines) # ---- windows --------------------------------------------------------- def cmd_new_window(self, ctx, args): s = self.s spec = args.get("t") session = ctx.session index = None anchor = None if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")): has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None if not has_window: spec = spec + ":" if spec and spec.endswith(":"): session = MplexTargets.session(ctx, spec[:-1]) elif spec and self.is_new_index(ctx, spec): sess_part, _sep, idx_part = spec.rpartition(":") session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session index = int(idx_part.lstrip("=")) elif spec: session, index, anchor = MplexTargets.window(ctx, spec) if session is None: raise MplexError("no current session") if index is None: index = session.free_index(s.options.get("base-index")) current = session.current if (args.has("a") or args.has("b")) and current is not None: index = current + 1 if args.has("a") else current self.make_room(session, index) elif anchor is not None: if args.has("a"): index += 1 self.make_room(session, index) elif args.has("b"): self.make_room(session, index) elif args.has("k"): s.kill_window(anchor) else: raise MplexError("index in use: %d" % index) cwd = os.path.expanduser(args.get("c") or session.cwd) env_extra = dict(item.partition("=")[::2] for item in args.all("e")) if args.has("S"): for idx in session.indexes(): if session.windows[idx].name == args.get("n"): session.select(idx) return "" created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d")) if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active) return "" def is_new_index(self, ctx, spec): """-t names an index that does not exist yet (the usual new-window -t :N case).""" if ":" not in spec: return False sess_part, _sep, idx_part = spec.rpartition(":") idx_part = idx_part.lstrip("=") if not idx_part.isdigit(): return False try: session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session except MplexError: return False return int(idx_part) not in session.windows def make_room(self, session, index): if index not in session.windows: return current = session.window() moved = {} for idx in session.indexes(): moved[idx + 1 if idx >= index else idx] = session.windows[idx] session.windows = moved session.current = session.index_of(current) if current else None def cmd_kill_window(self, ctx, args): session, index, window = self.window_target(ctx, args) if args.has("a"): for other in [session.windows[i] for i in session.indexes() if i != index]: self.s.kill_window(other) else: self.s.kill_window(window) return "" def cmd_select_window(self, ctx, args): if args.has("n") or args.has("p"): return self.step_window(ctx, args, 1 if args.has("n") else -1) if args.has("l"): return self.cmd_last_window(ctx, args) session, index, window = self.window_target(ctx, args) if args.has("T") and index == session.current: if session.last is None: raise MplexError("no last window") session.select(session.last) self.s.mark_dirty() return "" session.select(index) self.s.mark_dirty() return "" def step_window(self, ctx, args, step): session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]) if args.has("a"): raise MplexError("no %s window" % ("next" if step > 0 else "previous")) indexes = session.indexes() here = indexes.index(session.current) session.select(indexes[(here + step) % len(indexes)]) self.s.mark_dirty() return "" def cmd_next_window(self, ctx, args): return self.step_window(ctx, args, 1) def cmd_previous_window(self, ctx, args): return self.step_window(ctx, args, -1) def cmd_last_window(self, ctx, args): session = MplexTargets.session(ctx, args.get("t")) if session.last not in session.windows: raise MplexError("no last window") session.select(session.last) self.s.mark_dirty() return "" def cmd_rename_window(self, ctx, args): session, index, window = self.window_target(ctx, args) if not args.positional[0]: raise MplexError("empty window name") window.name = args.positional[0][:64] window.auto_rename = False self.s.mark_all_dirty() return "" def parse_window_destination(self, ctx, spec): if ":" in spec: sess_part, _sep, idx_part = spec.rpartition(":") session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session else: session, idx_part = ctx.session, spec idx_part = idx_part.lstrip("=") if not idx_part.lstrip("-").isdigit(): raise MplexError("bad window index: %s" % idx_part) return session, int(idx_part) def cmd_move_window(self, ctx, args): s = self.s if args.has("r"): s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0])) s.mark_all_dirty() return "" src_session, src_index, window = MplexTargets.window(ctx, args.get("s")) if not args.get("t"): raise MplexError("move-window needs -t") dst_session, idx = self.parse_window_destination(ctx, args.get("t")) if args.has("a"): idx += 1 self.make_room(dst_session, idx) elif args.has("b"): self.make_room(dst_session, idx) elif idx in dst_session.windows and dst_session.windows[idx] is not window: if args.has("k"): s.kill_window(dst_session.windows[idx]) else: raise MplexError("index in use: %d" % idx) if dst_session is src_session and idx == src_index: return "" was_current = src_session.current == src_index src_session.windows.pop(src_index) src_session.history = [item for item in src_session.history if item is not window] window.session = dst_session window.options.parent = dst_session.options dst_session.windows[idx] = window if was_current and src_session.windows and src_session is not dst_session: src_session.current = src_session.indexes()[0] elif was_current and src_session is dst_session: src_session.current = idx if not args.has("d"): dst_session.select(idx) if not src_session.windows: s.kill_session(src_session) else: s.resize_session(src_session) s.resize_session(dst_session) return "" def cmd_swap_window(self, ctx, args): src_session, src_index, src = MplexTargets.window(ctx, args.get("s")) dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t")) was = (src_session.window(), dst_session.window()) src_session.windows[src_index], dst_session.windows[dst_index] = dst, src src.session, dst.session = dst_session, src_session src.options.parent, dst.options.parent = dst_session.options, src_session.options for session, current in ((src_session, was[0]), (dst_session, was[1])): if current is not None: session.current = session.index_of(current) self.s.resize_session(src_session) self.s.resize_session(dst_session) return "" def cmd_find_window(self, ctx, args): s = self.s text = args.positional[0] fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "") fields = fields or "CNT" if args.has("r"): flags = re.IGNORECASE if args.has("i") else 0 try: matcher = re.compile(text, flags).search except re.error as exc: raise MplexError("bad regular expression: %s" % exc) else: import fnmatch pattern = "*%s*" % text low = pattern.lower() if args.has("i") else pattern def matcher(value): return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low) found = [] for session in sorted(s.sessions.values(), key=lambda x: x.id): for index in session.indexes(): window = session.windows[index] hit = False if "N" in fields and matcher(window.name): hit = True for pane in window.panes(): if "T" in fields and matcher(pane.title): hit = True if "C" in fields and matcher(pane.screen.text()): hit = True if hit: found.append((session, index, window)) if not found: raise MplexError("no windows matching: %s" % text) if len(found) == 1: session, index, _window = found[0] client = ctx.client if client is not None and client.attached and client.session is not session: s.attach_client(client, session) session.select(index) s.mark_dirty() return "" items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found] return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args) # ---- panes ----------------------------------------------------------- def size_arg(self, text, total): text = text.strip() if text.endswith("%"): return max(1, total * int(text[:-1]) // 100) return int(text) def cmd_split_window(self, ctx, args): s = self.s session, window, target = self.pane_target(ctx, args) kind = "lr" if args.has("h") else "tb" leaf = window.leaf_of(target) size = None if args.get("l"): try: size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h) except ValueError: raise MplexError("bad size: %s" % args.get("l")) cwd = os.path.expanduser(args.get("c") or session.cwd) env_extra = dict(item.partition("=")[::2] for item in args.all("e")) pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d")) if args.has("Z") and not args.has("d"): window.zoomed = pane window.relayout() if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane) return "" def cmd_kill_pane(self, ctx, args): session, window, pane = self.pane_target(ctx, args) if args.has("a"): for other in [item for item in window.panes() if item is not pane]: self.s.kill_pane(other) else: self.s.kill_pane(pane) return "" def cmd_select_pane(self, ctx, args): s = self.s if args.has("e") or args.has("d"): raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex") if args.has("M"): s.marked = None s.mark_all_dirty() return "" session, window, pane = self.pane_target(ctx, args) if args.has("m"): s.marked = None if s.marked is pane else pane s.mark_all_dirty() return "" if args.get("T") is not None: pane.screen.title = args.get("T")[:256] s.mark_all_dirty() return "" new = pane for flag in "UDLR": if args.has(flag): found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height) if found is None: raise MplexError("no pane in that direction") new = found if args.has("l"): if window.last_pane not in window.panes(): raise MplexError("no last pane") new = window.last_pane keep_zoom = args.has("Z") and window.zoomed is not None zoomed = window.zoomed s.select_pane(window, new) if keep_zoom and zoomed is not None: window.zoomed = new window.relayout() if session.window() is not window: session.select(session.index_of(window)) return "" def cmd_last_pane(self, ctx, args): _session, _index, window = MplexTargets.window(ctx, args.get("t")) if window.last_pane not in window.panes(): raise MplexError("no last pane") self.s.select_pane(window, window.last_pane) return "" def cmd_resize_pane(self, ctx, args): if args.has("M") or args.has("T"): raise MplexError("resize-pane -M/-T is not supported by mplex") session, window, pane = self.pane_target(ctx, args) if args.has("Z"): if window.zoomed is not None: window.zoomed = None elif len(window.panes()) > 1: window.zoomed = pane window.relayout() self.s.mark_dirty(window) return "" leaf = window.leaf_of(pane) amount = 1 if args.positional: try: amount = int(args.positional[0]) except ValueError: raise MplexError("bad adjustment: %s" % args.positional[0]) try: if args.get("x"): MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width)) if args.get("y"): MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height)) if args.has("R"): MplexLayout.resize_by(leaf, "lr", amount, True) elif args.has("L"): MplexLayout.resize_by(leaf, "lr", -amount, False) elif args.has("D"): MplexLayout.resize_by(leaf, "tb", amount, True) elif args.has("U"): MplexLayout.resize_by(leaf, "tb", -amount, False) except MplexError: pass window.layout_index = -1 window.relayout() self.s.mark_dirty(window) return "" def cmd_swap_pane(self, ctx, args): s = self.s if args.has("U") or args.has("D"): _s, window, dst = self.pane_target(ctx, args) panes = window.panes() here = panes.index(dst) src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)] else: _s, _w, dst = self.pane_target(ctx, args) if args.get("s"): _s2, _w2, src = MplexTargets.pane(ctx, args.get("s")) elif s.marked is not None: src = s.marked else: raise MplexError("no marked pane") if src is dst: return "" wa, wb = src.window, dst.window leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst) leaf_a.pane, leaf_b.pane = dst, src src.window, dst.window = wb, wa if wa is not wb: if wa.active is src: wa.active = dst if wb.active is dst: wb.active = src for window in (wa,) if wa is wb else (wa, wb): window.relayout() s.marked = None s.mark_all_dirty() return "" def cmd_rotate_window(self, ctx, args): session, index, window = self.window_target(ctx, args) panes = window.panes() if len(panes) < 2: return "" order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1] position = panes.index(window.active) for leaf, pane in zip(window.root.leaves(), order): leaf.pane = pane window.active = order[position] window.relayout() s = self.s s.mark_dirty(window) return "" def cmd_break_pane(self, ctx, args): s = self.s session, window, pane = MplexTargets.pane(ctx, args.get("s")) if len(window.panes()) < 2: raise MplexError("can't break with only one pane") dst_session = session index = dst_session.free_index(s.options.get("base-index")) if args.get("t"): dst_session, index = self.parse_window_destination(ctx, args.get("t")) if index in dst_session.windows: raise MplexError("index in use: %d" % index) leaf = window.leaf_of(pane) was_active = window.active is pane window.root = MplexLayout.remove(window.root, leaf) if window.zoomed is pane: window.zoomed = None rest = window.panes() if was_active or window.active is pane: window.active = window.last_pane if window.last_pane in rest else rest[0] if window.last_pane is pane: window.last_pane = None window.relayout() cols, rows = getattr(dst_session, "size", (80, 24)) fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options) fresh.auto_rename = not args.get("n") fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height) fresh.active = pane pane.window = fresh s.register_window(dst_session, fresh, index, not args.has("d")) fresh.relayout() s.mark_all_dirty() if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane) return "" def apply_layout(self, window, name, index): panes = window.panes() opts = window.options window.zoomed = None window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height")) window.layout_index = index window.relayout() self.s.mark_dirty(window) def cmd_select_layout(self, ctx, args): session, window, pane = self.pane_target(ctx, args) names = MPLEX_LAYOUT_NAMES if args.has("E"): leaf = window.leaf_of(pane) parent = leaf.parent if parent is not None: for child in parent.children: MplexLayout.set_size(child, parent.kind, 1) window.relayout() self.s.mark_dirty(window) return "" if args.positional: wanted = args.positional[0] matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)] if len(matches) != 1: raise MplexError("unknown or ambiguous layout: %s" % wanted) self.apply_layout(window, matches[0], names.index(matches[0])) return "" if args.has("p"): index = (window.layout_index - 1) % len(names) elif args.has("o"): index = window.layout_index if window.layout_index >= 0 else 0 else: index = (window.layout_index + 1) % len(names) self.apply_layout(window, names[index], index) return "" def cmd_next_layout(self, ctx, args): session, index, window = self.window_target(ctx, args) self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)) return "" def cmd_previous_layout(self, ctx, args): session, index, window = self.window_target(ctx, args) step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES) self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step) return "" def cmd_clear_history(self, ctx, args): _s, _w, pane = self.pane_target(ctx, args) pane.clear_history() return "" def cmd_capture_pane(self, ctx, args): _s, _w, pane = self.pane_target(ctx, args) screen = pane.screen if args.has("a") and not screen.alt_active: if args.has("q"): return "" raise MplexError("no alternate screen") history = list(screen.history or []) rows = history + [list(row) for row in screen.grid] base = len(history) def line_of(row): text = "".join(cell[0] if cell else " " for cell in row) return text if args.has("N") else text.rstrip() def bound(value, default): if value is None: return default if value == "-": return None try: return base + int(value) except ValueError: raise MplexError("bad line number: %s" % value) start = bound(args.get("S"), base) end = bound(args.get("E"), base + pane.h - 1) start = 0 if start is None else max(0, start) end = len(rows) - 1 if end is None else min(len(rows) - 1, end) text = "\n".join(line_of(rows[i]) for i in range(start, end + 1)) if args.has("p"): return text self.s.add_buffer(text + "\n", args.get("b")) return "" # ---- keys, prompts, input -------------------------------------------- def cmd_send_prefix(self, ctx, args): session, window, pane = self.pane_target(ctx, args) name = session.options.get("prefix2" if args.has("2") else "prefix") if name == "None": raise MplexError("no prefix key set") self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor)) return "" def cmd_send_keys(self, ctx, args): session, window, pane = self.pane_target(ctx, args) words = list(args.positional) count = args.int("N", None) if args.has("M"): raise MplexError("send-keys -M (mouse) is not supported by mplex") if args.has("X"): mode = pane.top_mode() if not isinstance(mode, MplexCopyMode): raise MplexError("pane is not in a mode") if not words: raise MplexError("send-keys -X needs a command") if len(words) > 1 and words[1] == "--": del words[1] if args.has("F"): words = [self.fmt(item, session, window, pane, ctx.client) for item in words] if count: pane.prefix_count = count mode.command(words[0], words[1:], ctx.client) return "" if args.has("R"): pane.screen.reset() if not words: if count: pane.prefix_count = count return "" if args.has("F"): words = [self.fmt(item, session, window, pane, ctx.client) for item in words] data = b"" if args.has("l"): data = "".join(words).encode("utf-8") elif args.has("H"): try: data = bytes(int(item, 16) for item in words) except ValueError: raise MplexError("bad hex byte in send-keys -H") else: for word in words: try: data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor) except MplexError: data += word.encode("utf-8") mode = pane.top_mode() for _i in range(max(1, min(count or 1, 1000))): if mode is not None and not args.has("l") and not args.has("H"): tokens, _rest = MplexKeys.split(data, True) for name, raw in tokens: mode.key(name, raw, ctx.client) elif not pane.dead: pane.write(data) self.s.mark_dirty(window) return "" def cmd_bind_key(self, ctx, args): table = "root" if args.has("n") else (args.get("T") or "prefix") key = MplexKeys.canonical(args.positional[0]) words = args.positional[1:] if not words: raise MplexError("bind-key needs a command") command = " ".join(MplexCommandParser.quote(word) for word in words) for parsed in MplexCommandParser.parse(command): self.resolve(parsed[0]) self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "") return "" def cmd_unbind_key(self, ctx, args): table = "root" if args.has("n") else (args.get("T") or "prefix") config = self.s.config if args.has("a"): config.tables[table] = {} return "" if not args.positional: raise MplexError("unbind-key needs a key (or -a)") key = MplexKeys.canonical(args.positional[0]) if not config.unbind(table, key) and not args.has("q"): raise MplexError("unknown key: %s" % args.positional[0]) return "" def cmd_list_keys(self, ctx, args): config = self.s.config opts = ctx.session.options if ctx.session else self.s.options only = MplexKeys.canonical(args.positional[0]) if args.positional else None tables = [args.get("T")] if args.get("T") else list(config.tables) lines = [] if args.has("N"): prefix = opts.get("prefix") rows = [] for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]): for key, binding in config.tables.get(table, {}).items(): if only is not None and key != only: continue note = binding.note or (binding.command if args.has("a") else "") if not note: continue label = key if table == "root" else "%s %s" % (prefix, key) if args.get("P") is not None: label = args.get("P") + key rows.append((label, note)) if only is not None and not rows: raise MplexError("unknown key: %s" % args.positional[0]) if args.has("1"): rows = rows[:1] width = max([len(label) for label, _note in rows] or [0]) return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows) for table in tables: for key, binding in config.tables.get(table, {}).items(): if only is not None and key != only: continue lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command)) if args.has("1") and lines: lines = lines[:1] return "\n".join(lines) def cmd_command_prompt(self, ctx, args): s = self.s client = self.need_client(ctx) template = args.positional[0] if args.positional else None prompts = args.get("p").split(",") if args.get("p") else [] inputs = args.get("I").split(",") if args.get("I") else [] prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts] inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs] if template is not None and args.has("F"): template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client) kind = args.get("T") or "command" history = s.prompt_history.setdefault(kind, []) answers = [] count = max(1, len(prompts)) pane = ctx.pane copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None def ask(position): label = (prompts[position] + " ") if prompts else ":" initial = inputs[position] if position < len(inputs) else "" def done(value): answers.append(value) if position + 1 < count: ask(position + 1) return if template is None: self.run_later(ctx, "%%", answers) else: self.run_later(ctx, template, answers) kwargs = {} if args.has("i") and template is not None: kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text]) kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text]) if copy_mode is not None: kwargs["on_cancel"] = copy_mode.search_cancel client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs) client.dirty = True ask(0) return "" def cmd_confirm_before(self, ctx, args): client = self.need_client(ctx) command = args.positional[0] text = args.get("p") or "Confirm '%s'? (y/n)" % command text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client) pane = ctx.pane def run(): fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True) fresh.depth = ctx.depth + 1 self.s.run_checked(fresh, command) self.s.ask_confirm(client, text, run) return "" def cmd_display_message(self, ctx, args): session, window, pane = self.pane_target(ctx, args) template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"] text = self.fmt(template, session, window, pane, ctx.client) text = MplexFormat.strip_styles(text) if args.has("p"): return text client = ctx.client if client is not None and client.attached: delay = args.int("d", None) self.s.message(client, text, delay) return "" def cmd_display_panes(self, ctx, args): client = self.need_client(ctx) window = client.session.window() duration = args.int("d", None) or window.options.get("display-panes-time") client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0) client.dirty = True return "" def cmd_clock_mode(self, ctx, args): _s, window, pane = self.pane_target(ctx, args) if pane.find_mode(MplexClockMode) is None: pane.push_mode(MplexClockMode(self.s, pane)) self.s.mark_dirty(window) return "" def cmd_copy_mode(self, ctx, args): if args.has("M"): raise MplexError("copy-mode -M (mouse) is not supported by mplex") _s, window, pane = self.pane_target(ctx, args) existing = pane.find_mode(MplexCopyMode) if args.has("q"): if existing is not None: existing.close() return "" if existing is None: existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H")) pane.push_mode(existing) if args.has("u"): existing.command("page-up", [], ctx.client) self.s.mark_dirty(window) return "" def cmd_show_messages(self, ctx, args): return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages) # ---- buffers --------------------------------------------------------- def buffer_rows(self): rows = [] for item in self.s.buffers: sample = item["data"][:50].replace("\n", " ") rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample})) return rows def cmd_list_buffers(self, ctx, args): return self.listing("list-buffers", args, self.buffer_rows()) def cmd_set_buffer(self, ctx, args): name = args.get("b") if args.get("n"): item = self.s.find_buffer(name) item["name"] = args.get("n") return "" if not args.positional: raise MplexError("set-buffer needs data") data = args.positional[0] if args.has("a"): item = self.s.find_buffer(name) item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX] else: self.s.add_buffer(data, name) return "" def cmd_show_buffer(self, ctx, args): return self.s.find_buffer(args.get("b"))["data"] def cmd_delete_buffer(self, ctx, args): item = self.s.find_buffer(args.get("b")) self.s.buffers.remove(item) self.s.refresh_choosers() return "" def cmd_save_buffer(self, ctx, args): item = self.s.find_buffer(args.get("b")) path = os.path.expanduser(args.positional[0]) try: with open(path, "ab" if args.has("a") else "wb") as handle: handle.write(item["data"].encode("utf-8")) except OSError as exc: raise MplexError("cannot write %s: %s" % (path, short_error(exc))) return "" def cmd_load_buffer(self, ctx, args): path = os.path.expanduser(args.positional[0]) try: with open(path, "rb") as handle: data = handle.read(MPLEX_PASTE_MAX) except OSError as exc: raise MplexError("cannot read %s: %s" % (path, short_error(exc))) self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b")) return "" def cmd_paste_buffer(self, ctx, args): session, window, pane = self.pane_target(ctx, args) item = self.s.find_buffer(args.get("b")) separator = args.get("s") if args.get("s") is not None else "\r" text = item["data"] if args.has("r") else item["data"].replace("\n", separator) data = text.encode("utf-8") if args.has("p") and pane.screen.bracketed_paste: data = b"\x1b[200~" + data + b"\x1b[201~" if not pane.dead: pane.write(data) if args.has("d"): self.s.buffers.remove(item) return "" # ---- choosers -------------------------------------------------------- def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None): s = self.s pane = ctx.pane if pane is None: raise MplexError("no pane") window = pane.window zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1 if zoom_on_open: window.zoomed = pane window.relayout() def restore(): if zoom_on_open and window.zoomed is pane: window.zoomed = None window.relayout() def choose(node, client): kind_tag, ident = node.payload if kind_tag == "window": target = s.windows.get(ident) if target is None: return if client is not None and client.session is not target.session: s.attach_client(client, target.session) target.session.select(target.session.index_of(target)) elif kind_tag == "pane": target = s.panes.get(ident) if target is None or target.window is None: return if client is not None and client.session is not target.window.session: s.attach_client(client, target.window.session) target.window.session.select(target.window.session.index_of(target.window)) s.select_pane(target.window, target) elif kind_tag == "session": target = s.sessions.get(ident) if target is not None and client is not None: s.attach_client(client, target) s.mark_all_dirty() mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore) pane.push_mode(mode) s.mark_dirty(window) return "" def cmd_choose_tree(self, ctx, args): s = self.s sessions_only = args.has("s") windows_collapsed = args.has("w") def builder(): items = [] for session in sorted(s.sessions.values(), key=lambda x: x.id): kids = [] for index in session.indexes(): window = session.windows[index] panes = [] for number, pane in enumerate(window.panes()): panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id))) kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height), ("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1)) attached = " (attached)" if s.attached_clients(session) else "" items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only)) return items def preview(node, width, height): kind_tag, ident = node.payload pane = None if kind_tag == "pane": pane = s.panes.get(ident) elif kind_tag == "window": window = s.windows.get(ident) pane = window.active if window else None else: session = s.sessions.get(ident) pane = session.window().active if session else None if pane is None: return [] blank = MplexCanvas.BLANK return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]] def delete(node, client): kind_tag, ident = node.payload if kind_tag == "session" and ident in s.sessions: s.kill_session(s.sessions[ident]) elif kind_tag == "window" and ident in s.windows: s.kill_window(s.windows[ident]) elif kind_tag == "pane" and ident in s.panes: s.kill_pane(s.panes[ident]) selected = ("window", ctx.window.id) if ctx.window is not None else None return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected) def cmd_choose_buffer(self, ctx, args): s = self.s pane = ctx.pane def builder(): return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers] def choose(node, client): if pane is not None and pane.id in s.panes and not pane.dead: item = s.find_buffer(node.payload[1]) pane.write(item["data"].replace("\n", "\r").encode("utf-8")) def delete(node, client): s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]] def preview(node, width, height): data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "") return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]] if not s.buffers: raise MplexError("no buffers") return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview) def cmd_choose_client(self, ctx, args): s = self.s def builder(): return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()] def choose(node, client): for target in s.attached_clients(): if target.id == node.payload[1]: s.detach_client(target, "[detached (from session %s)]" % target.session.name) return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose) # ---- options, shell, files ------------------------------------------- def option_name(self, name): if name in MplexOptions.DEFS: return name matches = [item for item in MplexOptions.DEFS if item.startswith(name)] if len(matches) == 1: return matches[0] raise MplexError("unknown or unsupported option: %s" % name) def option_scope(self, ctx, args, window_scope): if args.has("g") or args.has("s"): return self.s.options if window_scope or args.has("w"): return MplexTargets.window(ctx, args.get("t"))[2].options return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options def set_option(self, ctx, args, window_scope): try: name = self.option_name(args.positional[0]) except MplexError: if args.has("q"): return "" raise scope = self.option_scope(ctx, args, window_scope) if args.has("u"): scope.unset(name) else: if len(args.positional) < 2: scope.toggle(name) else: value = args.positional[1] if args.has("F"): value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client) if args.has("o") and scope.is_set(name): raise MplexError("already set: %s" % name) scope.set(name, value, args.has("a")) self.s.resize_all() self.s.mark_all_dirty() return "" def cmd_set_option(self, ctx, args): return self.set_option(ctx, args, False) def cmd_set_window_option(self, ctx, args): return self.set_option(ctx, args, True) def show_options(self, ctx, args, window_scope): scope = self.option_scope(ctx, args, window_scope) names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS) lines = [] for name in names: if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name): value = scope.get(name) text = MplexOptions.show(value) if args.has("v"): lines.append(text) else: lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text)) return "\n".join(lines) def cmd_show_options(self, ctx, args): return self.show_options(ctx, args, False) def cmd_show_window_options(self, ctx, args): return self.show_options(ctx, args, True) def shell_in(self, ctx, command, pane): cwd = pane.current_path() if pane is not None else None env = dict(ctx.session.env) if ctx.session is not None else None try: done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env) except (OSError, subprocess.SubprocessError) as exc: raise MplexError("shell command failed: " + short_error(exc)) return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace") def cmd_if_shell(self, ctx, args): session, window, pane = self.pane_target(ctx, args) condition = args.positional[0] if args.has("F"): ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client)) else: ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0 chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "") if not chosen: return "" return self.s.run_line(ctx, chosen, ctx.depth + 1) def cmd_run_shell(self, ctx, args): session, window, pane = self.pane_target(ctx, args) if args.get("d"): raise MplexError("run-shell -d (delay) is not supported by mplex") if not args.positional: return "" command = self.fmt(args.positional[0], session, window, pane, ctx.client) if args.has("C"): return self.s.run_line(ctx, command, ctx.depth + 1) if args.has("b"): try: subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=pane.current_path() or None, start_new_session=True) except OSError as exc: raise MplexError("shell command failed: " + short_error(exc)) return "" return self.shell_in(ctx, command, pane)[1].rstrip("\n") def cmd_source_file(self, ctx, args): s = self.s out = [] for raw in args.positional: path = os.path.expanduser(raw) if not os.path.exists(path): if args.has("q"): continue raise MplexError("no such file: %s" % raw) if path.endswith(".toml"): before = s.config.skipped s.config.apply_file(path) for problem in s.config.errors[before:]: s.log_message("mplex.toml: " + problem) skipped = s.config.skipped - before if skipped: out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped)) s.resize_all() s.mark_all_dirty() continue try: with open(path, "r", encoding="utf-8") as handle: lines = handle.read(MPLEX_CONFIG_MAX).splitlines() except OSError as exc: raise MplexError("cannot read %s: %s" % (path, short_error(exc))) for number, line in enumerate(lines, 1): if not line.strip() or line.lstrip().startswith("#"): continue try: text = s.run_line(ctx, line, ctx.depth + 1) except MplexError as exc: raise MplexError("%s:%d: %s" % (raw, number, exc)) if text: out.append(text) return "\n".join(out) class MplexClient: """The attached terminal: forwards keys and resizes to the server, paints what it sends.""" def __init__(self, home, commands): self.home = home self.commands = commands self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) self.stdin_fd = None self.saved = None self.attached = False self.wake_r = None self.wake_w = None @staticmethod def frame(kind, payload=b""): return kind + struct.pack(">I", len(payload)) + payload def size(self): try: packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8) rows, cols = struct.unpack("HH", packed[:4]) if rows and cols: return rows, cols except (OSError, ValueError, AttributeError): pass return 24, 80 def hello(self): rows, cols = self.size() pane = os.environ.get(PLEXER_PANE_ENV, "") info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(), "tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands, "env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""} return self.frame(b"H", json.dumps(info).encode("utf-8")) def enter(self): self.stdin_fd = sys.stdin.fileno() try: self.saved = termios.tcgetattr(self.stdin_fd) tty.setraw(self.stdin_fd) except (termios.error, OSError): self.saved = None os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H") self.attached = True def leave(self): if not self.attached: return self.attached = False try: os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l") except OSError: pass if self.saved is not None: try: termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved) except (termios.error, OSError): pass def write_out(self, data): fd = sys.stdout.fileno() view = memoryview(data) while view: try: written = os.write(fd, view) except BlockingIOError: select.select([], [fd], [], 1.0) continue view = view[written:] def on_winch(self, _signum, _frame): try: os.write(self.wake_w, b"w") except OSError: pass def run(self): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: sock.connect(self.sock_path) except OSError: sys.stderr.write("no server running on %s\n" % self.sock_path) return 1 sock.sendall(MPLEX_MAGIC + self.hello()) self.wake_r, self.wake_w = os.pipe() os.set_blocking(self.wake_w, False) previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None buf = bytearray() code = 0 message = "" try: while True: watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else []) try: ready, _w, _x = select.select(watch, [], []) except InterruptedError: continue if self.wake_r in ready: os.read(self.wake_r, 4096) rows, cols = self.size() sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) if self.attached and self.stdin_fd in ready: data = os.read(self.stdin_fd, 4096) if not data: message = "[detached]" break sock.sendall(self.frame(b"I", data)) if sock in ready: data = sock.recv(262144) if not data: code = 1 message = "[lost server]" break buf += data done = self.handle_frames(sock, buf) if done is not None: code, message = done break finally: self.leave() if previous is not None: signal.signal(signal.SIGWINCH, previous) sock.close() os.close(self.wake_r) os.close(self.wake_w) if message: (sys.stderr if code else sys.stdout).write(message + "\n") return code def handle_frames(self, sock, buf): while len(buf) >= 5: length = struct.unpack(">I", bytes(buf[1:5]))[0] if len(buf) < 5 + length: return None kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length]) del buf[:5 + length] if kind == b"A": self.enter() elif kind == b"O": self.write_out(payload) elif kind == b"M": sys.stdout.write(payload.decode("utf-8", "replace") + "\n") sys.stdout.flush() elif kind == b"E": return 1, payload.decode("utf-8", "replace") elif kind in (b"D", b"X"): return 0, payload.decode("utf-8", "replace") elif kind == b"Z": self.suspend(sock) return None def suspend(self, sock): self.leave() os.kill(os.getpid(), signal.SIGTSTP) self.enter() rows, cols = self.size() sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) class MplexCli: """tai.py --mplex : any mplex command; new-session and attach-session attach this terminal.""" @staticmethod def attaches(name, rest): if name == "attach-session": return True if name != "new-session": return False try: return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d") except MplexError: return True @staticmethod def split_commands(words): """argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument.""" commands = [[]] for word in words: if word == ";": commands.append([]) else: commands[-1].append(";" if word == "\\;" else word) return [item for item in commands if item] @classmethod def main(cls, home, words): commands = cls.split_commands(words) or [["new-session"]] try: name = MplexCommands.resolve(commands[0][0]) except MplexError as exc: sys.stderr.write("%s\n" % exc) return 1 commands[0][0] = name words = commands[0] if cls.attaches(name, words[1:]): if os.environ.get(PLEXER_ACTIVE_ENV): sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV) return 1 if not (sys.stdin.isatty() and sys.stdout.isatty()): sys.stderr.write("open terminal failed: not a terminal\n") return 1 if name in ("new-session", "start-server"): if not MplexServer.ensure_running(home): sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME)) return 1 return MplexClient(home, commands).run() class NativePlexerBackend(PlexerBackend): """What the shell hook execs into: a new mplex session attached to this terminal.""" def __init__(self, home, profile, shell_cmd=None): self.home = home self.profile = profile self.shell_cmd = shell_cmd def run(self): if pty is None or termios is None or select is None or fcntl is None: return "error: pty/termios/select not available on this platform" return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else [])) def plexer_should_wrap(env): """The Python-side half of the detection flow (see research/shell-multiplexer-auto-attach/). The cheap, fast checks ($- interactive flag, isatty) already happened in the bashrc block itself before tai.py was even invoked -- this only covers what needs Python: never wrap if a plexer is already active, and never wrap if tmux already is.""" if env.get(PLEXER_ACTIVE_ENV): return False if env.get("TMUX"): return False if os.path.exists(os.path.join(os.path.expanduser("~"), ".tai-no-plexer")): return False return True PLEXER_BASHRC_MARK_BEGIN = "# tai plexer hook - start" PLEXER_BASHRC_MARK_END = "# tai plexer hook - end" PLEXER_BASHRC_BLOCK = ( PLEXER_BASHRC_MARK_BEGIN + "\n" "if [ -z \"$TAI_PLEXER_ACTIVE\" ] && [ -z \"$TMUX\" ] && [ ! -e \"$HOME/.tai-no-plexer\" ] && case \"$-\" in *i*) true;; *) false;; esac; then\n" " if [ -t 0 ] && [ -t 1 ]; then\n" " exec \"$HOME/.local/bin/tai.py\" --plexer-wrap\n" " fi\n" "fi\n" + PLEXER_BASHRC_MARK_END + "\n" ) FISH_PLEXER_BLOCK = ( PLEXER_BASHRC_MARK_BEGIN + "\n" "if status is-interactive\n" "and test -z \"$TAI_PLEXER_ACTIVE\"\n" "and test -z \"$TMUX\"\n" "and not test -e \"$HOME/.tai-no-plexer\"\n" "and isatty stdin\n" "and isatty stdout\n" " exec \"$HOME/.local/bin/tai.py\" --plexer-wrap\n" "end\n" + PLEXER_BASHRC_MARK_END + "\n" ) class Tools: def __init__(self, app): self.app = app self.calls = 0 self.handlers = { "shell": self.run_shell, "read_file": self.run_read_file, "write_file": self.run_write_file, "edit_file": self.run_edit_file, "web_search": self.run_web_search, "describe_image": self.run_describe_image, "web_fetch": self.run_web_fetch, "speak": self.run_speak, "listen": self.run_listen, "remember": self.run_remember, "recall": self.run_recall, "load_skill": self.run_load_skill, "get_current_terminal_content": self.run_terminal_content, "list_terminals": self.run_list_terminals, "tail_terminals": self.run_tail_terminals, "export_terminal_log": self.run_export_terminal_log, "plexer_send": self.run_plexer_send, "plexer_read": self.run_plexer_read, "plexer_type": self.run_plexer_type, "plexer_status": self.run_plexer_status, "tui_screen": lambda args: self.run_tui("screen", args), "tui_keys": lambda args: self.run_tui("key", args), "tui_type": lambda args: self.run_tui("type", args), "tui_click": lambda args: self.run_tui("click", args), "tui_goto": lambda args: self.run_tui("goto", args), "tui_result": lambda args: self.run_tui("result", args), "fork": self.run_fork, "poll": self.run_poll, "ask_user": self.run_ask_user, "sysinfo": self.run_sysinfo, "create_skill": self.run_create_skill, "install_skill": self.run_install_skill, "store_secret": self.run_store_secret, "list_secrets": self.run_list_secrets, "delete_secret": self.run_delete_secret, "schedule": self.run_schedule, "unschedule": self.run_unschedule, "schedules": self.run_schedules, "record_save": self.run_record_save, "record_read": self.run_record_read, "record_search": self.run_record_search, "record_delete": self.run_record_delete, "graph_link": self.run_graph_link, "graph_query": self.run_graph_query, "delete_file": self.run_delete_file, "audit": self.run_audit, "restore": self.run_restore, "release": self.run_release, "tags": self.run_tags, "install": self.run_install, "create_bot": self.run_create_bot, "search": self.run_search, } self.secret_grants = set() self.read_files = set() self.live = False self.diff_preview = None self.board_shown = False def note_diff(self, path, old, new): self.diff_preview = {"path": path, "old": old, "new": new} def box_engine(self): try: return ensure_box(), "" except BackendError as exc: return "", "error: " + short_error(exc) def dispatch(self, name, raw_arguments): self.calls += 1 try: args = raw_arguments if isinstance(raw_arguments, dict) else json.loads(raw_arguments or "{}") except (TypeError, ValueError): return "error: arguments are not valid JSON" handler = self.handlers.get(name) if handler is None: return "error: unknown tool " + name try: result = handler(args) except Denied: raise except Exception as exc: return "error: " + short_error(exc) if self.app.store is not None: return self.app.store.redact(result, self.active_profile) return result def secret_grant(self, kind, key, names): token = (kind, key, tuple(sorted(names))) if token in self.secret_grants: return True if not self.app.ask_approval("share secrets (%s) with %s" % (", ".join(sorted(names)), kind)): return False self.secret_grants.add(token) return True def resolve_secrets(self, names): vault = {} for name in names: value = self.app.store.load_secret(name, self.active_profile) if value is None: return None, "error: unknown secret '%s'" % name vault[name] = value return vault, "" def shell_is_safe(self, command): parts = command.split() if not parts: return False if parts[0] == "git": return len(parts) > 1 and parts[1] in ("status", "diff", "log", "show", "branch", "remote") return parts[0] in SAFE_COMMANDS def snapshot_shell_targets(self, command, workdir): if self.app.store is None: return 0 profile = self.active_profile count = 0 for found in shell_target_paths(command, workdir): if not os.path.isfile(found): continue text, override = read_capped(found) if text is None: continue try: self.app.store.audit_event(self.audit_actor(), "shell-snapshot", found, command[:200], old=text, tags=["shell"], old_size=override, profile=profile) self.app.store.upsert_file_record(found, text, override, profile) count += 1 except (sqlite3.Error, OSError): continue return count def run_shell(self, args): command = str(args.get("command") or "").strip() if not command: return "error: empty command" secret_names = args.get("secrets") or [] if not isinstance(secret_names, list) or any(not isinstance(item, str) for item in secret_names): return "error: secrets must be a list of names" vault, failure = self.resolve_secrets(secret_names) if secret_names else ({}, "") if failure: return failure if vault and not self.secret_grant("shell", "", sorted(vault)): return "denied by user" if self.app.env == "sandbox": return self.run_shell_box(command, str(args.get("workdir") or "/"), vault) workdir = str(args.get("workdir") or os.getcwd()) if not self.app.config.auto_approve and not self.shell_is_safe(command) and not self.app.ask_approval(command): return "denied by user" self.snapshot_shell_targets(command, workdir) env = dict(os.environ) for name, value in vault.items(): env[secret_env_name(name)] = value stream = self.shell_stream(command) code, output, elapsed, timed_out = run_live(command, shell=True, cwd=workdir, env=env, stream=stream, ask=self.shell_ask(command)) if stream is not None: stream.close(stream_status(code, output, elapsed, timed_out)) if timed_out: return "error: timed out after %d seconds" % SHELL_TIMEOUT return "exit %d\n%s%s" % (code, spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile), curl_hint(command)) def shell_stream(self, command): """Where a running command's output goes live: the chat scene's own window when the app provides one, else the REPL's scrolling window, else nowhere.""" factory = getattr(self.app, "stream_factory", None) if factory is not None: store = self.app.store return factory(store.redact(command, self.active_profile) if store is not None else command) return LiveStream() if self.live and sys.stdout.isatty() else None def shell_ask(self, command): """ask(text, secret) for a command that stops on a prompt (sudo, ssh, a y/n), or None when nobody can answer: autonomous mode, background workers and non-interactive stdin keep the old behavior of a command that never gets input.""" app = self.app if getattr(app, "auto", False) or not getattr(app, "persist", True) or getattr(app, "depth", 0) > 0: return None prompter = getattr(app, "prompter", None) def answered(value): if value is not None: app.user_stepped_in = True return value if prompter is not None: return lambda text, secret: answered(prompter("ask", "%s asks:\n%s" % (command[:60], text), [])) if not (sys.stdin.isatty() and sys.stdout.isatty()): return None def repl_ask(text, secret): try: line = getpass.getpass(paint("» %s " % text, Ansi.YELLOW)) if secret else input(paint("» %s " % text, Ansi.YELLOW)) except (EOFError, KeyboardInterrupt): return None return answered(line) return repl_ask def run_shell_box(self, command, workdir, vault): engine, failure = self.box_engine() if not engine: return failure extra = ["--workdir", workdir] for name, value in vault.items(): extra += ["-e", "%s=%s" % (secret_env_name(name), value)] try: stream = self.shell_stream(command) ask = self.shell_ask(command) code, output, elapsed, timed_out = run_live([engine, "exec", *extra, "-it" if ask else "-i", BOX_NAME, "sh", "-c", command], stream=stream, ask=ask) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if stream is not None: stream.close(stream_status(code, output, elapsed, timed_out)) if timed_out: return "error: timed out after %d seconds" % SHELL_TIMEOUT return "exit %d\n%s%s" % (code, spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile), curl_hint(command)) def run_read_file(self, args): path = str(args.get("path") or "") if self.app.env == "sandbox": result = self.box_read(path) if not result.startswith("error:"): self.read_files.add(("box", path)) return result if not path or not os.path.isfile(path): return "error: no such file" if os.path.getsize(path) > 200000: return "error: file too large" try: with open(path, "r", encoding="utf-8", errors="replace") as handle: result = truncate(handle.read(), 8000, 2000) except OSError as exc: return "error: " + short_error(exc) self.read_files.add(("home", os.path.abspath(path))) return result def audit_actor(self): if getattr(self.app, "persist", True) and getattr(self.app, "depth", 0) == 0: return "main" return "worker" @property def active_profile(self): profile = getattr(self.app, "profile", None) if profile: return profile store = getattr(self.app, "store", None) return getattr(store, "profile", None) or "default" def audit_file(self, action, path, old, new, message="", tags=(), old_size=None, new_size=None): if self.app.store is None: return None profile = self.active_profile try: row_id = self.app.store.audit_event(self.audit_actor(), action, path, message, old, new, ["file", action] + list(tags), old_size, new_size, profile) except (sqlite3.Error, OSError): return False try: if action == "delete": self.app.store.delete_file_record(path, profile) elif new is not None: self.app.store.upsert_file_record(path, new, new_size, profile) elif old is not None: self.app.store.upsert_file_record(path, old, old_size, profile) except (sqlite3.Error, OSError): pass return row_id def box_read_raw(self, path, limit=200000): engine, failure = self.box_engine() if not engine: return None, failure try: done = box_exec(engine, ["sh", "-c", "wc -c < \"$1\" && head -c \"$2\" \"$1\"", "box", path, str(limit + 1)], timeout=60) except (OSError, subprocess.SubprocessError) as exc: return None, "error: " + short_error(exc) if done.returncode != 0: return None, "error: no such file" head, _, rest = done.stdout.decode("utf-8", "replace").partition("\n") try: size = int(head.strip()) except ValueError: return None, "error: unreadable file" return size, rest def box_read(self, path): if not path: return "error: empty path" engine, failure = self.box_engine() if not engine: return failure try: done = box_exec(engine, ["head", "-c", "200000", path], timeout=60) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: no such file" return truncate(done.stdout.decode("utf-8", "replace"), 8000, 2000) def box_write(self, path, content, old=_UNSET, action="write", message="", preview=True): engine, failure = self.box_engine() if not engine: return failure if ("box", path) not in self.read_files: try: probe = box_exec(engine, ["test", "-f", path], timeout=30) except (OSError, subprocess.SubprocessError): probe = None if probe is not None and probe.returncode == 0: return "error: write denied for '%s', read it first with read_file" % path if old is _UNSET: old, old_size = self.box_old_image(path) else: old_size = None parent = os.path.dirname(path) or "." try: done = box_exec(engine, ["sh", "-c", "mkdir -p \"$1\" && cat > \"$2\"", "box", parent, path], input_data=content.encode("utf-8"), timeout=60) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: " + done.stderr.decode("utf-8", "replace")[:200] self.read_files.add(("box", path)) audited = self.audit_file(action, "sandbox:" + path, old, content, message, (), old_size, None) note = "" if audited is not False else " (audit failed)" if preview and old_size is None: self.note_diff(path, old, content) return "wrote %s%s" % (path, note) def box_old_image(self, path): size, raw = self.box_read_raw(path, AUDIT_MAX_CHARS) if size is None: return None, None if size <= AUDIT_MAX_CHARS: return raw, None return raw[:AUDIT_MAX_CHARS], size def run_write_file(self, args): path = str(args.get("path") or "") if not path: return "error: empty path" if self.app.env == "sandbox": return self.box_write(path, str(args.get("content") or "")) key = ("home", os.path.abspath(path)) if os.path.isfile(path) and key not in self.read_files: return "error: write denied for '%s', read it first with read_file" % path content = str(args.get("content") or "") if os.path.isfile(path): old, old_size = read_capped(path) else: old, old_size = None, None try: parent = os.path.dirname(os.path.abspath(path)) os.makedirs(parent, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write(content) self.read_files.add(key) except OSError as exc: return "error: " + short_error(exc) audited = self.audit_file("write", os.path.abspath(path), old, content, "", (), old_size, None) note = "" if audited is not False else " (audit failed)" if old_size is None: self.note_diff(path, old, content) return "wrote %s%s" % (path, note) def run_edit_file(self, args): path = str(args.get("path") or "") find = str(args.get("find") or "") if self.app.env == "sandbox": return self.box_edit(path, find, str(args.get("replace") or "")) if not path or not os.path.isfile(path): return "error: no such file" if not find: return "error: empty match" if ("home", os.path.abspath(path)) not in self.read_files: return "error: edit denied for '%s', read it first with read_file" % path try: with open(path, "r", encoding="utf-8", errors="replace") as handle: content = handle.read() except OSError as exc: return "error: " + short_error(exc) if content.count(find) != 1: return "error: match is not unique (%d occurrences)" % content.count(find) updated = content.replace(find, str(args.get("replace") or "")) try: with open(path, "w", encoding="utf-8") as handle: handle.write(updated) except OSError as exc: return "error: " + short_error(exc) audited = self.audit_file("edit", os.path.abspath(path), content, updated) note = "" if audited is not False else " (audit failed)" self.note_diff(path, content, updated) return "edited %s%s" % (path, note) def run_web_search(self, args): query = str(args.get("query") or "").strip() if not query: return "error: empty query" params = {"query": query} if args.get("images"): params["type"] = "images" if args.get("content"): params["content"] = "true" url = RSEARCH_BASE + "/search?" + urllib.parse.urlencode(params) request = urllib.request.Request(url, headers={"Accept": "application/json"}) try: with urllib.request.urlopen(request, timeout=30) as response: data = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError) as exc: return "error: search failed: " + short_error(exc) lines = [] for pos, item in enumerate(data.get("results") or [], 1): lines.append("%d. %s\n %s\n %s" % (pos, item.get("title"), item.get("url"), (item.get("description") or "")[:400])) if item.get("content"): lines.append(" content: " + truncate(str(item["content"]), 2000, 500)) self.result_note = "%d results" % len(data.get("results") or []) return "\n".join(lines) if lines else "no results" def run_describe_image(self, args): source = str(args.get("source") or "").strip() if not source: return "error: empty source" if source.startswith(("http://", "https://")): url = RSEARCH_BASE + "/describe?" + urllib.parse.urlencode({"url": source}) request = urllib.request.Request(url, headers={"Accept": "application/json"}) else: path = os.path.expanduser(source) try: if os.path.getsize(path) > 10 * 1024 * 1024: return "error: image larger than 10 MB" with open(path, "rb") as handle: body = handle.read() except OSError as exc: return "error: " + short_error(exc) request = urllib.request.Request(RSEARCH_BASE + "/describe", data=body, headers={"Accept": "application/json", "Content-Type": "application/octet-stream"}, method="POST") try: with urllib.request.urlopen(request, timeout=60) as response: data = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError) as exc: return "error: describe failed: " + short_error(exc) text = str(data.get("description") or data.get("error") or "no description") self.result_note = text return text def run_web_fetch(self, args): url = str(args.get("url") or "").strip() if not url.startswith(("http://", "https://")): return "error: url must start with http:// or https://" method = str(args.get("method") or "GET").strip().upper() if method not in FETCH_METHODS: return "error: method must be one of %s" % ", ".join(FETCH_METHODS) headers = {"User-Agent": "tai/%s" % VERSION} custom = args.get("headers") or {} if not isinstance(custom, dict) or any(not isinstance(name, str) or not isinstance(value, str) for name, value in custom.items()): return "error: headers must be an object of string names to string values" for name, value in custom.items(): if not re.fullmatch(r"[A-Za-z0-9-]+", name.strip()) or "\n" in value or "\r" in value: return "error: invalid header '%s'" % name[:40] headers[name.strip()] = value data = None if args.get("body") is not None: if not isinstance(args.get("body"), str): return "error: body must be a string" data = args.get("body").encode("utf-8") try: timeout = int(args.get("timeout") or 30) except (TypeError, ValueError): return "error: invalid timeout" timeout = max(1, min(timeout, 120)) auth_name = str(args.get("auth_secret") or "").strip() if auth_name: value = self.app.store.load_secret(auth_name, self.active_profile) if value is None: return "error: unknown secret '%s'" % auth_name header = str(args.get("auth_header") or "Authorization").strip() or "Authorization" if not re.fullmatch(r"[A-Za-z0-9-]+", header): return "error: invalid auth header name" prefix = args.get("auth_prefix") if args.get("auth_prefix") is not None else "Bearer " prefix = str(prefix) if "\n" in prefix or "\r" in prefix: return "error: invalid auth prefix" host = urllib.parse.urlparse(url).hostname or "" if not self.secret_grant("web_fetch", host, [auth_name]): return "denied by user" headers[header] = prefix + value request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=timeout) as response: status = getattr(response, "status", 200) fields = getattr(response, "headers", None) content_type = fields.get("Content-Type", "") if fields is not None else "" payload = response.read(200000) except urllib.error.HTTPError as exc: try: detail = exc.read(2000).decode("utf-8", "replace").strip() except OSError: detail = "" if detail: return "error: HTTP %d: %s" % (exc.code, truncate(detail, 1000, 300)) return "error: HTTP %d: %s" % (exc.code, short_error(exc)) except OSError as exc: return "error: fetch failed: " + short_error(exc) text = payload.decode("utf-8", "replace") if not args.get("raw") and ("html" in content_type or not content_type): stripped = re.sub(r"(?s)|", " ", text) stripped = re.sub(r"<[^>]+>", " ", stripped) text = re.sub(r"\s+", " ", html.unescape(stripped)).strip() text = truncate(text.strip(), 5000, 1000) or "empty page" head = "HTTP %d · %s · %s" % (status, content_type.split(";")[0].strip() or "unknown", human_size(len(payload))) return "%s\n%s" % (head, text) def run_speak(self, args): text = str(args.get("text") or "").strip() if not text: return "error: empty text" voice = str(args.get("voice") or self.app.config.voice) try: audio = edge_synthesize(text[:8000], voice) except (BackendError, OSError) as exc: return "error: speech failed: " + short_error(exc) if not audio: return "error: speech returned no audio" path = os.path.join(self.app.config.audio_dir, "speech-%d.mp3" % int(time.time())) try: with open(path, "wb") as handle: handle.write(audio) except OSError as exc: return "error: " + short_error(exc) return "saved %s (%d bytes), played: %s" % (path, len(audio), play_audio(path)) def run_listen(self, args): try: seconds = max(1, min(30, int(args.get("seconds") or 5))) except (TypeError, ValueError): seconds = 5 return listen_audio(seconds, self.app.config.audio_dir) def run_remember(self, args): instruction = str(args.get("instruction") or "").strip() if not instruction: return "error: empty instruction" current = self.app.system_message prompt = [ {"role": "system", "content": MERGE_SYSTEM}, {"role": "user", "content": "CURRENT SYSTEM PROMPT:\n" + current + "\n\nMEMORY INSTRUCTION:\n" + instruction}, ] spinner = Spinner("merging memory") spinner.start() try: merged = self.app.chat.complete(prompt, ephemeral=True) except BackendError as exc: return "error: memory merge failed: " + short_error(exc) finally: spinner.stop() text = merged["content"].strip() text = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", text).strip() if len(text) < 20: return "error: merged system message too short, rejected" if len(text) > SYSTEM_MAX_CHARS: return "error: merged system message exceeds %d chars, rejected" % SYSTEM_MAX_CHARS self.app.update_system(text) return "system message updated for profile '%s' (%d to %d chars)" % (self.app.profile, len(current), len(text)) def run_recall(self, args): query = str(args.get("query") or "").strip() if not query: return "error: empty query" tags = args.get("tags") or [] if not isinstance(tags, list) or any(not isinstance(item, str) for item in tags): return "error: tags must be a list of names" rows = self.app.store.search_events(self.active_profile, query, tags=tags) if not rows: return "no memories matching " + query return "\n".join("[%s %s/%s] %s" % (stamp, role, kind, text[:300]) for stamp, role, kind, text in rows) def box_edit(self, path, find, replace): if not path: return "error: empty path" if not find: return "error: empty match" if ("box", path) not in self.read_files: return "error: edit denied for '%s', read it first with read_file" % path size, content = self.box_read_raw(path) if size is None: return content if size > 200000: return "error: file too large to edit safely (%d bytes)" % size if content.count(find) != 1: return "error: match is not unique (%d occurrences)" % content.count(find) written = self.box_write(path, content.replace(find, replace), old=content, action="edit") if written.startswith("wrote "): return "edited %s%s" % (path, written[len("wrote " + path):]) return written def run_load_skill(self, args): name = str(args.get("name") or "").strip() skill = self.app.skills.get(name) if skill is None: blueprint = SKILL_BLUEPRINTS.get(name) if blueprint is None: known = ", ".join(sorted(self.app.skills)) or "none" return "error: unknown skill, known: " + known return "building skill '%s' from blueprint, this runs deep research and takes a while:\n%s" % (name, self.run_create_skill({"name": name, "brief": blueprint["brief"], "scope": blueprint["scope"]})) parts = [skill["body"].strip(), skill_provenance_note(skill)] extras = skill_extra_files(skill["root"]) if extras: parts.append("bundled files:\n" + "\n".join(extras)) return "\n\n".join(parts) def run_terminal_content(self, args): try: lines = max(10, min(500, int(args.get("lines") or 100))) except (TypeError, ValueError): lines = 100 target = str(args.get("target") or "").strip()[:128] if not shutil.which("tmux"): return "tmux not available" history = shell_history_section() if target: header = ["pane %s" % target] else: pane_header = tmux_pane_header() header = [pane_header] if pane_header else [] argv = ["tmux", "capture-pane", "-p", "-S", "-%d" % lines] if target: argv += ["-t", target] try: done = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError) as exc: return "terminal capture failed: " + short_error(exc) + history if done.returncode != 0: return "terminal capture failed: " + (done.stderr or "").strip()[:200] + history if not done.stdout.strip(): return "terminal pane is empty" + history return "\n".join(header + [truncate(done.stdout, 6000, 2000)]) + history def run_list_terminals(self, args): panes, error = tmux_panes() if error: return error if not panes: return "no open terminals" sessions = len({pane["ref"].split(":")[0] for pane in panes}) windows = len({pane["ref"].rsplit(".", 1)[0] for pane in panes}) lines = ["%d session%s · %d window%s · %d pane%s" % ( sessions, "" if sessions == 1 else "s", windows, "" if windows == 1 else "s", len(panes), "" if len(panes) == 1 else "s")] for pane in panes[:TERMINAL_LIST_MAX_PANES]: lines.append("%s %s %s %s%s" % (pane["ref"], pane["id"], pane["command"], pane["path"], " *" if pane["active"] else "")) if len(panes) > TERMINAL_LIST_MAX_PANES: lines.append("... +%d more" % (len(panes) - TERMINAL_LIST_MAX_PANES)) return "\n".join(lines) def run_tail_terminals(self, args): try: lines = max(1, min(TAIL_TERMINALS_MAX_LINES, int(args.get("lines") or TAIL_TERMINALS_DEFAULT_LINES))) except (TypeError, ValueError): lines = TAIL_TERMINALS_DEFAULT_LINES panes, error = tmux_panes() if error: return error if not panes: return "no open terminals" blocks = [] for pane in panes[:TAIL_TERMINALS_MAX_PANES]: try: done = subprocess.run(["tmux", "capture-pane", "-p", "-t", pane["id"], "-S", "-%d" % lines], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError): blocks.append("=== %s %s (%s) ===\n(capture failed)" % (pane["ref"], pane["id"], pane["command"])) continue if done.returncode != 0 or not done.stdout.strip(): blocks.append("=== %s %s (%s) ===\n(empty)" % (pane["ref"], pane["id"], pane["command"])) continue blocks.append("=== %s %s (%s) ===\n%s" % (pane["ref"], pane["id"], pane["command"], done.stdout.strip()[:TAIL_PER_PANE_CHARS])) if len(panes) > TAIL_TERMINALS_MAX_PANES: blocks.append("... +%d more panes, capture one with get_current_terminal_content" % (len(panes) - TAIL_TERMINALS_MAX_PANES)) return truncate("\n\n".join(blocks), 6000, 2000) def run_export_terminal_log(self, args): if not os.environ.get("TMUX"): return "error: tmux is not active (this tool only works inside a live tmux session)" if not shutil.which("tmux"): return "error: tmux not available" extra_tags = args.get("tags") or [] if not isinstance(extra_tags, list) or any(not isinstance(item, str) for item in extra_tags): return "error: tags must be a list of strings" try: done = subprocess.run(["tmux", "capture-pane", "-p", "-S", "-"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: tmux capture failed: " + (done.stderr or "").strip()[:200] raw = done.stdout if not raw.strip(): return "terminal pane history is empty" profile = self.active_profile pane_header = tmux_pane_header() or "pane" stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", pane_header).strip("-") or "pane" folder = os.path.join(self.app.config.home, "termlogs", profile) try: os.makedirs(folder, exist_ok=True) path = os.path.join(folder, "%s-%s.log" % (slug, stamp)) with open(path, "w", encoding="utf-8") as handle: handle.write(raw) except OSError as exc: return "error: " + short_error(exc) if self.app.store is None: return "captured %s to %s (%d bytes); vault unavailable, not indexed for search" % (pane_header, path, len(raw.encode("utf-8"))) title = "tmux %s captured %s" % (pane_header, stamp) record_id = self.app.store.add_record("termlog", title, raw, ["termlog"] + extra_tags, profile) chunks = chunk_lines(collapse_repeated_lines(raw)) self.app.store.add_chunks("record", record_id, chunks, profile) return "captured %s to %s (%d bytes), indexed as %s in %d chunks -- use search (kind chunk) to answer questions about this terminal session" % (pane_header, path, len(raw.encode("utf-8")), record_id, len(chunks)) def run_plexer_send(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" text = str(args.get("text") or "") try: controller.send_keys(text, enter=bool(args.get("enter"))) except PlexerError as exc: return "error: " + str(exc) return "sent %d chars via %s backend%s" % (len(text), controller.kind, " + enter" if args.get("enter") else "") def run_plexer_read(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" try: lines = max(1, min(int(args.get("lines") or 200), 5000)) except (TypeError, ValueError): lines = 200 try: content = controller.read_pane(lines) except PlexerError as exc: return "error: " + str(exc) return truncate(content or "(empty)", 6000, 2000) def run_plexer_type(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" text = str(args.get("text") or "") if not text: return "error: empty text" try: delay = max(0.0, min(float(args.get("delay") or 0.03), 2.0)) except (TypeError, ValueError): delay = 0.03 try: controller.type_text(text, delay=delay, enter=args.get("enter") is not False) except PlexerError as exc: return "error: " + str(exc) return "typed %d chars via %s backend at %.2fs/char" % (len(text), controller.kind, delay) def run_tui(self, op, args): """Drive the running grr TUI through its control socket (see GrrControl).""" payload = {"op": op} payload.update({k: v for k, v in args.items() if v is not None}) if op == "screen" and args.get("until"): payload["wait"] = (args.get("timeout") or GRR_CONTROL_WAIT) + 2 return json.dumps(grr_control_call(payload)) def run_plexer_status(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "no plexer session active (neither tai's native wrapper nor tmux)" try: data = controller.status() except PlexerError as exc: return "error: " + str(exc) return json.dumps(data) def run_fork(self, args): task = str(args.get("task") or "").strip() if not task: return "error: empty task" if self.app.depth >= FORK_MAX_DEPTH: return "error: fork depth limit reached" try: timeout = max(30, min(3600, int(args.get("timeout") or 600))) except (TypeError, ValueError): timeout = 600 asked = str(args.get("profile") or "").strip() if asked and not PROFILE_RE.match(asked): return "error: invalid profile name" if asked and asked != self.active_profile: return "error: cannot fork as another profile" profile = self.active_profile agent_id = spawn_agent(task, profile, timeout, self.app.config, self.app.store.seal, self.app.depth + 1, self.app.runner_override) return "agent %d started: %s. Collect its result with poll." % (agent_id, task[:80]) def run_poll(self, args): try: agent_id = int(args.get("id") or 0) except (TypeError, ValueError): return "error: invalid agent id" try: wait = max(0, min(120, int(args.get("wait") or 0))) except (TypeError, ValueError): wait = 0 status, text = poll_agent(agent_id, 0, self.active_profile) if status == "running" and wait > 0: board = LaneBoard(self.active_profile) if LaneBoard.available(self.app) else None if board is not None: board.start() try: status, text = poll_agent(agent_id, wait, self.active_profile) finally: if board is not None: board.stop() self.board_shown = True if status in ("done", "timeout", "error"): with AGENTS_LOCK: if agent_id in AGENTS: AGENTS[agent_id]["collected"] = True if status in ("done", "timeout") and self.app.store is not None: with AGENTS_LOCK: record = dict(AGENTS.get(agent_id) or {}) full = record.get("result") or "" if len(full) > SPILL_LIMIT: spilled = record.get("spilled") if not spilled: task = str(record.get("task") or "")[:80] spilled = self.app.store.add_record("output", "agent %d: %s" % (agent_id, task), full, ["agent-output"], self.active_profile) self.app.store.add_chunks("record", spilled, chunk_lines(full), self.active_profile) with AGENTS_LOCK: if agent_id in AGENTS: AGENTS[agent_id]["spilled"] = spilled return "[%s] %s" % (status, spilled_view(full, spilled, len(full))) return "[%s] %s" % (status, text) def run_ask_user(self, args): question = str(args.get("question") or "").strip() if not question: return "error: question must be a non-empty string" raw_options = args.get("options") or [] if not isinstance(raw_options, list): return "error: options must be a list of strings" options = [] for item in raw_options[:ASK_USER_MAX_OPTIONS]: if isinstance(item, dict): label = item.get("label", item.get("value", "")) else: label = item label = str(label or "").strip().replace("\n", " ") if label: options.append(label[:200]) if getattr(self.app, "auto", False): return "ask_user unavailable in autonomous mode: decide yourself and state the assumption" if not getattr(self.app, "persist", True) or getattr(self.app, "depth", 0) > 0: return "ask_user unavailable to background workers: decide yourself and state the assumption" prompter = getattr(self.app, "prompter", None) if prompter is not None: raw = prompter("ask", question, options) if raw is None: return "user gave no answer (input closed): proceed with your best assumption" else: if not sys.stdin.isatty(): return "error: no interactive user on this stdin, decide yourself and state the assumption" print(paint("?- " + question, Ansi.CYAN, Ansi.BOLD)) for pos, label in enumerate(options, 1): print(paint(" %d) %s" % (pos, label), Ansi.DIM)) try: if options: raw = input(paint("answer [1-%d, option text, or free input] " % len(options), Ansi.YELLOW)) else: raw = input(paint("answer (free input, empty skips) ", Ansi.YELLOW)) except (EOFError, KeyboardInterrupt): return "user gave no answer (input closed): proceed with your best assumption" self.app.user_stepped_in = True answer = raw.strip() if not answer: return "user gave no answer (empty): proceed with your best assumption" if len(answer) > ASK_USER_MAX_ANSWER: answer = answer[:ASK_USER_MAX_ANSWER] + "...[truncated]" if options: if answer.isdigit(): pos = int(answer) if 1 <= pos <= len(options): return "user chose option %d: %s" % (pos, options[pos - 1]) lowered = answer.lower() for pos, label in enumerate(options, 1): if label.lower() == lowered: return "user chose option %d: %s" % (pos, label) return "user answered: %s" % answer def run_sysinfo(self, args): names = args.get("checks") if names is None: return collect_sysinfo() if not isinstance(names, list) or not names: return "error: checks must be a non-empty list" valid = [name for name, _func in SYSINFO_CHECKS] unknown = [str(item) for item in names if str(item) not in valid] if unknown: return "error: unknown checks: %s (valid: %s)" % (", ".join(unknown), ", ".join(valid)) return collect_sysinfo([str(item) for item in names]) def run_create_skill(self, args): name = str(args.get("name") or "").strip() brief = str(args.get("brief") or "").strip() if not name or not SKILL_NAME_RE.match(name): return "error: invalid skill name, use lowercase letters, digits, and hyphens" if not brief: return "error: empty brief" if self.app.depth >= FORK_MAX_DEPTH: return "error: create_skill depth limit reached" scope = str(args.get("scope") or "project").strip().lower() if scope not in ("project", "home"): return "error: scope must be project or home" try: timeout = max(60, min(3600, int(args.get("timeout") or 1200))) except (TypeError, ValueError): timeout = 1200 skill_dir = skill_scope_dir(scope, self.app.config.home, os.getcwd(), name) skill_file = os.path.join(skill_dir, "SKILL.md") existed = os.path.isfile(skill_file) spinner = Spinner("creating skill %s" % name) spinner.start() try: if self.app.runner_override is None: result = default_runner(self.app.config, self.app.store.seal, self.app.profile, self.app.depth + 1, create_skill_prompt(name, brief, skill_dir), timeout, CREATE_SKILL_STEPS) else: result = self.app.runner_override(create_skill_prompt(name, brief, skill_dir), self.app.profile, timeout) finally: spinner.stop() if os.path.isfile(skill_file): self.app.skills = discover_skills(self.app.config.home, os.getcwd()) self.app.apply_system() action = "replaced" if existed else "created" return "skill '%s' %s at %s\n%s" % (name, action, skill_file, truncate(result, 2000, 500)) status = "timeout" if "[time limit reached]" in result else "done" return "skill '%s' was not created (%s), worker output:\n%s" % (name, status, truncate(result, 3000, 1000)) @staticmethod def find_skill_root(base): """A source dir qualifies if it's SKILL.md itself, or has exactly one child that is.""" if os.path.isfile(os.path.join(base, "SKILL.md")): return base try: entries = sorted(os.listdir(base)) except OSError: return None candidates = [entry for entry in entries if os.path.isfile(os.path.join(base, entry, "SKILL.md"))] return os.path.join(base, candidates[0]) if len(candidates) == 1 else None def resolve_skill_source(self, source, workdir): """Returns ((path, ephemeral), '') on success, (None, 'error: ...') otherwise. path is a directory containing SKILL.md, or a bare SKILL.md file. ephemeral means the path lives under workdir (git clone / zip extraction) and symlinking to it would dangle once workdir is cleaned up, so install must copy it. """ if source.endswith(".git") or source.startswith("git@") or source.startswith("git+"): git_url = source[4:] if source.startswith("git+") else source if not shutil.which("git"): return None, "error: git is not installed, supply a local path or .zip instead" clone_dir = os.path.join(workdir, "clone") try: done = subprocess.run(["git", "clone", "--depth", "1", git_url, clone_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120) except (OSError, subprocess.SubprocessError) as exc: return None, "error: git clone failed: " + short_error(exc) if done.returncode != 0: return None, "error: git clone failed: " + (done.stderr or "").strip()[:300] found = self.find_skill_root(clone_dir) if found is None: return None, "error: no SKILL.md found in " + git_url return (found, True), "" path = os.path.expanduser(source) if not os.path.exists(path): return None, "error: no such file or directory: " + source if os.path.isfile(path) and path.endswith(".zip"): extract_dir = os.path.join(workdir, "zip") try: with zipfile.ZipFile(path) as archive: archive.extractall(extract_dir) except (OSError, zipfile.BadZipFile) as exc: return None, "error: bad zip file: " + short_error(exc) found = self.find_skill_root(extract_dir) if found is None: return None, "error: no SKILL.md found in " + source return (found, True), "" if os.path.isfile(path) and path.endswith(".md"): return (path, False), "" if os.path.isdir(path): found = self.find_skill_root(path) if found is None: return None, "error: no SKILL.md found under " + source return (found, False), "" return None, "error: source must be a directory, a SKILL.md file, a .zip, or a git URL" def run_install_skill(self, args): source = str(args.get("source") or "").strip() if not source: return "error: empty source" scope = str(args.get("scope") or "").strip().lower() or default_skill_scope(os.getcwd()) if scope not in ("project", "home"): return "error: scope must be project or home" mode = str(args.get("mode") or "").strip().lower() or None if mode is not None and mode not in ("copy", "symlink"): return "error: mode must be copy or symlink" force = bool(args.get("force")) workdir = tempfile.mkdtemp(prefix="tai-skill-") try: spinner = Spinner("installing skill from %s" % source) spinner.start() try: resolved, failure = self.resolve_skill_source(source, workdir) finally: spinner.stop() if failure: return failure real_source, ephemeral = resolved source_dir = real_source if os.path.isdir(real_source) else None skill_file = os.path.join(real_source, "SKILL.md") if source_dir else real_source try: skill = parse_skill_file(skill_file) except OSError as exc: return "error: " + short_error(exc) if skill is None: return "error: %s has no valid SKILL.md (needs name, description, and a name of lowercase letters, digits, hyphens)" % source name = str(args.get("name") or "").strip() or skill["name"] if not SKILL_NAME_RE.match(name): return "error: invalid skill name, use lowercase letters, digits, and hyphens" if mode is None: mode = "symlink" if (source_dir and not ephemeral and is_external_skill_root(source_dir)) else "copy" elif mode == "symlink" and (ephemeral or source_dir is None): return "error: symlink mode needs a persistent source directory, not a git/zip source or a bare SKILL.md file" scope_dir = skill_scope_dir(scope, self.app.config.home, os.getcwd()) dest = os.path.join(scope_dir, name) existed = os.path.islink(dest) or os.path.exists(dest) if existed and not force and not self.app.ask_approval("overwrite existing skill '%s' at %s" % (name, dest)): return "denied by user" if existed: if os.path.islink(dest) or os.path.isfile(dest): os.remove(dest) else: shutil.rmtree(dest) os.makedirs(scope_dir, exist_ok=True) if mode == "symlink": os.symlink(os.path.abspath(source_dir), dest) elif source_dir is not None: shutil.copytree(source_dir, dest) else: os.makedirs(dest) shutil.copy2(real_source, os.path.join(dest, "SKILL.md")) save_skill_install_meta(scope_dir, name, {"source": source, "mode": mode, "scope": scope, "installed_at": datetime.now(timezone.utc).isoformat()}) if self.app.store is not None: try: self.app.store.audit_event(self.audit_actor(), "skill-install", dest, "installed skill '%s' from %s (%s, %s scope)" % (name, source, mode, scope), tags=["skill", "install"]) except (sqlite3.Error, OSError): pass self.app.skills = discover_skills(self.app.config.home, os.getcwd()) self.app.apply_system() extras = skill_extra_files(dest) tail = "\nbundled files: %s" % ", ".join(extras) if extras else "" replaced = " (replacing the previous install)" if existed else "" return "skill '%s' installed (%s, %s scope) at %s from %s%s\n%s%s" % (name, mode, scope, dest, source, replaced, skill["description"], tail) finally: shutil.rmtree(workdir, ignore_errors=True) def run_store_secret(self, args): name = str(args.get("name") or "").strip() value = str(args.get("value") or "") if not SECRET_RE.match(name): return "error: invalid secret name, use letters, digits, dot, underscore, hyphen" if not value: return "error: empty value" username = str(args.get("username") or "").strip()[:128] host = str(args.get("host") or "").strip()[:255] port = 0 if args.get("port") is not None and str(args.get("port")).strip() != "": try: port = int(args.get("port")) except (TypeError, ValueError): return "error: port must be a number" if not 1 <= port <= 65535: return "error: port must be 1-65535" notes = str(args.get("notes") or "").strip()[:2000] expires = str(args.get("expires") or "").strip() if expires: try: expires = parse_schedule_at(expires) except ValueError as exc: return "error: " + short_error(exc) raw_tags = args.get("tags") or [] if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags): return "error: tags must be a list of names" tags = normalize_tags(raw_tags) meta = {} if username: meta["username"] = username if host: meta["host"] = host if port: meta["port"] = port if notes: meta["notes"] = notes if expires: meta["expires"] = expires self.app.store.save_secret(name, value, meta, tags, self.active_profile) return "stored secret '%s' (sealed; tags: %s)" % (name, ", ".join(["secret"] + tags)) def run_list_secrets(self, args): infos = self.app.store.list_secret_infos(self.active_profile) if not infos: return "secrets: none" now = datetime.now(timezone.utc).isoformat() lines = [] for info in infos: meta = info["meta"] parts = ["- " + info["name"]] if info["tags"]: parts.append("[%s]" % " ".join(info["tags"])) who = "" if meta.get("username") and meta.get("host"): who = "%s@%s" % (meta["username"], meta["host"]) elif meta.get("host"): who = str(meta["host"]) elif meta.get("username"): who = str(meta["username"]) if who and meta.get("port"): who += ":%s" % meta["port"] if who: parts.append(who) if meta.get("notes"): parts.append(str(meta["notes"])[:60]) if meta.get("expires"): parts.append("(expired)" if meta["expires"] <= now else "(expires %s)" % local_display(meta["expires"])) lines.append(" ".join(parts)) return "\n".join(lines) def run_delete_secret(self, args): name = str(args.get("name") or "").strip() if self.app.store.load_secret(name, self.active_profile) is None: return "error: unknown secret '%s'" % name if not self.app.ask_approval("delete secret '%s'" % name): return "denied by user" self.app.store.delete_secret(name, self.active_profile) return "deleted secret '%s'" % name def run_schedule(self, args): prompt = str(args.get("prompt") or "").strip() if not prompt: return "error: empty prompt" at_raw = args.get("at") every_raw = args.get("every") if (at_raw is None) == (every_raw is None): return "error: pass exactly one of at or every" if at_raw is not None: try: next_run = parse_schedule_at(str(at_raw)) except ValueError as exc: return "error: " + short_error(exc) every_sec = 0 else: try: every_sec = int(every_raw) except (TypeError, ValueError): return "error: every must be seconds" if every_sec < 60: return "error: every must be at least 60 seconds" next_run = (datetime.now(timezone.utc) + timedelta(seconds=every_sec)).isoformat() name = str(args.get("name") or "").strip()[:80] try: timeout = max(60, min(3600, int(args.get("timeout") or 600))) except (TypeError, ValueError): timeout = 600 asked = str(args.get("profile") or "").strip() if asked and not PROFILE_RE.match(asked): return "error: invalid profile name" if asked and asked != self.active_profile: return "error: cannot schedule for another profile" profile = self.active_profile raw_tags = args.get("tags") or [] if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags): return "error: tags must be a list of names" if not self.app.ask_approval("schedule '%s'" % (name or prompt[:60])): return "denied by user" row_id = self.app.store.add_schedule(name, prompt, profile, every_sec, next_run, timeout, normalize_tags(raw_tags)) when = "every %s" % format_delay(every_sec) if every_sec else "at %s" % local_display(next_run) return "scheduled #%d (%s)" % (row_id, when) def run_unschedule(self, args): try: row_id = int(args.get("id") or 0) except (TypeError, ValueError): return "error: invalid schedule id" if row_id not in [item["id"] for item in self.app.store.list_schedules(self.active_profile)]: return "error: no schedule #%d" % row_id if not self.app.ask_approval("delete schedule #%d" % row_id): return "denied by user" self.app.store.remove_schedule(row_id, self.active_profile) return "deleted schedule #%d" % row_id def run_schedules(self, args): return format_schedule_lines(self.app.store.list_schedules(self.active_profile), datetime.now(timezone.utc)) def record_tag_args(self, args): raw_tags = args.get("tags") or [] if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags): return None return normalize_tags(raw_tags) def run_record_save(self, args): content = str(args.get("content") or "") if not content: return "error: content is empty" title = str(args.get("title") or "").strip()[:200] or "(untitled)" kind = str(args.get("kind") or "note").strip().lower() or "note" if kind not in RECORD_KINDS: return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(RECORD_KINDS)) tags = self.record_tag_args(args) if tags is None: return "error: tags must be a list of names" record_id = self.app.store.add_record(kind, title, content, tags, self.active_profile) chunks = chunk_lines(content) self.app.store.add_chunks("record", record_id, chunks, self.active_profile) saved = "saved %s (%d chars, kind %s, %d chunks, tags: %s)" % (record_id, len(content), kind, len(chunks), ", ".join(self.app.store.item_tags(record_id, self.active_profile))) links = sorted(edge["other"] for edge in self.app.store.edges_for(record_id, self.active_profile) if edge["direction"] == "out") if links: saved += " [linked: %s]" % ", ".join(links) return saved def run_create_bot(self, args): name = str(args.get("name") or "").strip().lower() if not BOT_RE.match(name): return "error: invalid bot name (lowercase letters, digits, - and _)" if name == "main": return "error: 'main' is the default bot" description = str(args.get("description") or "").strip() rules = str(args.get("rules") or "").strip() behavior = str(args.get("behavior") or "").strip() if not description and not rules and not behavior: return "error: give at least one of description, rules, or behavior" raw_nicks = args.get("nicknames") or [] if not isinstance(raw_nicks, list) or any(not isinstance(item, str) for item in raw_nicks): return "error: nicknames must be a list of names" profile = self.active_profile taken = {"main"} for item in self.app.store.list_bots(profile): taken.add(item["name"]) taken.update(item["nicknames"]) short = re.split(r"[-_]", name, 1)[0] nicks = [] for cand in list(raw_nicks) + [short]: clean = cand.strip().lower() if clean and clean != name and BOT_RE.match(clean) and clean not in nicks: nicks.append(clean) for cand in list(raw_nicks): clean = cand.strip().lower() if clean and not BOT_RE.match(clean): return "error: invalid nickname '%s'" % cand.strip() if name in taken: return "error: bot name '%s' is taken" % name clashes = [nick for nick in nicks if nick in taken] if clashes: return "error: nickname '%s' is taken" % clashes[0] parts = [] if description: parts.append(description) if rules: parts.append("Rules:\n" + rules) if behavior: parts.append("Behavior:\n" + behavior) self.app.store.save_bot_system(profile, name, "\n\n".join(parts)) bots = self.app.store.load_bots(profile) stamp = now_iso() bots[name] = {"nicknames": nicks, "created": stamp, "updated": stamp} self.app.store.save_bots(profile, bots) extra = " (nicknames: %s)" % ", ".join(nicks) if nicks else "" return "created bot '%s'%s. Switch with /bot %s or mention @%s." % (name, extra, name, name) def run_install(self, args): action = str(args.get("action") or "").strip().lower() if action not in ("status", "install", "upgrade", "reinstall", "uninstall"): return "error: action must be status, install, upgrade, reinstall, or uninstall" targets = args.get("targets") or list(INSTALL_TARGETS) if not isinstance(targets, list) or not targets or any(item not in INSTALL_TARGETS for item in targets): return "error: unknown target, valid: %s" % ", ".join(INSTALL_TARGETS) if action != "status" and not self.app.ask_approval("install %s: %s (vault always kept)" % (action, ", ".join(targets))): return "denied by user" return install_report(action, targets, self.app.config.home, self.app.store) def run_search(self, args): query = str(args.get("query") or "").strip() if not query: return "error: empty query" kinds = args.get("kinds") or ["record", "event", "audit", "chunk"] if not isinstance(kinds, list) or not kinds or any(item not in ("record", "event", "audit", "chunk") for item in kinds): return "error: kinds must be a list of record, event, audit, chunk" try: limit = int(args.get("limit") or 10) except (TypeError, ValueError): return "error: invalid limit" expand = bool(args.get("expand")) profile = self.active_profile hits = self.app.store.fts_search(query, kinds, profile, limit) if self.app.store.seal.enabled and "event" in kinds and not any(hit["kind"] == "event" for hit in hits): for ts, _role, kind, text in self.app.store.events_like(profile, query, limit): hits.append({"kind": "event", "item": "event %s" % ts, "sub": kind, "title": text[:60], "snippet": text[:160], "rank": 99.0}) hits.sort(key=lambda hit: hit["rank"]) if not hits: return "no matches for '%s'" % query[:80] chunk_ids = [] for hit in hits: if hit["kind"] == "chunk": try: chunk_ids.append(int(str(hit["item"]).split(":", 1)[1])) except (IndexError, ValueError): pass chunk_rows = self.app.store.get_chunks_by_ids(chunk_ids, profile) if chunk_ids else {} lines = [] for hit in hits: if hit["kind"] == "record": lines.append("[record/%s] %s :: %s -- %s" % (hit["sub"], hit["item"], hit["title"][:80], hit["snippet"])) if expand: neighbors = [] for edge in self.app.store.edges_for(hit["item"], profile): if len(neighbors) < 3: neighbors.append("%s (%s)" % (self.app.store.node_title(edge["other"], profile)[:60], edge["relation"])) if neighbors: lines.append(" linked: %s" % "; ".join(neighbors)) elif hit["kind"] == "event": lines.append("[event/%s] #%s %s -- %s" % (hit["sub"], hit["item"], hit["title"][:60], hit["snippet"])) elif hit["kind"] == "chunk": try: chunk_id = int(str(hit["item"]).split(":", 1)[1]) except (IndexError, ValueError): chunk_id = None chunk = chunk_rows.get(chunk_id) if chunk is None: lines.append("[chunk/%s] %s -- %s" % (hit["sub"], hit["title"][:80], hit["snippet"])) else: preview = chunk["text"][:500] + ("..." if len(chunk["text"]) > 500 else "") lines.append("[chunk/%s] %s#%d (parent %s, read full with record_read) -- %s" % (chunk["parent_kind"], chunk["parent_id"][:60], chunk["idx"], chunk["parent_id"], preview)) else: lines.append("[audit/%s] #%s %s -- %s" % (hit["sub"], str(hit["item"]).split(":", 1)[1], hit["title"][:80], hit["snippet"])) return "\n".join(lines) def run_tags(self, args): prefix = str(args.get("prefix") or "").strip() try: limit = int(args.get("limit") or 50) except (TypeError, ValueError): return "error: invalid limit" counts = self.app.store.tag_counts(prefix, limit, self.active_profile) if not counts: return "no tags yet" lines = ["%s (%d)" % (tag, count) for tag, count in counts] return "%d tags:\n%s" % (len(counts), "\n".join(lines)) def run_record_read(self, args): record_id = str(args.get("id") or "").strip() try: offset = max(0, int(args.get("offset") or 0)) except (TypeError, ValueError): return "error: invalid offset" try: limit = int(args.get("limit") or 4000) except (TypeError, ValueError): return "error: invalid limit" page = self.app.store.read_record(record_id, offset, max(1, min(limit, 20000)), self.active_profile) if page is None: return "error: unknown record " + record_id head = "%s chars %d-%d of %d :: %s" % (record_id, page["start"], page["end"], page["total"], self.app.store.node_title(record_id, self.active_profile)) if page["tags"]: head += " [%s]" % ", ".join(page["tags"]) return head + "\n" + page["slice"] def run_record_search(self, args): query = str(args.get("query") or "") kind = str(args.get("kind") or "").strip().lower() or None if kind is not None and kind not in RECORD_KINDS: return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(RECORD_KINDS)) tags = self.record_tag_args(args) if tags is None: return "error: tags must be a list of names" try: limit = int(args.get("limit") or 8) except (TypeError, ValueError): return "error: invalid limit" found = self.app.store.search_records(query, kind, tags, limit, self.active_profile) if not found: return "no records match" lines = [] for item in found: lines.append("%s [%s] %s (%d chars, %d reads) tags: %s" % (item["id"], item["kind"], item["title"], item["size"], item["reads"], ", ".join(item["tags"]))) return "\n".join(lines) def run_record_delete(self, args): record_id = str(args.get("id") or "").strip() if not record_id: return "error: record id is required" if not self.app.ask_approval("delete record %s" % record_id): return "denied by user" if self.app.store.delete_record(record_id, self.active_profile): return "deleted record " + record_id return "error: unknown record " + record_id def run_graph_link(self, args): src = str(args.get("src") or "").strip() dst = str(args.get("dst") or "").strip() if not src or not dst: return "error: src and dst are required" try: relation = self.app.store.add_edge(src, dst, args.get("relation"), self.active_profile) except ValueError as exc: return "error: " + str(exc) return "linked %s -[%s]-> %s" % (src, relation, dst) def run_delete_file(self, args): path = str(args.get("path") or "") if not path: return "error: empty path" if self.app.env == "sandbox": return self.box_delete(path) if not os.path.isfile(path): return "error: no such file" if ("home", os.path.abspath(path)) not in self.read_files: return "error: delete denied for '%s', read it first with read_file" % path if not self.app.ask_approval("delete file %s" % path): return "denied by user" old, old_size = read_capped(path) try: os.remove(path) except OSError as exc: return "error: " + short_error(exc) self.read_files.discard(("home", os.path.abspath(path))) audited = self.audit_file("delete", os.path.abspath(path), old, None, "", (), old_size, None) note = "" if audited is not False else " (audit failed)" if old_size is None: self.note_diff(path, old, None) return "deleted %s%s" % (path, note) def box_delete(self, path): engine, failure = self.box_engine() if not engine: return failure if ("box", path) not in self.read_files: try: probe = box_exec(engine, ["test", "-f", path], timeout=30) except (OSError, subprocess.SubprocessError): probe = None if probe is None or probe.returncode != 0: return "error: no such file" return "error: delete denied for '%s', read it first with read_file" % path old, old_size = self.box_old_image(path) if old is None: return "error: no such file" try: done = box_exec(engine, ["rm", "-f", path], timeout=60) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: " + done.stderr.decode("utf-8", "replace")[:200] self.read_files.discard(("box", path)) audited = self.audit_file("delete", "sandbox:" + path, old, None, "", (), old_size, None) note = "" if audited is not False else " (audit failed)" if old_size is None: self.note_diff(path, old, None) return "deleted %s%s" % (path, note) def run_audit(self, args): path = str(args.get("path") or "").strip() or None tag = str(args.get("tag") or "").strip() or None query = str(args.get("query") or "").strip() or None try: limit = int(args.get("limit") or 20) except (TypeError, ValueError): return "error: invalid limit" if query: rows = self.app.store.audit_search(query, self.active_profile, limit) else: rows = self.app.store.audit_history(path, tag, limit, self.active_profile) if not rows: return "no audit rows match" lines = [] for row in rows: old_size = row["old_size"] if row["old_size"] is not None else "-" new_size = row["new_size"] if row["new_size"] is not None else "-" line = "#%d %s %s %s %s (%s->%s bytes)" % (row["id"], row["ts"][:19], row["actor"], row["action"], row["path"], old_size, new_size) if row["message"]: line += " :: " + row["message"][:120] if row["tags"]: line += " [%s]" % row["tags"] lines.append(line) return "\n".join(lines) def run_restore(self, args): try: row_id = int(args.get("id") or 0) except (TypeError, ValueError): return "error: invalid audit id" row = self.app.store.audit_get(row_id, self.active_profile) if row is None: return "error: unknown audit #%d" % row_id if row["action"] in ("snapshot", "release"): return "error: audit #%d is a %s row, restorable rows are write, edit, delete, shell-snapshot, restore" % (row_id, row["action"]) if row["new"] is not None: target, which, full_size = row["new"], "post", row["new_size"] elif row["old"] is not None: target, which, full_size = row["old"], "pre", row["old_size"] else: return "error: audit #%d holds no content" % row_id if "[truncated, full size" in target: return "error: audit #%d %s-image is truncated (full size %s bytes), cannot restore safely" % (row_id, which, full_size) if not self.app.ask_approval("restore %s from audit #%d (%s-image)" % (row["path"], row_id, which)): return "denied by user" message = "restored from audit #%d (%s-image)" % (row_id, which) if row["path"].startswith("sandbox:"): return self.box_restore(row["path"][len("sandbox:"):], target, message) if os.path.isfile(row["path"]): current, capped = read_capped(row["path"]) else: current, capped = None, None try: parent = os.path.dirname(row["path"]) if parent: os.makedirs(parent, exist_ok=True) with open(row["path"], "w", encoding="utf-8") as handle: handle.write(target) self.read_files.add(("home", os.path.abspath(row["path"]))) except OSError as exc: return "error: " + short_error(exc) self.audit_file("restore", row["path"], None, target, message) self.app.store.upsert_file_record(row["path"], target, None, self.active_profile) if capped is None: self.note_diff(row["path"], current, target) return "restored %s from audit #%d (%s-image)" % (row["path"], row_id, which) def box_restore(self, path, target, message): self.read_files.add(("box", path)) old, capped = self.box_old_image(path) written = self.box_write(path, target, old=None, action="restore", message=message, preview=False) if written.startswith("wrote "): if capped is None: self.note_diff(path, old, target) return "restored sandbox:%s (%s)" % (path, message) return written def run_release(self, args): part = str(args.get("part") or "").strip().lower() message = str(args.get("message") or "").strip() if part not in ("major", "minor", "patch"): return "error: part must be major, minor, or patch" if not message: return "error: release message is required" script_file = os.path.abspath(__file__) try: with open(script_file, "r", encoding="utf-8") as handle: match = VERSION_RE.search(handle.read()) if match is None: return "error: no VERSION line in running script" current = ".".join(match.groups()) upcoming = next_version(current, part) except (OSError, ValueError) as exc: return "error: " + short_error(exc) if not self.app.ask_approval("release %s (%s): %s" % (upcoming, part, message[:120])): return "denied by user" try: old, new, dest, row_id = do_release(self.app.store, script_file, backups_dir(self.app.config.home), part, message, self.audit_actor()) except (OSError, ValueError, sqlite3.Error) as exc: return "error: " + short_error(exc) return "released %s -> %s, backup %s, audit #%d" % (old, new, os.path.basename(dest), row_id) def run_graph_query(self, args): node = str(args.get("node") or "").strip() parsed = parse_node_id(node) if parsed is None or not self.app.store.has_node(*parsed, self.active_profile): return "error: unknown node " + node try: depth = int(args.get("depth") or 2) except (TypeError, ValueError): return "error: invalid depth" try: limit = int(args.get("limit") or 30) except (TypeError, ValueError): return "error: invalid limit" found = self.app.store.traverse(node, depth, limit, self.active_profile) lines = [] for item in found: if item["depth"] == 0: lines.append("%s :: %s" % (item["node"], item["title"])) else: lines.append("%s%s :: %s" % (" " * item["depth"], item["via"], item["title"])) return "\n".join(lines) def conversation_tokens(messages, tools=None): total = 0 for item in messages: total += estimate_tokens(item.get("content")) for call in item.get("tool_calls") or []: total += estimate_tokens(json.dumps(call)) if tools: total += estimate_tokens(json.dumps(tools)) return total def compact_messages(messages, chat, keep=KEEP_TURNS): if len(messages) <= keep + 1: return messages, False system = messages[0] if messages[0].get("role") == "system" else {"role": "system", "content": DEFAULT_SYSTEM} recent = messages[-keep:] while recent and recent[0].get("role") != "user": recent = recent[1:] if not recent: recent = messages[-keep:] middle = messages[1:len(messages) - len(recent)] if not middle: return messages, False digest = [] for item in middle: role = item.get("role") if role == "assistant" and item.get("tool_calls"): names = ", ".join(call.get("function", {}).get("name", "?") for call in item["tool_calls"]) digest.append("assistant tool calls: " + names) digest.append("%s: %s" % (role, (item.get("content") or "")[:1500])) request = [ {"role": "system", "content": COMPACT_SYSTEM}, {"role": "user", "content": "Compress this history:\n" + "\n".join(digest)}, ] summary = chat.complete(request, ephemeral=True)["content"].strip() rebuilt = [ system, {"role": "user", "content": "Previous session summary:\n" + summary}, {"role": "assistant", "content": "Understood. Continuing with summarized context."}, ] + recent return rebuilt, True class Denied(Exception): def __init__(self, guidance=""): super().__init__("denied by user") self.guidance = guidance class Agent: def __init__(self, config, store, persist=True, quiet=False, depth=0, auto=False, yolo=False, ctx=None): self.config = config self.auto = auto self.yolo = yolo self.ctx_id = ShellSession.coerce(ctx) self.store = store self.chat = ChatClient(config) if not persist or depth > 0: self.chat.fixed_key = "worker/%s" % uuid.uuid4().hex self.tools = Tools(self) self.profile = "" self.bot = "main" self.system_message = "" self.messages = [] self.skills = discover_skills(config.home, os.getcwd()) self.env = "home" self.persist = persist self.quiet = quiet self.depth = depth self.deadline = None self.timed_out = False self.runner_override = None self.user_stepped_in = False self._turn_open = False self._turn_goal = "" self._turn_steps = 0 self._turn_tools = 0 self._last_event = None self.switch_profile(config.profile, silent=True) def apply_system(self): content = self.system_message + skill_catalog(self.skills) + tool_catalog() content += orientation_block(sandbox=self.env == "sandbox") content += capability_block(self.yolo, self.auto) if self.auto: content += "\n\n" + AUTONOMOUS_NOTE self.messages[0] = {"role": "system", "content": content} def sync_chat_scope(self): self.chat.scope = (self.profile, self.bot) def reset_history(self): self.messages = [{"role": "system", "content": self.system_message}] self.apply_system() self.apply_system() def switch_profile(self, name, silent=False): if self.messages and self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) self.profile = name self.bot = "main" self.store.profile = name self.tools.read_files.clear() self.tools.secret_grants.clear() loaded = self.store.load_system(name) if loaded is None: loaded = DEFAULT_SYSTEM self.store.save_system(name, loaded) if LEGACY_PASSWORD_NOTE in loaded: loaded = loaded.replace(LEGACY_PASSWORD_NOTE, VAULT_MEMORY_NOTE) self.store.save_system(name, loaded) self.system_message = loaded restored = self.store.load_session(name, ctx=self.ctx_id) if self.persist else [] self.messages = [{"role": "system", "content": self.system_message}] + restored self.apply_system() self.sync_chat_scope() if not silent: print(paint("profile: %s (%d chars knowledge, %d restored messages)" % (name, len(self.system_message), len(restored)), Ansi.GREEN)) def update_system(self, text): self.system_message = text if self.bot == "main": self.store.save_system(self.profile, text) else: self.store.save_bot_system(self.profile, self.bot, text) self.apply_system() self.store.log_event(self.profile, "system", "remember", "system message updated (%d chars)" % len(text)) def switch_bot(self, name, silent=False): target = self.store.resolve_bot(self.profile, name) if target is None: return "unknown bot @%s (available: %s)" % (name, ", ".join(item["name"] for item in self.store.list_bots(self.profile))) if self.messages and self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) self.bot = target loaded = self.store.load_bot_system(self.profile, target) if loaded is None: loaded = DEFAULT_SYSTEM if target == "main": self.store.save_system(self.profile, loaded) else: self.store.save_bot_system(self.profile, target, loaded) self.system_message = loaded restored = self.store.load_session(self.profile, target, ctx=self.ctx_id) if self.persist else [] self.messages = [{"role": "system", "content": self.system_message}] + restored self.apply_system() self.sync_chat_scope() if not silent: print(paint("bot: %s (%d restored messages)" % (target, len(restored)), Ansi.GREEN)) return "switched to bot '%s'" % target def scrub_secret_from_history(self): for item in self.messages: content = item.get("content") if isinstance(content, str) and content: item["content"] = self.store.redact(content, self.profile) for recorded in item.get("tool_calls") or []: args = (recorded.get("function") or {}).get("arguments") if isinstance(args, str) and args: recorded["function"]["arguments"] = self.store.redact(args, self.profile) self.chat.reset_muse() def ask_approval(self, command, guidance=True): if self.yolo or self.auto: return True if not self.persist: return self.config.auto_approve prompter = getattr(self, "prompter", None) if prompter is not None: choice = prompter("approve", command, ["Allow once", "Always allow (approvals off)", "Deny"]) if choice == "Always allow (approvals off)": self.user_stepped_in = True self.yolo = True self.config.auto_approve = True return True if choice == "Allow once": self.user_stepped_in = True return True return False if not sys.stdin.isatty(): return False print(paint("run: %s" % command, Ansi.YELLOW)) try: answer = input(paint("allow? [y]once [Y]always [n]o ", Ansi.YELLOW)).strip() except (EOFError, KeyboardInterrupt): return False self.user_stepped_in = True if answer == "Y" or answer.lower() in ("yolo", "always"): self.yolo = True self.config.auto_approve = True print(paint("yolo mode on, approvals off for this session", Ansi.YELLOW)) return True if answer.lower() in ("y", "yes"): return True if not guidance or self.auto: return False try: typed = input(paint("denied. What should I do instead? (empty aborts this turn) ", Ansi.YELLOW)).strip() except (EOFError, KeyboardInterrupt): typed = "" raise Denied(typed) def show_call(self, call): if self.quiet: return preview_args = tool_args_text(call["arguments"]) if call["name"] == "store_secret": try: masked = json.loads(preview_args or "{}") masked["value"] = "[hidden]" preview_args = json.dumps(masked) except ValueError: preview_args = "[hidden]" if Glow.enabled(): verb, target, extra, pretty = Glow.describe_call(call["name"], preview_args) target = self.store.redact(target, self.profile) extra = self.store.redact(extra, self.profile) preview = self.store.redact(pretty, self.profile)[:400].replace("\n", " ") self._last_event = (call["name"], verb, target) print() inner = max(20, Glow.width() - 2) print(Glow.bar(call["name"]) + Glow.event_row(Glow.icon_for(call["name"]), verb, target, extra, inner, Glow.accent_for(call["name"]))) print(Glow.bar(call["name"]) + Glow.preview_row(preview, inner)) return preview = self.store.redact(preview_args, self.profile)[:400].replace("\n", " ") print(paint("┌─ %s" % call["name"], Ansi.MAGENTA)) print(paint("│ %s" % preview, Ansi.DIM)) def show_result(self, result, elapsed_ms): if self.quiet: return self.show_diff() note = getattr(self.tools, "result_note", None) if note: self.tools.result_note = None print(paint(note, Ansi.DIM)) if Glow.enabled(): name, verb, target = self._last_event or ("", "Done", "") failed = result == "denied by user" or (result or "").startswith(("error:", "[error]", "[missing]")) if name == "fork": match = re.match(r"agent (\d+) started", result or "") if match: target = "%s · #%s" % (target, match.group(1)) if target else "#%s" % match.group(1) inner = max(20, Glow.width() - 2) print(Glow.bar(name) + Glow.done_row(verb, target, len(result), elapsed_ms, failed, inner)) self._last_event = None self.show_lanes(name, result or "") return print(paint("└─ %d chars · %dms" % (len(result), elapsed_ms), Ansi.DIM)) def show_lanes(self, name, result): boarded = getattr(self.tools, "board_shown", False) self.tools.board_shown = False started = name == "fork" and result.startswith("agent ") collected = name == "poll" and result.startswith(("[done]", "[timeout]")) watching = name == "poll" and result.startswith("[running]") if not (started or collected or watching): return records = list_agents(self.profile) running = [record for record in records if record["status"] == "running"] pending = [record for record in records if record["status"] in ("done", "timeout", "error") and not record.get("collected")] if boarded: if collected and not running and not pending: print(Glow.event_row("◆", "lanes", "", "all collected")) return if len(running) >= 2 or pending: for row in Glow.lanes(records): print(row) elif collected and not running and not pending: print(Glow.event_row("◆", "lanes", "", "all collected")) def show_diff(self): preview = getattr(self.tools, "diff_preview", None) if preview is None: return self.tools.diff_preview = None rendered = render_diff(preview["path"], preview["old"], preview["new"], colors=color_enabled()) if rendered: print(rendered) def checkpoint(self): if not self.persist: return self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) if self._turn_open: self.store.save_turn_state(self.profile, self.bot, {"open": True, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()}, ctx=self.ctx_id) def close_turn(self): if not self.persist: self._turn_open = False return self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) self._turn_open = False if self._turn_goal: self.store.save_turn_state(self.profile, self.bot, {"open": False, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()}, ctx=self.ctx_id) def mention_error(self, tag): bots = ", ".join(item["name"] for item in self.store.list_bots(self.profile)) return ("unknown bot @%s (available: %s; providers: %s; @path references a file)" % (tag, bots, ", ".join(PROVIDER_LABELS))) def mention_confirm(self, selectors, cleared, directory): bits = [] if cleared and selectors["provider"] is None and selectors["model"] is None: bits.append("pins cleared") if selectors["provider"] is not None: bits.append("provider pinned to %s" % selectors["provider"]) if selectors["model"] is not None: bits.append("model pinned to %s" % selectors["model"]) if selectors["bot"] is not None: bits.append("switched to bot '%s'" % selectors["bot"]) if not bits: bits.append("nothing to select") suffix = "saved as default for %s" % directory if self.persist else "not saved (persist off)" return "%s (%s)" % ("; ".join(bits), suffix) def apply_mentions(self, text, capture=False, max_steps=MAX_STEPS): """Parse leading @tags. Returns (text, result): result is not None when the turn is fully handled (unknown-tag error, bare-selector confirm, or a routed bot turn).""" tags, rest = split_leading_tags(text) directory = dir_key() selectors = {"provider": None, "model": None, "bot": None} cleared = False files = [] loud = not capture and not self.quiet for tag in tags: kind, value = resolve_tag(tag, self, directory) if kind == "unknown": error = self.mention_error(tag) if loud: print(error) return text, error if kind == "auto": cleared = True selectors["provider"] = None selectors["model"] = None continue if kind == "file": files.append(value) continue if kind == "route": selectors["provider"], selectors["model"] = value continue selectors[kind] = value if cleared or selectors["provider"] is not None: self.chat.provider_pin = selectors["provider"] if cleared or selectors["model"] is not None: self.chat.model_pin = selectors["model"] if (cleared or any(selectors.values())) and self.persist: clear = [key for key in ("provider", "model") if cleared and selectors[key] is None] self.store.save_dir_defaults(directory, provider=selectors["provider"], model=selectors["model"], bot=selectors["bot"], clear=clear) prompt = expand_file_mentions(rest, directory) if files: prompt = " ".join(files + ([prompt] if prompt.strip() else [])) if not tags: return prompt, None if not prompt.strip(): if selectors["bot"] is not None and selectors["bot"] != self.bot: self.switch_bot(selectors["bot"], silent=True) confirm = self.mention_confirm(selectors, cleared, directory) if loud: print(confirm) return None, confirm if selectors["bot"] is not None and selectors["bot"] != self.bot: return None, self.run_mention(selectors["bot"], prompt, text, capture, max_steps) return prompt, None def phase(self, name, detail=""): """Report what the turn is really doing (thinking, tool, typing) to an optional on_phase(name, detail) listener; listener errors never reach the turn.""" listener = getattr(self, "on_phase", None) if listener is not None: try: listener(name, detail) except Exception: pass def run_turn(self, text, capture=False, max_steps=MAX_STEPS, _routed=False): self.apply_system() if not _routed: text, result = self.apply_mentions(text, capture, max_steps) if result is not None: return result self.messages.append({"role": "user", "content": text}) self.store.log_event(self.profile, "user", "message", self.store.redact(text, self.profile)) self._turn_open = True self._turn_goal = text[:500] self._turn_steps = 0 self._turn_tools = 0 self.checkpoint() started = time.time() backends = [] pieces = [] last_text = "" sink = pieces.append loud = not capture and not self.quiet self.tools.live = loud seen_actions = collections.deque(maxlen=400) seen_results = collections.deque(maxlen=400) stall = 0 last_pair = None run_len = 0 nudged = False for _step in range(Consts.TOTAL_STEP_CAP): if self.deadline is not None and time.time() > self.deadline: self.timed_out = True last_text = (last_text + "\n[time limit reached]").strip() break payload = select_tools(conversation_text(self.messages)) tokens = conversation_tokens(self.messages, payload) if tokens > CONTEXT_CAP * COMPACT_RATIO: if loud: print(paint("compacting context (%d tokens)..." % tokens, Ansi.DIM)) self.messages, _changed = compact_messages(self.messages, self.chat) self.apply_system() spinner = None if loud: spinner = Spinner("thinking") spinner.start() self.phase("thinking") try: reply = self.chat.complete(self.messages, payload, stream_sink=sink, deadline=self.deadline) except BackendError as exc: if loud: print(paint("backend error: %s" % exc, Ansi.RED)) self.messages.pop() self.checkpoint() return last_text or "backend error: %s" % exc finally: if spinner is not None: spinner.stop() reply["content"] = self.store.redact(reply["content"] or "", self.profile) if loud and reply["content"].strip(): print(render_markdown(reply["content"])) if reply["backend"] not in backends: backends.append(reply["backend"]) if loud and (len(backends) > 1 or reply["backend"] != MUSE_LABEL): print(paint("(via %s)" % reply["backend"], Ansi.DIM)) if loud and reply["reasoning"] and not reply["content"] and not reply["tool_calls"]: print(paint(reply["reasoning"][:2000], Ansi.GRAY)) reply["tool_calls"] = [call for call in reply["tool_calls"] if TOOL_NAME_RE.match(call["name"])] history_calls = [{"id": call["id"], "type": "function", "function": {"name": call["name"], "arguments": call["arguments"]}} for call in reply["tool_calls"]] self.messages.append({"role": "assistant", "content": reply["content"] or None, "tool_calls": history_calls or None}) if not reply["tool_calls"]: if reply["content"]: last_text = reply["content"] elif reply["reasoning"]: last_text = reply["reasoning"] if last_text: self.phase("typing") self.store.log_event(self.profile, "assistant", "message", last_text) break aborted = False step_pairs = [] for pos, call in enumerate(reply["tool_calls"]): self.show_call(call) self.phase("tool", call["name"]) call_started = time.time() try: result = self.tools.dispatch(call["name"], call["arguments"]) except Denied as denied: result = "denied by user" self.show_result(result, (time.time() - call_started) * 1000) self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) self.store.log_event(self.profile, "tool", call["name"], result) step_pairs.append((action_signature(call["name"], call["arguments"]), result_digest(result))) for skipped in reply["tool_calls"][pos + 1:]: self.messages.append({"role": "tool", "tool_call_id": skipped["id"], "content": "skipped: stopped after denial"}) if denied.guidance: self.messages.append({"role": "user", "content": denied.guidance}) self.store.log_event(self.profile, "user", "guidance", self.store.redact(denied.guidance, self.profile)) else: aborted = True last_text = "stopped by user" break self.show_result(result, (time.time() - call_started) * 1000) self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) self.store.log_event(self.profile, "tool", call["name"], result) step_pairs.append((action_signature(call["name"], call["arguments"]), result_digest(result))) if call["name"] == "store_secret": self.scrub_secret_from_history() if aborted: break productive = self.user_stepped_in self.user_stepped_in = False for pair in step_pairs: if pair[0] not in seen_actions or pair[1] not in seen_results: productive = True seen_actions.append(pair[0]) seen_results.append(pair[1]) if pair == last_pair: run_len += 1 else: last_pair = pair run_len = 1 nudged = False self._turn_steps += 1 self._turn_tools += len(step_pairs) self.checkpoint() if productive: stall = 0 else: stall += 1 if run_len == LOOP_NUDGE_AT and not nudged: nudged = True nudge = "loop warning: you called %s %d times in a row with identical results. Change approach, try different arguments, or stop instead of repeating it." % (last_pair[0][:200], run_len) self.messages.append({"role": "user", "content": nudge}) self.store.log_event(self.profile, "user", "guidance", nudge) if loud: print(paint(nudge, Ansi.YELLOW)) if run_len >= LOOP_STOP_AT: last_text = "stuck in a loop: %s repeated %d times with identical results" % (last_pair[0][:200], run_len) if loud: print(paint(last_text, Ansi.YELLOW)) self.store.log_event(self.profile, "system", "stall", last_text) break if stall >= max_steps: last_text = "no progress for %d steps: actions and results keep repeating. Say 'continue' to resume with fresh direction." % max_steps if loud: print(paint(last_text, Ansi.YELLOW)) self.store.log_event(self.profile, "system", "stall", last_text) break else: if loud: print(paint("step budget exhausted (%d total steps)" % Consts.TOTAL_STEP_CAP, Ansi.YELLOW)) self.close_turn() total = time.time() - started if loud: if Glow.enabled(): print(Glow.hairline()) print(paint("tokens≈%d · %s · %.1fs" % (conversation_tokens(self.messages), "+".join(backends), total), Ansi.DIM)) return last_text def run_mention(self, target, rest, original, capture=False, max_steps=MAX_STEPS): if self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) other = self.store.load_session(self.profile, target, ctx=self.ctx_id) else: other = [] system = self.store.load_bot_system(self.profile, target) or DEFAULT_SYSTEM saved_messages, saved_system, saved_bot = self.messages, self.system_message, self.bot self.messages = [{"role": "system", "content": system}] + other self.system_message = system self.bot = target self.apply_system() self.sync_chat_scope() try: answer = self.run_turn(rest, capture=capture, max_steps=max_steps, _routed=True) finally: if self.persist: self.store.save_session(self.profile, self.messages, target, ctx=self.ctx_id) self.messages, self.system_message, self.bot = saved_messages, saved_system, saved_bot self.apply_system() self.sync_chat_scope() self.store.log_event(self.profile, "user", "message", self.store.redact(original, self.profile)) self.messages.append({"role": "user", "content": original}) self.messages.append({"role": "assistant", "content": answer, "reasoning": "", "tool_calls": [], "backend": "mention"}) if self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) return answer def default_runner(config, seal, profile, depth, task, timeout, max_steps=WORKER_STEPS, stats=None): store = Store(config, seal) try: worker = Agent(config, store, persist=False, quiet=True, depth=depth) if profile != worker.profile: worker.switch_profile(profile, silent=True) worker.deadline = time.time() + timeout try: return worker.run_turn(task, capture=True, max_steps=max_steps) finally: if stats is not None: stats["tools"] = worker.tools.calls finally: store.close() def spawn_agent(task, profile, timeout, config, seal, depth=0, runner=None): with AGENTS_LOCK: agent_id = AGENTS_NEXT[0] AGENTS_NEXT[0] += 1 record = {"id": agent_id, "task": task, "profile": profile, "status": "running", "result": "", "started": time.time(), "ended": None, "thread": None, "spilled": None, "collected": False, "tools": None} AGENTS[agent_id] = record def target(): try: if runner is None: result = default_runner(config, seal, profile, depth, task, timeout, stats=record) else: result = runner(task, profile, timeout) status = "timeout" if "[time limit reached]" in result else "done" except Exception as exc: result = "worker failed: " + short_error(exc) status = "error" with AGENTS_LOCK: record["result"] = result record["status"] = status record["ended"] = time.time() worker_thread = threading.Thread(target=target, daemon=True) with AGENTS_LOCK: record["thread"] = worker_thread worker_thread.start() return agent_id def poll_agent(agent_id, wait=0, profile=None): with AGENTS_LOCK: record = AGENTS.get(agent_id) if profile is not None and record is not None and record.get("profile") != profile: record = None worker_thread = record["thread"] if record else None if record is None: return "missing", "no agent %d" % agent_id if worker_thread is not None: worker_thread.join(timeout=max(0, wait)) with AGENTS_LOCK: status = record["status"] result = record["result"] elapsed = (record["ended"] or time.time()) - record["started"] idle = record.get("tools") == 0 if status == "running": return status, "agent %d still running (%ds elapsed)" % (agent_id, elapsed) if status == "error": return status, result text = truncate(result, 6000, 2000) or "(empty result)" if idle: text += "\n[note: this worker ran 0 tool calls, so it changed nothing; check its answer before relying on it]" return status, text def list_agents(profile=None): with AGENTS_LOCK: return [{"id": record["id"], "task": record["task"], "status": record["status"], "elapsed": int((record["ended"] or time.time()) - record["started"]), "collected": record.get("collected", False), "tools": record.get("tools")} for record in AGENTS.values() if profile is None or record.get("profile") == profile] def clear_agents(profile=None): with AGENTS_LOCK: finished = [key for key, record in AGENTS.items() if record["status"] != "running" and (profile is None or record.get("profile") == profile)] for key in finished: del AGENTS[key] return len(finished) def running_agents(): with AGENTS_LOCK: return sum(1 for record in AGENTS.values() if record["status"] == "running") def parse_schedule_at(raw): text = str(raw or "").strip() if not text: raise ValueError("empty datetime") if text[-1:] in ("Z", "z"): text = text[:-1] + "+00:00" try: moment = _fromisoformat(text) except ValueError: raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") return moment.astimezone(timezone.utc).isoformat() def parse_duration(raw): match = re.fullmatch(r"(\d+)\s*([smhdw])?", str(raw or "").strip().lower()) if not match: return None return int(match.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[match.group(2) or "s"] def format_delay(seconds): seconds = int(seconds) if seconds < 0: return "overdue" if seconds < 60: return "%ds" % seconds if seconds < 3600: return "%dm" % (seconds // 60) if seconds < 86400: return "%dh" % (seconds // 3600) return "%dd" % (seconds // 86400) def local_display(utc_iso): try: return _fromisoformat(utc_iso).astimezone().strftime("%Y-%m-%d %H:%M") except ValueError: return utc_iso def format_schedule_lines(items, now): if not items: return "(no schedules)" lines = [] for item in items: try: due_in = (_fromisoformat(item["next_run"]) - now).total_seconds() except ValueError: due_in = -1 when = "every %s" % format_delay(item["every"]) if item["every"] else "at %s" % local_display(item["next_run"]) marked = " [%s]" % " ".join(item["tags"]) if item["tags"] else "" lines.append("#%d [%s] %s (%s) %s%s :: %s" % (item["id"], item["status"], when, format_delay(due_in), item["name"] or "(no name)", marked, item["prompt"][:80])) if item["last"]: extra = " :: %s" % item["result"][:120] if item["result"] else "" lines.append(" last: %s%s" % (item["last"], extra)) return "\n".join(lines) def note_schedule_result(db_path, row_id, status, result=""): try: db = sqlite3.connect(db_path, timeout=30) db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA busy_timeout=30000") try: db.execute("UPDATE schedules SET last_status = ?, last_result = ?, updated = ? WHERE id = ?", (status, result[:2000], datetime.now(timezone.utc).isoformat(), row_id)) db.commit() finally: db.close() except sqlite3.Error: pass def watch_schedule(db_path, row_id, agent_id, timeout): status, text = poll_agent(agent_id, wait=timeout + 30) note_schedule_result(db_path, row_id, status, text) def scheduler_tick(config, seal, runner=None): fired = 0 now = datetime.now(timezone.utc) db = sqlite3.connect(config.db_path, timeout=30) db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA busy_timeout=30000") try: rows = db.execute("SELECT id, prompt, profile, every_sec, next_run, timeout FROM schedules WHERE status = 'pending' AND next_run <= ? ORDER BY next_run", (now.isoformat(),)).fetchall() for row_id, prompt, profile, every_sec, next_run, timeout in rows: stamp = datetime.now(timezone.utc).isoformat() if every_sec: try: claimed_next = _fromisoformat(next_run) except ValueError: continue while claimed_next <= datetime.now(timezone.utc): claimed_next += timedelta(seconds=every_sec) done = db.execute("UPDATE schedules SET next_run = ?, updated = ? WHERE id = ? AND status = 'pending' AND next_run = ?", (claimed_next.isoformat(), stamp, row_id, next_run)) else: done = db.execute("UPDATE schedules SET status = 'done', updated = ? WHERE id = ? AND status = 'pending'", (stamp, row_id)) db.commit() if done.rowcount != 1: continue if prompt.startswith(Seal.PREFIX): try: prompt = seal.unlock(prompt) except SealError as exc: note_schedule_result(config.db_path, row_id, "error: " + short_error(exc)) continue agent_id = spawn_agent(prompt, profile, timeout, config, seal, 0, runner) watcher = threading.Thread(target=watch_schedule, args=(config.db_path, row_id, agent_id, timeout), daemon=True) watcher.start() fired += 1 finally: db.close() return fired def run_scheduler_loop(config, seal, stop_flag): while not stop_flag.is_set(): try: scheduler_tick(config, seal) except Exception as exc: print("scheduler error: %s" % short_error(exc)) stop_flag.wait(SCHEDULER_INTERVAL) def start_scheduler(config, seal): stop_flag = threading.Event() worker = threading.Thread(target=run_scheduler_loop, args=(config, seal, stop_flag), daemon=True) worker.start() return stop_flag COMMANDS = ("help", "profile", "profiles", "bots", "bot", "env", "ctx", "skills", "sysinfo", "models", "secret", "schedule", "schedules", "unschedule", "records", "record", "graph", "tags", "tools", "install", "search", "audit", "restore", "release", "fork", "agents", "agent", "compact", "clear", "demo", "quit", "exit") def complete_command(text, state): options = ["/" + name for name in COMMANDS if name.startswith(text.lstrip("/"))] return options[state] if state < len(options) else None def mention_options(prefix, agent, directory=None, cap=200): found = [] seen = set() low = (prefix or "").lower() try: bots = agent.store.list_bots(agent.profile) except Exception: bots = [] for item in bots: for name in [item["name"]] + list(item.get("nicknames") or []): if name.lower().startswith(low) and name not in seen: seen.add(name) found.append("@" + name) labels = list(PROVIDER_LABELS) + [MENTION_AUTO] for backend in agent.config.backends or []: label = ((backend or {}).get("label") or "").strip() if label: labels.append(label) for label in labels: if label.lower().startswith(low) and label not in seen: seen.add(label) found.append("@" + label) try: models = sorted(known_model_ids(agent.config, getattr(agent.chat, "health", None))) except Exception: models = [] for model in models: if model.lower().startswith(low) and model not in seen: seen.add(model) found.append("@" + model) for rel in complete_files(prefix or "", directory or dir_key()): if rel not in seen: seen.add(rel) found.append("@" + rel) return found[:cap] def complete_mention(text, state, agent): options = mention_options(text[1:], agent) if (text or "").startswith("@") else [] return options[state] if state < len(options) else None def complete_input(text, state, agent=None): if (text or "").startswith("@") and agent is not None: return complete_mention(text, state, agent) return complete_command(text, state) def handle_command(agent, text): parts = text[1:].split(None, 1) name = parts[0].lower() arg = parts[1].strip() if len(parts) > 1 else "" if name in ("quit", "exit"): return False if name == "help": print(HELP_TEXT) elif name == "profiles": names = agent.store.list_profiles() for item in names: print("%s %s" % ("*" if item == agent.profile else " ", item)) if not names: print("(no profiles)") elif name == "bots": for item in agent.store.list_bots(agent.profile): nicks = " aka %s" % ", ".join(item["nicknames"]) if item["nicknames"] else "" print("%s %s%s" % ("*" if item["name"] == agent.bot else " ", item["name"], nicks)) elif name == "bot": if not arg: print(paint("use /bot , see /bots", Ansi.RED)) else: print(agent.switch_bot(arg)) elif name == "profile": if not arg: print("current profile: %s (%d chars)" % (agent.profile, len(agent.system_message))) elif not PROFILE_RE.match(arg): print(paint("invalid profile name", Ansi.RED)) else: agent.switch_profile(arg) elif name == "env": if not arg: print("current environment: %s" % agent.env) elif arg not in ("home", "sandbox"): print(paint("use /env home or /env sandbox", Ansi.RED)) elif arg == "sandbox": try: ensure_box() except BackendError as exc: print(paint("sandbox unavailable: %s" % exc, Ansi.RED)) else: agent.env = "sandbox" print("environment: sandbox (isolated container)") else: agent.env = "home" print("environment: home") elif name == "ctx": print(ShellSession.report(agent.store, getattr(agent, "ctx_id", ""))) elif name == "skills": bits = arg.split() if bits and bits[0] == "install": if len(bits) < 2: print(paint("use /skills install [name=x] [scope=project|home] [mode=copy|symlink] [force]", Ansi.RED)) return True call_args = {"source": bits[1]} for token in bits[2:]: if token == "force": call_args["force"] = True elif "=" in token: key, _, value = token.partition("=") if key in ("name", "scope", "mode"): call_args[key] = value print(agent.tools.dispatch("install_skill", json.dumps(call_args))) return True agent.skills = discover_skills(agent.config.home, os.getcwd()) agent.apply_system() if not agent.skills: print("(no skills)") for skill_name in sorted(agent.skills): skill = agent.skills[skill_name] print("- %s: %s %s" % (skill_name, skill["description"][:200], skill_provenance_note(skill))) wanted = sorted(item for item in SKILL_BLUEPRINTS if item not in agent.skills) if wanted: print("blueprints, built on first load:") for skill_name in wanted: print("- %s: %s" % (skill_name, SKILL_BLUEPRINTS[skill_name]["hint"])) elif name == "sysinfo": print(collect_sysinfo()) elif name == "models": print(models_report(agent.chat)) elif name == "secret": sub = arg.split(None, 1) action = sub[0].lower() if sub else "" rest = sub[1].strip() if len(sub) > 1 else "" if action == "set" and rest: if not SECRET_RE.match(rest): print(paint("invalid secret name, use letters, digits, dot, underscore, hyphen", Ansi.RED)) else: try: value = getpass.getpass("value for '%s': " % rest) except (EOFError, KeyboardInterrupt): print("\naborted") return True if not value: print("empty value, aborted") else: agent.store.save_secret(rest, value, None, None, agent.profile) print("stored secret '%s'" % rest) elif action in ("list", "") and not rest: print(agent.tools.dispatch("list_secrets", "{}")) elif action == "delete" and rest: if agent.store.load_secret(rest, agent.profile) is None: print(paint("unknown secret '%s'" % rest, Ansi.RED)) elif agent.ask_approval("delete secret '%s'" % rest, guidance=False): agent.store.delete_secret(rest, agent.profile) print("deleted secret '%s'" % rest) else: print("aborted") else: print(paint("use /secret set or /secret list or /secret delete ", Ansi.RED)) elif name == "schedule": parts = arg.split(None, 2) if len(parts) != 3 or parts[0] not in ("at", "every"): print(paint("use /schedule at or /schedule every <30s|10m|2h|1d> ", Ansi.RED)) else: mode, when, prompt = parts if mode == "at": try: next_run = parse_schedule_at(when) except ValueError as exc: print(paint("error: %s" % exc, Ansi.RED)) return True every_sec = 0 else: every_sec = parse_duration(when) if every_sec is None or every_sec < 60: print(paint("error: every must be a duration of at least 60s", Ansi.RED)) return True next_run = (datetime.now(timezone.utc) + timedelta(seconds=every_sec)).isoformat() row_id = agent.store.add_schedule("", prompt, agent.profile, every_sec, next_run, 600) print("scheduled #%d" % row_id) elif name == "schedules": print(format_schedule_lines(agent.store.list_schedules(agent.profile), datetime.now(timezone.utc))) elif name == "unschedule": try: row_id = int(arg or "0") except ValueError: print(paint("use /unschedule ", Ansi.RED)) return True if agent.store.remove_schedule(row_id, agent.profile): print("deleted schedule #%d" % row_id) else: print(paint("no schedule #%d" % row_id, Ansi.RED)) elif name == "records": print(agent.tools.dispatch("record_search", json.dumps({"query": arg}))) elif name == "record": bits = arg.split() if not bits: print(paint("use /record [offset]", Ansi.RED)) else: try: offset = int(bits[1]) if len(bits) > 1 else 0 except ValueError: print(paint("offset must be a number", Ansi.RED)) return True print(agent.tools.dispatch("record_read", json.dumps({"id": bits[0], "offset": offset}))) elif name == "graph": if not arg: print(paint("use /graph ", Ansi.RED)) else: print(agent.tools.dispatch("graph_query", json.dumps({"node": arg}))) elif name == "tools": if arg: match = [schema["function"] for schema in TOOL_SCHEMAS if schema["function"]["name"] == arg] if not match: print(paint("unknown tool '%s'" % arg, Ansi.RED)) return True print("%s [%s]" % (match[0]["name"], "core" if match[0]["name"] in CORE_TOOLS else "lazy")) print(match[0]["description"]) print("trigger tags: %s" % ", ".join(TOOL_TAGS.get(match[0]["name"], ()))) return True names = [schema["function"]["name"] for schema in TOOL_SCHEMAS if tool_env_ok(schema["function"]["name"])] cores = sorted(tool for tool in names if tool in CORE_TOOLS) lazy = sorted(tool for tool in names if tool not in CORE_TOOLS) print("core, always loaded:\n %s" % "\n ".join(cores)) print("lazy, loads when named:\n %s" % "\n ".join(lazy)) elif name == "search": if not arg: print(paint("use /search ", Ansi.RED)) else: print(agent.tools.dispatch("search", json.dumps({"query": arg}))) elif name == "install": bits = arg.split() action = bits[0] if bits else "status" if action not in ("status", "install", "upgrade", "reinstall", "uninstall"): print(paint("use /install [status|install|upgrade|reinstall|uninstall] [targets...]", Ansi.RED)) else: print(agent.tools.dispatch("install", json.dumps({"action": action, "targets": bits[1:] or list(INSTALL_TARGETS)}))) elif name == "tags": print(agent.tools.dispatch("tags", json.dumps({"prefix": arg} if arg else {}))) elif name == "audit": print(agent.tools.dispatch("audit", json.dumps({"path": arg} if arg else {}))) elif name == "restore": try: row_id = int(arg or "0") except ValueError: print(paint("use /restore ", Ansi.RED)) return True print(agent.tools.dispatch("restore", json.dumps({"id": row_id}))) agent.show_diff() elif name == "release": bits = arg.split(None, 1) if len(bits) != 2 or bits[0] not in ("major", "minor", "patch"): print(paint("use /release ", Ansi.RED)) else: print(agent.tools.dispatch("release", json.dumps({"part": bits[0], "message": bits[1]}))) elif name == "fork": if not arg: print(paint("use /fork ", Ansi.RED)) else: agent_id = spawn_agent(arg, agent.profile, 600, agent.config, agent.store.seal, agent.depth + 1, agent.runner_override) print("agent %d started, REPL stays free" % agent_id) elif name == "agents": records = list_agents(agent.profile) if not records: print("(no agents)") elif Glow.enabled(): running = sum(1 for record in records if record["status"] == "running") print(Glow.event_row("◆", "agents", "%d total" % len(records), "%d running" % running)) for record in records[:50]: print(Glow.agent_row(record)) if len(records) > 50: print(paint(" · +%d more" % (len(records) - 50), Ansi.DIM)) else: for record in records: print("#%d [%s] %ds %s" % (record["id"], record["status"], record["elapsed"], record["task"][:60])) elif name == "agent": if arg == "clear": print("purged %d finished agents" % clear_agents(agent.profile)) else: try: agent_id = int(arg) except ValueError: print(paint("use /agent or /agent clear", Ansi.RED)) return True status, text = poll_agent(agent_id, 0, agent.profile) print("[%s]\n%s" % (status, text)) elif name == "compact": agent.messages, changed = compact_messages(agent.messages, agent.chat) agent.apply_system() print("compacted" if changed else "nothing to compact") elif name == "clear": agent.reset_history() print("history cleared") elif name == "demo": tui_demo() else: print(paint("unknown command, try /help", Ansi.RED)) return True class Telegram: def __init__(self, token): self.token = token def call(self, method, params, timeout=70): body = json.dumps(params).encode("utf-8") request = urllib.request.Request(TELEGRAM_API + self.token + "/" + method, data=body, headers={"Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8", "replace")) def telegram_env_path(home): return os.path.join(home, "telegram.env") def load_telegram_token(home): token = os.environ.get("TELEGRAM_BOT_TOKEN") or "" if token: return token path = telegram_env_path(home) if not os.path.exists(path): return "" with open(path, "r", encoding="utf-8") as handle: for line in handle.read().splitlines(): if line.startswith("TELEGRAM_BOT_TOKEN="): return line.split("=", 1)[1].strip().strip("'\"") return "" def telegram_send(bot, chat_id, text): for pos in range(0, len(text), 4000): bot.call("sendMessage", {"chat_id": chat_id, "text": text[pos:pos + 4000] or "(empty reply)"}) def telegram_transcribe(bot, file_id): try: info = bot.call("getFile", {"file_id": file_id}) remote = (info.get("result") or {}).get("file_path", "") if not remote: return "error: telegram returned no file" with urllib.request.urlopen(TELEGRAM_FILE_API + bot.token + "/" + remote, timeout=60) as response: audio = response.read(20000000) engine = ensure_box() return box_transcribe(engine, audio) except (OSError, ValueError, BackendError) as exc: return "error: transcription failed: " + short_error(exc) def handle_telegram_update(agent, bot, update): message = update.get("message") or {} chat_id = (message.get("chat") or {}).get("id") if not chat_id: return text = message.get("text") or "" if text == "/start": telegram_send(bot, chat_id, "tai online. Send any message, /new for a fresh start.") return if text == "/new": agent.reset_history() telegram_send(bot, chat_id, "fresh start.") return voice = message.get("voice") or message.get("audio") if voice: bot.call("sendChatAction", {"chat_id": chat_id, "action": "typing"}) text = telegram_transcribe(bot, voice.get("file_id", "")) if text.startswith("error"): telegram_send(bot, chat_id, text) return if not text.strip(): return bot.call("sendChatAction", {"chat_id": chat_id, "action": "typing"}) reply = agent.run_turn(text, capture=True) telegram_send(bot, chat_id, reply) def run_telegram_bot(agent): token = load_telegram_token(agent.config.home) if not token: print("missing TELEGRAM_BOT_TOKEN", file=sys.stderr) return 2 bot = Telegram(token) try: me = bot.call("getMe", {}) except (OSError, ValueError) as exc: print("telegram auth failed: %s" % short_error(exc), file=sys.stderr) return 2 print("telegram bot online as @%s" % (me.get("result") or {}).get("username", "?")) start_scheduler(agent.config, agent.store.seal) offset = 0 while True: try: data = bot.call("getUpdates", {"offset": offset, "timeout": 50}) except (OSError, ValueError) as exc: print("poll error: %s, retrying" % short_error(exc)) time.sleep(5) continue for update in data.get("result") or []: offset = update.get("update_id", offset) + 1 try: handle_telegram_update(agent, bot, update) except Exception as exc: print("update error: %s" % short_error(exc)) def run_host(argv, timeout=120): try: done = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=timeout) return done.returncode == 0, (done.stdout or "") + (done.stderr or "") except (OSError, subprocess.SubprocessError) as exc: return False, short_error(exc) class Install: """Install probes (static; tests patch these live).""" @staticmethod def shell_present(shell): return shutil.which(shell) is not None @staticmethod def venv_available(): if importlib.util.find_spec("venv") is None: return False if importlib.util.find_spec("ensurepip") is not None: return True return shutil.which("pip3") is not None or shutil.which("pip") is not None @staticmethod def ensure_venv(home): found = venv_python(home) if found: return True, "kept %s" % found if not Install.venv_available(): return False, "python venv module unavailable" try: done = subprocess.run([sys.executable, "-m", "venv", venv_dir(home)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=300) except (OSError, subprocess.SubprocessError) as exc: return False, short_error(exc) if done.returncode != 0 or not venv_python(home): return False, ((done.stderr or "").strip()[:200] or "venv creation failed") return True, "created %s" % venv_python(home) def venv_available(): return Install.venv_available() def venv_dir(home): return os.path.join(home, "venv") def venv_python(home): cand = os.path.join(venv_dir(home), "bin", "python") if os.path.isfile(cand) and os.access(cand, os.X_OK): return cand return None def ensure_venv(home): return Install.ensure_venv(home) def service_exec(home, script, *args): parts = [] python = venv_python(home) if python: parts.append(python) parts.append(script) parts.extend(args) return " ".join(parts) def unit_dir(home): return os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") def service_state(name): if not shutil.which("systemctl"): return "unknown", "unknown" active, _out = run_host(["systemctl", "--user", "is-active", name], timeout=15) enabled, _out = run_host(["systemctl", "--user", "is-enabled", name], timeout=15) return ("active" if active else "inactive"), ("enabled" if enabled else "disabled") def activate_service(name): if not shutil.which("systemctl"): return "systemctl missing, unit written only" run_host(["systemctl", "--user", "daemon-reload"]) active, _enabled = service_state(name) if active == "active": ok, out = run_host(["systemctl", "--user", "restart", name]) return "restarted, changes effective immediately" if ok else "restart failed: " + out.strip()[:150] ok, out = run_host(["systemctl", "--user", "enable", "--now", name]) if ok: user = os.environ.get("USER") or getpass.getuser() run_host(["loginctl", "enable-linger", user]) return "enabled and started" return "enable failed: " + out.strip()[:150] def remove_service_unit(home, name): path = os.path.join(unit_dir(home), name) if shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", name]) run_host(["systemctl", "--user", "daemon-reload"]) if os.path.exists(path): try: os.remove(path) return True, "removed " + path except OSError as exc: return False, short_error(exc) return True, "not present" def script_version(path): try: with open(path, "r", encoding="utf-8") as handle: match = VERSION_RE.search(handle.read()) except OSError: return None return ".".join(match.groups()) if match else None def shell_rc_path(shell): """Where each shell's startup file lives. bash/zsh: a dotfile sourced for interactive shells only. fish: config.fish, which fish sources on every startup (interactive or not, login or not) -- hence the `status is-interactive` guard inside FISH_PLEXER_BLOCK rather than relying on the file only being read at the right time.""" home = os.path.expanduser("~") if shell == "fish": config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config") return os.path.join(config_home, "fish", "config.fish") if shell == "zsh": return os.path.join(home, ".zshrc") return os.path.join(home, ".bashrc") def shell_present(shell): return Install.shell_present(shell) def _rc_block_present(path, block): try: with open(path, "r", encoding="utf-8") as handle: return block in handle.read() except OSError: return False def _rc_block_remove(path, mark_begin, mark_end): try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: return False stripped = re.sub(re.escape(mark_begin) + r".*?" + re.escape(mark_end) + r"\n?", "", raw, flags=re.DOTALL) if stripped == raw: return False backup = path + ".bak-tai" if raw and not os.path.exists(backup): with open(backup, "w", encoding="utf-8") as handle: handle.write(raw) with open(path, "w", encoding="utf-8") as handle: handle.write(stripped) return True def _rc_block_upsert(path, mark_begin, mark_end, block): try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: raw = "" if block in raw: return False backup = path + ".bak-tai" if raw and not os.path.exists(backup): with open(backup, "w", encoding="utf-8") as handle: handle.write(raw) stripped = re.sub(re.escape(mark_begin) + r".*?" + re.escape(mark_end) + r"\n?", "", raw, flags=re.DOTALL) if stripped and not stripped.endswith("\n"): stripped += "\n" parent = os.path.dirname(path) if parent and not os.path.isdir(parent): os.makedirs(parent, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write(stripped + block) return True def hook_present(path): return _rc_block_present(path, BASHRC_BLOCK) def remove_bashrc_block(path): return _rc_block_remove(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END) def zsh_hook_present(path): return _rc_block_present(path, ZSH_NOTFOUND_BLOCK) def remove_zsh_hook_block(path): return _rc_block_remove(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END) def upsert_zsh_hook_block(path): return _rc_block_upsert(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END, ZSH_NOTFOUND_BLOCK) def fish_hook_present(path): return _rc_block_present(path, FISH_NOTFOUND_BLOCK) def remove_fish_hook_block(path): return _rc_block_remove(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END) def upsert_fish_hook_block(path): return _rc_block_upsert(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END, FISH_NOTFOUND_BLOCK) def plexer_hook_present(path): return _rc_block_present(path, PLEXER_BASHRC_BLOCK) def remove_plexer_bashrc_block(path): return _rc_block_remove(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END) def upsert_plexer_bashrc_block(path): return _rc_block_upsert(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END, PLEXER_BASHRC_BLOCK) # zsh reuses PLEXER_BASHRC_BLOCK byte-for-byte -- zsh is POSIX-enough that # the plain `[`/`case "$-"` bash content works unchanged; only the target # file differs (~/.zshrc, not ~/.bashrc). No ZSH_PLEXER_BLOCK constant exists # on purpose -- there is nothing shell-specific to put in one. def zsh_plexer_hook_present(path): return _rc_block_present(path, PLEXER_BASHRC_BLOCK) def remove_zsh_plexer_block(path): return _rc_block_remove(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END) def upsert_zsh_plexer_block(path): return _rc_block_upsert(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END, PLEXER_BASHRC_BLOCK) def fish_plexer_hook_present(path): return _rc_block_present(path, FISH_PLEXER_BLOCK) def remove_fish_plexer_block(path): return _rc_block_remove(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END) def upsert_fish_plexer_block(path): return _rc_block_upsert(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END, FISH_PLEXER_BLOCK) def box_venv_state(engine): try: probe = box_exec(engine, ["test", "-x", BOX_PYTHON], timeout=30) except (OSError, subprocess.SubprocessError): return "unknown" return "venv ready" if probe.returncode == 0 else "no venv (reinstall container to rebuild)" def shell_hook_status_line(label, shell, present_fn): path = shell_rc_path(shell) note = "" if shell_present(shell) else " (%s not found)" % shell return "%s: %s in %s%s" % (label, "installed" if present_fn(path) else "not installed", path, note) def status_lines(home, store=None): lines = [] binary = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") if os.path.isfile(binary): lines.append("binary: installed %s (%s, running %s)" % (binary, script_version(binary) or "unknown", VERSION)) else: lines.append("binary: not installed") lines.append("bash-hook: %s in ~/.bashrc" % ("installed" if hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed")) lines.append(shell_hook_status_line("zsh-hook", "zsh", zsh_hook_present)) lines.append(shell_hook_status_line("fish-hook", "fish", fish_hook_present)) lines.append("plexer-hook: %s in ~/.bashrc" % ("installed" if plexer_hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed")) lines.append(shell_hook_status_line("zsh-plexer-hook", "zsh", zsh_plexer_hook_present)) lines.append(shell_hook_status_line("fish-plexer-hook", "fish", fish_plexer_hook_present)) python = venv_python(home) if python: lines.append("venv: ready %s" % python) elif venv_available(): lines.append("venv: not created") else: lines.append("venv: unavailable (no venv module)") for name, label in (("tai-scheduler.service", "scheduler-service"), ("tai-telegram.service", "telegram-service")): unit = os.path.join(unit_dir(home), name) if not os.path.isfile(unit): lines.append("%s: not installed" % label) continue active, enabled = service_state(name) lines.append("%s: unit present, %s, %s" % (label, active, enabled)) engine = container_engine() if not engine: lines.append("container: no engine (install podman)") else: state = box_state(engine) if state == "running": lines.append("container: running, %s" % box_venv_state(engine)) else: lines.append("container: %s" % state) lines.append(vault_line(home, store)) return lines def vault_line(home, store=None): db_path = os.path.join(home, "memory.db") if not os.path.isfile(db_path): return "vault: empty, install ops never touch data" if store is None: return "vault: kept %s, install ops never touch data" % db_path try: secrets = store.db.execute("SELECT COUNT(*) FROM secrets").fetchone()[0] schedules = store.db.execute("SELECT COUNT(*) FROM schedules").fetchone()[0] records = store.db.execute("SELECT COUNT(*) FROM records").fetchone()[0] except sqlite3.Error: return "vault: kept %s, install ops never touch data" % db_path return "vault: kept (%d secrets, %d schedules, %d records), install ops never touch data" % (secrets, schedules, records) def op_binary(mode, home): target = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") src = os.path.abspath(__file__) current = script_version(target) if mode == "install" and current is not None: return "binary: already installed %s (running %s)" % (current, VERSION) if mode == "uninstall": if not os.path.exists(target): return "binary: not present" try: os.remove(target) return "binary: removed %s" % target except OSError as exc: return "binary: remove failed: %s" % short_error(exc) try: os.makedirs(os.path.dirname(target), exist_ok=True) with open(src, "rb") as handle: blob = handle.read() with open(target, "wb") as handle: handle.write(blob) os.chmod(target, 0o755) except OSError as exc: return "binary: copy failed: %s" % short_error(exc) if current is None: return "binary: installed %s" % VERSION return "binary: refreshed %s -> %s" % (current, VERSION) def op_hook(mode, home): path = os.path.join(os.path.expanduser("~"), ".bashrc") if mode == "uninstall": return "bash-hook: %s" % ("removed" if remove_bashrc_block(path) else "not present") if mode == "reinstall": remove_bashrc_block(path) try: changed = upsert_bashrc_block(path) except OSError as exc: return "bash-hook: write failed: %s" % short_error(exc) if changed: return "bash-hook: installed, run: source ~/.bashrc" return "bash-hook: already present" def op_plexer_hook(mode, home): path = os.path.join(os.path.expanduser("~"), ".bashrc") if mode == "uninstall": return "plexer-hook: %s" % ("removed" if remove_plexer_bashrc_block(path) else "not present") if mode == "reinstall": remove_plexer_bashrc_block(path) try: changed = upsert_plexer_bashrc_block(path) except OSError as exc: return "plexer-hook: write failed: %s" % short_error(exc) if changed: return "plexer-hook: installed, run: source ~/.bashrc (new interactive shells auto-wrap; existing ones are unaffected until restarted)" return "plexer-hook: already present" def op_shell_hook(mode, shell, label, upsert_fn, remove_fn, source_hint): """Shared body for the zsh/fish hook variants -- mirrors op_hook/op_plexer_hook exactly, plus one addition: install/upgrade skip loudly (never write a dangling hook into a config file for a shell that isn't even on this machine) when the shell binary isn't found. uninstall/reinstall/status still act on the file regardless, so a hook installed before a shell was later removed still cleans up.""" path = shell_rc_path(shell) if mode in ("install", "upgrade") and not shell_present(shell): return "%s: %s not found, skipped" % (label, shell) if mode == "uninstall": return "%s: %s" % (label, "removed" if remove_fn(path) else "not present") if mode == "reinstall": remove_fn(path) try: changed = upsert_fn(path) except OSError as exc: return "%s: write failed: %s" % (label, short_error(exc)) if changed: return "%s: installed, run: %s" % (label, source_hint) return "%s: already present" % label def op_zsh_hook(mode, home): return op_shell_hook(mode, "zsh", "zsh-hook", upsert_zsh_hook_block, remove_zsh_hook_block, "source ~/.zshrc") def op_fish_hook(mode, home): return op_shell_hook(mode, "fish", "fish-hook", upsert_fish_hook_block, remove_fish_hook_block, "open a new fish shell") def op_zsh_plexer_hook(mode, home): return op_shell_hook( mode, "zsh", "zsh-plexer-hook", upsert_zsh_plexer_block, remove_zsh_plexer_block, "source ~/.zshrc (new interactive shells auto-wrap; existing ones are unaffected until restarted)", ) def op_fish_plexer_hook(mode, home): return op_shell_hook( mode, "fish", "fish-plexer-hook", upsert_fish_plexer_block, remove_fish_plexer_block, "open a new fish shell (new interactive shells auto-wrap; existing ones are unaffected until restarted)", ) def op_venv(mode, home): if mode == "uninstall": folder = venv_dir(home) if not os.path.isdir(folder): return "venv: not present" try: shutil.rmtree(folder) return "venv: removed %s" % folder except OSError as exc: return "venv: remove failed: %s" % short_error(exc) if mode == "reinstall" and os.path.isdir(venv_dir(home)): try: shutil.rmtree(venv_dir(home)) except OSError as exc: return "venv: rebuild failed: %s" % short_error(exc) ok, detail = ensure_venv(home) return "venv: %s" % detail def op_service(mode, home, name, label, body): unit = os.path.join(unit_dir(home), name) if mode == "uninstall": _done, detail = remove_service_unit(home, name) return "%s: %s" % (label, detail) existed = os.path.isfile(unit) if mode == "install" and existed: active, enabled = service_state(name) return "%s: already installed (%s, %s)" % (label, active, enabled) if mode == "reinstall" and shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", name]) try: os.makedirs(os.path.dirname(unit), exist_ok=True) with open(unit, "w", encoding="utf-8") as handle: handle.write(body) except OSError as exc: return "%s: unit write failed: %s" % (label, short_error(exc)) return "%s: unit %s, %s" % (label, "rewritten" if existed else "written", activate_service(name)) def op_scheduler_service(mode, home): body = SCHEDULER_UNIT % service_exec(home, os.path.abspath(__file__)) return op_service(mode, home, "tai-scheduler.service", "scheduler-service", body) def op_telegram_service(mode, home): if mode == "uninstall": _done, detail = remove_service_unit(home, "tai-telegram.service") return "telegram-service: %s" % detail unit = os.path.join(unit_dir(home), "tai-telegram.service") if mode == "install" and os.path.isfile(unit): active, enabled = service_state("tai-telegram.service") return "telegram-service: already installed (%s, %s)" % (active, enabled) token = os.environ.get("TELEGRAM_BOT_TOKEN") or load_telegram_token(home) if not token: return "telegram-service: needs TELEGRAM_BOT_TOKEN in the environment" try: me = Telegram(token).call("getMe", {}) except (OSError, ValueError) as exc: return "telegram-service: token rejected: %s" % short_error(exc) env_path = telegram_env_path(home) try: with open(env_path, "w", encoding="utf-8") as handle: handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token) os.chmod(env_path, 0o600) except OSError as exc: return "telegram-service: env write failed: %s" % short_error(exc) body = TELEGRAM_UNIT % (service_exec(home, os.path.abspath(__file__)), env_path) if mode == "reinstall" and shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"]) try: os.makedirs(os.path.dirname(unit), exist_ok=True) with open(unit, "w", encoding="utf-8") as handle: handle.write(body) except OSError as exc: return "telegram-service: unit write failed: %s" % short_error(exc) who = (me.get("result") or {}).get("username", "?") return "telegram-service: @%s, %s" % (who, activate_service("tai-telegram.service")) def op_container(mode, _home): engine = container_engine() if not engine: return "container: no engine (install podman)" if mode == "uninstall": run_host([engine, "rm", "-f", BOX_NAME]) ok, _out = run_host([engine, "rmi", "-f", BOX_IMAGE]) return "container: removed%s" % (", image purged" if ok else "") if mode == "reinstall": run_host([engine, "rm", "-f", BOX_NAME]) run_host([engine, "rmi", "-f", BOX_IMAGE]) try: engine = ensure_box() except BackendError as exc: return "container: failed: %s" % exc state = box_state(engine) detail = ", %s" % box_venv_state(engine) if state == "running" else "" if mode == "reinstall": return "container: rebuilt and %s%s" % (state, detail) if mode == "install" and state == "running": return "container: already running%s" % detail return "container: %s%s" % (state, detail) def install_report(action, targets, home, store=None): if action == "status": return "\n".join(status_lines(home, store)) lines = [] for target in targets: if target == "binary": lines.append(op_binary(action, home)) elif target == "bash-hook": lines.append(op_hook(action, home)) elif target == "zsh-hook": lines.append(op_zsh_hook(action, home)) elif target == "fish-hook": lines.append(op_fish_hook(action, home)) elif target == "plexer-hook": lines.append(op_plexer_hook(action, home)) elif target == "zsh-plexer-hook": lines.append(op_zsh_plexer_hook(action, home)) elif target == "fish-plexer-hook": lines.append(op_fish_plexer_hook(action, home)) elif target == "venv": lines.append(op_venv(action, home)) elif target == "scheduler-service": lines.append(op_scheduler_service(action, home)) elif target == "telegram-service": lines.append(op_telegram_service(action, home)) elif target == "container": lines.append(op_container(action, home)) lines.append(vault_line(home, store)) return "\n".join(lines) def install_telegram(): token = os.environ.get("TELEGRAM_BOT_TOKEN") or "" if token: print("using TELEGRAM_BOT_TOKEN from environment") else: try: token = getpass.getpass("Telegram bot token: ").strip() except (EOFError, KeyboardInterrupt): print("\naborted") return 1 if not token: print("empty token, aborted") return 1 try: me = Telegram(token).call("getMe", {}) except (OSError, ValueError) as exc: print("token rejected: %s" % short_error(exc)) return 1 print("token ok for @%s" % (me.get("result") or {}).get("username", "?")) home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") os.makedirs(home, exist_ok=True) env_path = telegram_env_path(home) with open(env_path, "w", encoding="utf-8") as handle: handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token) try: os.chmod(env_path, 0o600) except OSError: pass print("wrote %s" % env_path) try: ensure_box() except BackendError as exc: print("container failed: %s" % exc) return 1 unit_folder = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") os.makedirs(unit_folder, exist_ok=True) unit_path = os.path.join(unit_folder, "tai-telegram.service") with open(unit_path, "w", encoding="utf-8") as handle: handle.write(TELEGRAM_UNIT % (service_exec(home, os.path.abspath(__file__)), env_path)) print("wrote %s" % unit_path) print("service: %s" % activate_service("tai-telegram.service")) return 0 def uninstall_telegram(): unit_path = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user", "tai-telegram.service") if shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"]) if os.path.exists(unit_path): try: os.remove(unit_path) print("removed %s" % unit_path) except OSError as exc: print("unit remove failed: %s" % short_error(exc)) else: print("no service unit found") if shutil.which("systemctl"): run_host(["systemctl", "--user", "daemon-reload"]) engine = container_engine() if not engine: print("no container engine found") return 0 ok, _out = run_host([engine, "rm", "-f", BOX_NAME]) print("container remove: %s" % ("ok" if ok else "not present")) ok, _out = run_host([engine, "rmi", "-f", BOX_IMAGE]) print("image purge: %s" % ("ok" if ok else "not present")) return 0 def upsert_bashrc_block(path): return _rc_block_upsert(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END, BASHRC_BLOCK) def install_self(): home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") print(op_binary("upgrade", home)) print(op_hook("install", home)) # bash-hook always goes in, unconditionally, for back-compat -- it's harmless clutter # in ~/.bashrc for a zsh/fish user who never touches bash. $SHELL additionally gets its # own matching hook so the one-shot CLI bootstrap (--install) actually works out of the # box on a non-bash login shell, not just when the user later runs /install zsh-hook. login_shell = os.path.basename(os.environ.get("SHELL") or "") rc = "~/.bashrc" if login_shell == "zsh": print(op_zsh_hook("install", home)) rc = "~/.zshrc" elif login_shell == "fish": print(op_fish_hook("install", home)) rc = "~/.config/fish/config.fish" print(op_venv("install", home)) print("restart your shell or run: source %s" % rc) return 0 def apply_dir_defaults(agent, directory=None): key = dir_key(directory) saved = agent.store.load_dir_defaults(key) if not saved: return "" bits = [] if saved.get("provider"): offered = set((cand["provider"] or "").lower() for cand in agent.chat.candidates()) if saved["provider"] in offered: agent.chat.provider_pin = saved["provider"] bits.append("provider=%s" % saved["provider"]) else: print(paint("dir default provider '%s' is not available here, ignoring it (@auto clears it)" % saved["provider"], Ansi.DIM), file=sys.stderr) if saved.get("model") and not getattr(agent.config, "explicit_model", ""): agent.chat.model_pin = saved["model"] bits.append("model=%s" % saved["model"]) if saved.get("bot") and saved["bot"] != agent.bot: switched = agent.switch_bot(saved["bot"], silent=True) if switched.startswith("switched"): bits.append("bot=%s" % saved["bot"]) if not bits: return "" return "dir defaults for %s: %s" % (key, " ".join(bits)) def boot(args): args.yes = args.yes or args.yolo or args.auto config = Config(args) passphrase, default_key = resolve_passphrase() try: seal = Seal(config.home, passphrase) except SealError as exc: if default_key: raise try: old = Seal(config.home, DEFAULT_PASSPHRASE) except SealError: raise exc try: seal = rotate_seal(config, old, passphrase) except (OSError, sqlite3.Error) as fail: raise SealError(str(fail)) print("seal upgraded from default key to TAI_PASSPHRASE") seal.default_key = default_key and seal.enabled store = Store(config, seal) if store.schema_notes: print("vault schema upgraded: %s" % ", ".join(store.schema_notes)) ctx = ShellSession.current() ctx.register(store) ctx.export() try: backed_up = ensure_self_backup(config, store) except (OSError, sqlite3.Error) as exc: print("warning: self-backup failed: %s" % short_error(exc)) backed_up = None if backed_up is not None: print("self-backup: %s" % os.path.basename(backed_up)) agent = Agent(config, store, yolo=args.yolo or args.auto, auto=args.auto, ctx=ctx) try: loaded = apply_dir_defaults(agent) except OSError: loaded = "" if loaded: print(paint(loaded, Ansi.DIM)) return agent # --------------------------------------------------------------------------- # # TUI framework: retained component tree, compositor, app loop. # # A tiny Textual-shaped, DOM-like UI toolkit in one section of this file: # Panel-rooted components (Panel, Text, Button, Options, Dialog, Question, # Terminal, Progress, Log), rows/cols/dock/layer layout with cells, percent, # fr and auto sizes, a cuts/chops/occlusion compositor with named layers and # z-order, bubbling events, SGR mouse input, focus chains with modal traps, # and tick-driven animations. Sub-terminals are real ptys streamed through an # in-house VT screen emulator. No dependencies beyond the standard library. # Full guide: tui.md. Live tour: /demo in the REPL or ./tai.py --demo. # --------------------------------------------------------------------------- # TUI_FPS = 30 TUI_INPUT_PAINT_GAP = 0.008 TUI_GEOMETRY = ("x", "y", "w", "h") TUI_DECLRMM = 69 TUI_CELL_KEY = operator.itemgetter(1) TUI_SWITCH_COST = 40 TUI_MOVE_COST = 8 TUI_SCROLL_SLACK = 80 TUI_MIN_ROWS = 8 TUI_MIN_COLS = 20 TUI_LAYERS = {"base": 0, "panel": 10, "dialog": 20, "toast": 30} TUI_COLORS = {"black": 0, "red": 1, "green": 2, "yellow": 3, "blue": 4, "magenta": 5, "cyan": 6, "white": 7} TUI_THEME_ROLES = ("bg", "surface", "fg", "muted", "border", "accent", "on_accent", "selection", "success", "warning", "error", "info", "bubble_in", "bubble_out", "bubble_fg", "meta_in", "meta_out", "tick_read", "service_bg", "service_fg", "unread", "unread_muted", "panel", "accent2", "accent3", "code_bg", "tool_bg") TUI_THEME_DEFAULT = "dark" class TuiTheme: """Named palette: 12 role colors plus a dark flag. Role values use the same color language as TuiStyle (names, #rrggbb, ints).""" def __init__(self, name, dark, roles): self.name = name self.dark = bool(dark) self.roles = dict(roles) def color(self, role): return self.roles.get(role) TUI_THEMES = {} def tui_register_theme(theme): missing = [role for role in TUI_THEME_ROLES if not theme.color(role)] if missing: raise ValueError("theme '%s' lacks roles: %s" % (theme.name, ", ".join(missing))) TUI_THEMES[theme.name] = theme return theme def _tui_builtin_themes(): tui_register_theme(TuiTheme("dark", True, { "bg": "#0a0e1a", "surface": "#141b2e", "fg": "#e5e9f5", "muted": "#7f8aa8", "border": "#2a3558", "accent": "#8b5cf6", "on_accent": "#ffffff", "selection": "#22305f", "success": "#34d399", "warning": "#fbbf24", "error": "#fb7185", "info": "#38bdf8", "bubble_in": "#18223d", "bubble_out": "#4338ca", "bubble_fg": "#f1f5ff", "meta_in": "#6b7aa3", "meta_out": "#c7d2fe", "tick_read": "#67e8f9", "service_bg": "#1d2750", "service_fg": "#a5b4fc", "unread": "#8b5cf6", "unread_muted": "#3a4668", "panel": "#0e1424", "accent2": "#22d3ee", "accent3": "#f472b6", "code_bg": "#0b1226", "tool_bg": "#101a33"})) tui_register_theme(TuiTheme("light", False, { "bg": "#f5f7ff", "surface": "#ffffff", "fg": "#1b2036", "muted": "#6a7494", "border": "#cdd5f0", "accent": "#6d4aff", "on_accent": "#ffffff", "selection": "#dcd6ff", "success": "#0f9d6b", "warning": "#b7791f", "error": "#d6336c", "info": "#0b7fc2", "bubble_in": "#ffffff", "bubble_out": "#dcd6ff", "bubble_fg": "#1b2036", "meta_in": "#8a93b2", "meta_out": "#6b5bd6", "tick_read": "#0b7fc2", "service_bg": "#dfe4fa", "service_fg": "#4a55a0", "unread": "#6d4aff", "unread_muted": "#b9c0dd", "panel": "#eaeefc", "accent2": "#0891b2", "accent3": "#db2777", "code_bg": "#eef1fd", "tool_bg": "#e3e8fb"})) tui_register_theme(TuiTheme("nord", True, { "bg": "#2e3440", "surface": "#3b4252", "fg": "#eceff4", "muted": "#7c8698", "border": "#4c566a", "accent": "#88c0d0", "on_accent": "#2e3440", "selection": "#434c5e", "success": "#a3be8c", "warning": "#ebcb8b", "error": "#bf616a", "info": "#81a1c1", "bubble_in": "#3b4252", "bubble_out": "#4c6a8a", "bubble_fg": "#eceff4", "meta_in": "#7c8698", "meta_out": "#88c0d0", "tick_read": "#88c0d0", "service_bg": "#4c566a", "service_fg": "#eceff4", "unread": "#88c0d0", "unread_muted": "#4c566a", "panel": "#292e39", "accent2": "#8fbcbb", "accent3": "#b48ead", "code_bg": "#272c36", "tool_bg": "#333a48"})) tui_register_theme(TuiTheme("gruvbox", True, { "bg": "#282828", "surface": "#3c3836", "fg": "#ebdbb2", "muted": "#928374", "border": "#504945", "accent": "#83a598", "on_accent": "#282828", "selection": "#458588", "success": "#b8bb26", "warning": "#fabd2f", "error": "#fb4934", "info": "#8ec07c", "bubble_in": "#3c3836", "bubble_out": "#458588", "bubble_fg": "#ebdbb2", "meta_in": "#928374", "meta_out": "#83a598", "tick_read": "#83a598", "service_bg": "#504945", "service_fg": "#ebdbb2", "unread": "#458588", "unread_muted": "#665c54", "panel": "#1d2021", "accent2": "#8ec07c", "accent3": "#d3869b", "code_bg": "#1d2021", "tool_bg": "#32302f"})) tui_register_theme(TuiTheme("ansi", True, { "bg": "black", "surface": "black", "fg": "white", "muted": "bright_black", "border": "white", "accent": "cyan", "on_accent": "black", "selection": "blue", "success": "green", "warning": "yellow", "error": "red", "info": "cyan", "bubble_in": "black", "bubble_out": "blue", "bubble_fg": "white", "meta_in": "white", "meta_out": "cyan", "tick_read": "bright_blue", "service_bg": "black", "service_fg": "white", "unread": "blue", "unread_muted": "white", "panel": "black", "accent2": "cyan", "accent3": "magenta", "code_bg": "black", "tool_bg": "black"})) _tui_builtin_themes() TUI_THEME = TUI_THEMES[TUI_THEME_DEFAULT] def tui_theme(): return TUI_THEME def tui_set_theme(name): global TUI_THEME if name not in TUI_THEMES: raise ValueError("unknown theme '%s' (available: %s)" % (name, ", ".join(sorted(TUI_THEMES)))) TUI_THEME = TUI_THEMES[name] return TUI_THEME def tui_resolve_theme_name(flag): """Precedence: --theme flag, TAI_THEME env, default. Raises ValueError naming the available themes when the pick is unknown.""" name = flag or os.environ.get("TAI_THEME") or TUI_THEME_DEFAULT if name not in TUI_THEMES: raise ValueError("unknown theme '%s' (available: %s)" % (name, ", ".join(sorted(TUI_THEMES)))) return name def tui_cycle_theme_name(current): names = list(TUI_THEMES) if current in names: return names[(names.index(current) + 1) % len(names)] return TUI_THEME_DEFAULT class TuiWatched: """Base of state objects a panel paints from. Assign one to a public panel attribute (or insert it into a list, dict or set kept there) and a later write to any of its public fields touches that panel, so a widget never shows stale cells because someone forgot touch(). Equal writes are ignored. Subclass it for your own models; fields starting with an underscore are private and never touch. Copies and pickles do not carry the owner links.""" def __setattr__(self, name, value): fields = self.__dict__ if name in fields and fields[name] == value: return fields[name] = value if name[0] != "_": self._changed() def _changed(self): owners = self.__dict__.get("_owners") if owners: for panel in list(owners): panel.touch() def adopt(self, panel): """Register a panel that paints from this object, so a field written later repaints it.""" owners = self.__dict__.get("_owners") if owners is None: owners = self.__dict__["_owners"] = weakref.WeakSet() owners.add(panel) def __getstate__(self): state = dict(self.__dict__) state.pop("_owners", None) return state def __copy__(self): clone = self.__class__.__new__(self.__class__) clone.__dict__.update(self.__getstate__()) return clone class TuiStyle(TuiWatched): """Foreground/background/attribute bundle. Colors are names ("red", "bright_cyan"), 256-color ints, "#rrggbb" strings, or (r, g, b) tuples.""" def __init__(self, fg=None, bg=None, bold=False, dim=False, underline=False, reverse=False, strike=False): fields = self.__dict__ fields["fg"] = fg fields["bg"] = bg fields["bold"] = bold fields["dim"] = dim fields["underline"] = underline fields["reverse"] = reverse fields["strike"] = strike def _changed(self): self.__dict__.pop("_key", None) TuiWatched._changed(self) def key(self): """Hashable identity of the look; cached, and any later write to a field drops the cache.""" fields = self.__dict__ key = fields.get("_key") if key is None: key = fields["_key"] = (TuiStyle.color_key(self.fg), TuiStyle.color_key(self.bg), self.bold, self.dim, self.underline, self.reverse, self.strike) return key def sgr(self): parts = ["0"] if self.bold: parts.append("1") if self.dim: parts.append("2") if self.underline: parts.append("4") if self.reverse: parts.append("7") if self.strike: parts.append("9") fg = TuiStyle.color_sgr(self.fg, False) if fg: parts.append(fg) bg = TuiStyle.color_sgr(self.bg, True) if bg: parts.append(bg) return "\x1b[" + ";".join(parts) + "m" def merged(self, other): if other is None: return self merged = TuiStyle(self.fg, self.bg, self.bold, self.dim, self.underline, self.reverse, self.strike) if other.fg is not None: merged.fg = other.fg if other.bg is not None: merged.bg = other.bg merged.bold = merged.bold or other.bold merged.dim = merged.dim or other.dim merged.underline = merged.underline or other.underline merged.reverse = merged.reverse or other.reverse merged.strike = merged.strike or other.strike return merged @staticmethod def sgr_from_key(key): """The sgr() of the style that produced key(): lets the compositor emit bytes from cell keys alone, with no style object kept alive.""" fg, bg, bold, dim, underline, reverse, strike = key parts = ["0"] if bold: parts.append("1") if dim: parts.append("2") if underline: parts.append("4") if reverse: parts.append("7") if strike: parts.append("9") fg_sgr = TuiStyle.color_sgr(fg, False) if fg_sgr: parts.append(fg_sgr) bg_sgr = TuiStyle.color_sgr(bg, True) if bg_sgr: parts.append(bg_sgr) return "\x1b[" + ";".join(parts) + "m" @staticmethod def color_key(color): if color is None or isinstance(color, int) or isinstance(color, str): return color return tuple(color) @staticmethod def color_sgr(color, background): if color is None: return "" if isinstance(color, int): return "%d;5;%d" % (48 if background else 38, max(0, min(255, color))) if isinstance(color, (tuple, list)) and len(color) == 3: return "%d;2;%d;%d;%d" % ((48 if background else 38,) + tuple(max(0, min(255, int(part))) for part in color)) if isinstance(color, str): text = color.strip().lower() if text.startswith("role:"): resolved = tui_theme().color(text[len("role:"):]) return TuiStyle.color_sgr(resolved, background) if resolved else "" if text.startswith("#") and len(text) == 7: try: rgb = tuple(int(text[pos:pos + 2], 16) for pos in (1, 3, 5)) except ValueError: return "" return "%d;2;%d;%d;%d" % ((48 if background else 38,) + rgb) bright = text.startswith("bright_") if bright: text = text[len("bright_"):] if text in TUI_COLORS: base = (90 if bright else 30) if not background else (100 if bright else 40) return str(base + TUI_COLORS[text]) return "" class TuiSpan(TuiWatched): """One styled text run: the compositor's paint primitive.""" def __init__(self, text, style=None): fields = self.__dict__ fields["text"] = text fields["style"] = style or TuiStyle() def tui_ease(name, progress): """Easing curve for animate(): progress 0..1 in, eased 0..1 out.""" t = max(0.0, min(1.0, progress)) if name == "linear": return t if name == "in_quad": return t * t if name == "out_quad": return 1.0 - (1.0 - t) * (1.0 - t) if name == "in_cubic": return t * t * t if name == "out_cubic": return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t) if name == "in_out_quad": return 2.0 * t * t if t < 0.5 else 1.0 - (-2.0 * t + 2.0) ** 2 / 2.0 return 4.0 * t * t * t if t < 0.5 else 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0 class TuiEvent: """Base event: bubble=True walks target, parent chain; stop() halts it.""" name = "event" bubble = True def __init__(self, **fields): self.__dict__.update(fields) self.target = None self.stopped = False def stop(self): self.stopped = True @property def handler_name(self): return "on_" + self.name class TuiKey(TuiEvent): name = "key" class TuiClick(TuiEvent): name = "click" class TuiScroll(TuiEvent): name = "scroll" class TuiFocus(TuiEvent): name = "focus" bubble = False class TuiBlur(TuiEvent): name = "blur" bubble = False class TuiResize(TuiEvent): name = "resize" bubble = False class TuiTick(TuiEvent): name = "tick" bubble = False class TuiModeReply(TuiEvent): """The terminal's answer to a DECRQM query (mode, state): 1 set, 2 reset, 0 unknown, 3 and 4 fixed. Consumed by the app, never delivered to panels.""" name = "mode_reply" bubble = False class TuiMount(TuiEvent): name = "mount" bubble = False class TuiUnmount(TuiEvent): name = "unmount" bubble = False class TuiSelect(TuiEvent): name = "select" class TuiPress(TuiEvent): name = "press" class TuiClose(TuiEvent): name = "close" class TuiPaste(TuiEvent): name = "paste" class TuiChange(TuiEvent): name = "change" bubble = False def tui_int(value, default=0): try: return int(value) except (TypeError, ValueError): return default def tui_flatten(spans): cells = [] for span in spans: for char in span.text: cells.append((char, span.style)) return cells def tui_runs(cells): """Merge neighbor cells with equal style into runs. Cells mostly share one style object, so identity short-circuits the key comparison.""" runs = [] last = last_key = None for char, style in cells: if style is last: runs[-1][0].append(char) continue key = style.key() last = style if runs and key == last_key: runs[-1][0].append(char) continue last_key = key runs.append([[char], style]) return [TuiSpan("".join(chars), style) for chars, style in runs] def tui_pad_spans(spans, width, fill_style): return tui_slice_spans(spans, 0, width, fill_style) def tui_span_width(spans): width = 0 for span in spans: width += len(span.text) return width def tui_slice_spans(spans, start, length, fill_style): """Columns [start, start+length) of a span row, padded with fill_style to exactly length. Works on whole spans, never per character; neighbors sharing a style object (or an equal key) merge into one run.""" start = max(0, start) length = max(0, length) end = start + length out = [] last = last_key = None pos = 0 for span in spans: if pos >= end: break text = span.text size = len(text) nxt = pos + size if nxt > start: cut_lo = start - pos if start > pos else 0 cut_hi = end - pos if end < nxt else size piece = text if cut_lo == 0 and cut_hi == size else text[cut_lo:cut_hi] if piece: style = span.style if style is last: out[-1].text += piece else: key = style.key() if out and key == last_key: out[-1].text += piece else: out.append(TuiSpan(piece, style)) last_key = key last = style pos = nxt covered = min(pos, end) - start if pos > start else 0 if covered < length: fill = " " * (length - covered) if fill_style is last: out[-1].text += fill else: key = fill_style.key() if out and key == last_key: out[-1].text += fill else: out.append(TuiSpan(fill, fill_style)) return out TUI_OBSERVE_DEPTH = 4 class TuiObserved: """Marker and shared behavior of the containers a panel keeps its state in. An in-place mutation (append, item assignment, update, ...) touches the owning panel, so a widget never shows stale cells because someone forgot touch(). Copies and pickles are plain containers: the owner link never travels.""" __slots__ = () def _adopt(self, value): """A container inserted into an observed one is observed too, down to TUI_OBSERVE_DEPTH levels.""" if self._depth < TUI_OBSERVE_DEPTH and (type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING)): return tui_observe(self._owner, value, self._depth + 1) if isinstance(value, TuiWatched) and self._owner is not None: value.adopt(self._owner) return value def _changed(self): owner = self._owner if owner is not None: owner.touch() def tui_watch(base, names): """Subclass body that runs the base method, then touches the owner.""" body = {} for name in names: def method(self, *args, _call=getattr(base, name), **kwargs): result = _call(self, *args, **kwargs) self._changed() return result method.__name__ = name body[name] = method return body class TuiList(TuiObserved, list): __slots__ = ("_owner", "_depth") def __reduce__(self): return (list, (list(self),)) def append(self, value): list.append(self, self._adopt(value)) self._changed() def insert(self, index, value): list.insert(self, index, self._adopt(value)) self._changed() def extend(self, values): list.extend(self, [self._adopt(value) for value in values]) self._changed() def __iadd__(self, values): self.extend(values) return self def __setitem__(self, index, value): if isinstance(index, slice): value = [self._adopt(item) for item in value] else: value = self._adopt(value) list.__setitem__(self, index, value) self._changed() for _name, _method in tui_watch(list, ("remove", "pop", "clear", "sort", "reverse", "__delitem__", "__imul__")).items(): setattr(TuiList, _name, _method) class TuiDict(TuiObserved, dict): __slots__ = ("_owner", "_depth") def __reduce__(self): return (dict, (dict(self),)) def __setitem__(self, key, value): dict.__setitem__(self, key, self._adopt(value)) self._changed() def setdefault(self, key, default=None): if key not in self: dict.__setitem__(self, key, self._adopt(default)) self._changed() return dict.__getitem__(self, key) def update(self, *args, **kwargs): dict.update(self, {key: self._adopt(value) for key, value in dict(*args, **kwargs).items()}) self._changed() def __ior__(self, other): self.update(other) return self for _name, _method in tui_watch(dict, ("pop", "popitem", "clear", "__delitem__")).items(): setattr(TuiDict, _name, _method) class TuiSet(TuiObserved, set): __slots__ = ("_owner", "_depth") def __reduce__(self): return (set, (set(self),)) for _name, _method in tui_watch(set, ("add", "discard", "remove", "pop", "clear", "update", "difference_update", "intersection_update", "symmetric_difference_update", "__ior__", "__iand__", "__ixor__", "__isub__")).items(): setattr(TuiSet, _name, _method) TUI_PLAIN = {list: TuiList, dict: TuiDict, set: TuiSet} TUI_OBSERVING = (TuiList, TuiDict, TuiSet) def tui_observe(owner, value, depth=0): """An observing copy of a list, dict or set (nested containers included) whose in-place mutations touch owner. The caller's original is left alone: the panel keeps the copy.""" if isinstance(value, TuiList): kind = TuiList elif isinstance(value, TuiDict): kind = TuiDict elif isinstance(value, TuiSet): kind = TuiSet else: kind = TUI_PLAIN[type(value)] nested = depth < TUI_OBSERVE_DEPTH if kind is TuiSet: copy = TuiSet(value) else: copy = kind() if kind is TuiDict: for key, item in dict.items(value): dict.__setitem__(copy, key, tui_item(owner, item, depth, nested)) else: list.extend(copy, [tui_item(owner, item, depth, nested) for item in value]) copy._depth = depth copy._owner = owner return copy def tui_item(owner, item, depth, nested): """What an observing copy keeps for one item: a nested observing copy of a container, the item itself after its owner link is set when it is a TuiWatched, else the item.""" if nested and tui_watchable(item): return tui_observe(owner, item, depth + 1) if isinstance(item, TuiWatched): item.adopt(owner) return item def tui_watchable(value): return type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING) class TuiPanel: """Base of every component: box, children, layout, paint, events. Geometry specs accept ints (cells), "50%" (of parent content box), "1fr" (share of leftover flow space), "auto" (measured content), and "center" (x/y only). flow arranges unpositioned children ("rows", "cols", or None for manual placement); dock carves an edge first; positioned children (x/y given) overlay out of flow; z plus the named layer decide paint order through the compositor.""" _seq_counter = [0] # Attribute writes on a mounted panel are the change signal. Geometry and # structure names relayout (and the layout pass diffs rects); the layout # outputs and links are bookkeeping; everything else repaints this panel # only. A scalar written back unchanged is not a change. _RELAYOUT = frozenset(("x", "y", "w", "h", "flow", "dock", "offset", "align", "valign", "padding", "gap", "border")) _RESTACK = frozenset(("layer", "z")) _QUIET = frozenset(("ax", "ay", "aw", "ah", "clip", "parent", "app", "children", "seq")) _SCALARS = (int, float, str, bool, type(None), tuple) # A panel that sets shifts = True promises that changing scroll_y by n # moves its rows n up or down and rewrites little else; the compositor # then tries a terminal scroll before rewriting cells. It is only a hint: # the compositor checks it against the real cells and falls back. shifts = False _shift = 0 def __init__(self, id=None, title="", x=None, y=None, w="auto", h="auto", flow="rows", dock=None, layer=None, z=0, offset=None, align="left", valign="top", padding=0, gap=0, border=False, border_style=None, style=None, title_style=None, rounded=False, focus_border_style=None, visible=True, focusable=False, enabled=True, modal=False, scrollable=False): TuiPanel._seq_counter[0] += 1 self._cells = None self._cells_size = None self.seq = TuiPanel._seq_counter[0] self.id = id self.title = title or "" self.x = x self.y = y self.w = w self.h = h self.flow = flow self.dock = dock self.layer = layer self.z = z self.offset = offset or (0, 0) self.align = align self.valign = valign self.padding = TuiPanel.norm_padding(padding) self.gap = max(0, tui_int(gap)) self.border = bool(border) self.border_style = border_style or TuiStyle(fg="white") self.rounded = bool(rounded) self.focus_border_style = focus_border_style self.style = style or TuiStyle() self.title_style = title_style or TuiStyle(bold=True) self.visible = visible self.focusable = focusable self.enabled = enabled self.modal = modal self.scrollable = scrollable self.scroll_x = 0 self.scroll_y = 0 self.children = [] self.parent = None self.app = None self.ax = 0 self.ay = 0 self.aw = 0 self.ah = 0 self.clip = (0, 0, 0, 0) self._arrange = None self._geom = None self._measure_seen = None self._pending_anims = [] def __setattr__(self, name, value): fields = self.__dict__ old = fields.get(name, self) if name[0] != "_" and name not in TuiPanel._QUIET: kind = type(value) if kind in TUI_PLAIN: value = tui_observe(self, value) elif kind in TuiPanel._SCALARS: pass elif kind in TUI_OBSERVING and value._owner is not self: value = tui_observe(self, value) elif isinstance(value, TuiWatched): value.adopt(self) object.__setattr__(self, name, value) if name[0] == "_" or name in TuiPanel._QUIET: return app = fields.get("app") if app is None: return if name == "scroll_y" and self.shifts and type(old) is int and type(value) is int: self._shift += value - old if type(old) is type(value): if type(value) in TuiPanel._SCALARS: if old == value: return elif type(value) is TuiStyle and old.key() == value.key(): return if name[:3] == "on_": if name == "on_tick": app.tree_changed() elif name in TuiPanel._RELAYOUT: app.relayout(self) self.touch() elif name == "visible": self.forget_measure() app.relayout(self, restack=True) elif name == "scroll_x" or name == "scroll_y": if not self._scroll_in_range(name, value): app.relayout(self, only=True) self.touch() elif name in TuiPanel._RESTACK: app.restack() else: self.touch() def _scroll_in_range(self, name, value): """True when a scroll offset write stays inside the range the last arrange measured, so nothing needs clamping.""" seen = self._measure_seen if seen is None or type(value) is not int: return False if name == "scroll_x": return 0 <= value <= max(0, seen[0] - self._content_w()) return 0 <= value <= max(0, seen[1] - self._content_h()) def touch(self): """Mark this panel's painted cells stale; only its rect repaints.""" self._cells = None self.forget_measure() if self.app is not None: self.app.damage(self) @staticmethod def norm_padding(padding): if isinstance(padding, (tuple, list)): if len(padding) == 2: return (tui_int(padding[0]), tui_int(padding[1]), tui_int(padding[0]), tui_int(padding[1])) if len(padding) == 4: return tuple(tui_int(part) for part in padding) value = tui_int(padding) return (value, value, value, value) def add(self, *children): for child in children: child.parent = self self.children.append(child) if self.app is not None: child._bind_app(self.app) self.forget_measure() if self.app is not None: self.app.relayout(self, inner=True, restack=True) return children[0] if len(children) == 1 else children def remove(self, child): if child in self.children: self.children.remove(child) child.parent = None child._unbind_app() self.forget_measure() if self.app is not None: self.app.relayout(self, inner=True, restack=True) return child def clear(self): for child in list(self.children): self.remove(child) def walk(self): yield self for child in self.children: for node in child.walk(): yield node def find(self, id): for node in self.walk(): if node.id == id: return node return None def root(self): node = self while node.parent is not None: node = node.parent return node def depth(self): depth = 0 node = self while node.parent is not None: node = node.parent depth += 1 return depth def is_shown(self): node = self while node is not None: if not node.visible: return False node = node.parent return True def effective_z(self): return TUI_LAYERS.get(self.layer, 0) + tui_int(self.z) def chrome_w(self): pad_top, pad_right, pad_bottom, pad_left = self.padding return (2 if self.border else 0) + pad_left + pad_right def chrome_h(self): pad_top, pad_right, pad_bottom, pad_left = self.padding return (2 if self.border else 0) + pad_top + pad_bottom def _bind_app(self, app): self.app = app self._cells = None for anim in self._pending_anims: app.animations.append(anim) self._pending_anims = [] self.emit(TuiMount(panel=self)) for child in self.children: child._bind_app(app) def _unbind_app(self): self.emit(TuiUnmount(panel=self)) for child in self.children: child._unbind_app() self.app = None # -- events ------------------------------------------------------ # def dispatch(self, event): node = self first = True while node is not None: if first or (event.bubble and not event.stopped): handler = getattr(node, event.handler_name, None) if callable(handler): handler(event) if not event.bubble or event.stopped: break node = node.parent first = False return event def emit(self, event): event.target = self return self.dispatch(event) # -- geometry ---------------------------------------------------- # @staticmethod def _fr_weight(spec): if isinstance(spec, str) and spec.endswith("fr"): try: return max(0.0, float(spec[:-2] or 1)) except ValueError: return 0.0 return 0.0 @staticmethod def _pct(spec, total): if isinstance(spec, str) and spec.endswith("%"): try: return int(total * float(spec[:-1]) / 100.0) except ValueError: return 0 return None def _fixed_len(self, spec, total, content_len): if isinstance(spec, int): return max(0, spec) if isinstance(spec, str): pct = TuiPanel._pct(spec, total) if pct is not None: return max(0, pct) if spec == "auto": return max(0, content_len) return 0 _measure_memo = None memoize_measure = True _mcache = None _stale = None _arranged = None _owes_arrange = False def mark_pending(self): """Flag this panel and its ancestors as owing an arrange. The flag stays until arrange() reaches the panel, so a change made under a hidden ancestor is still applied when it shows again.""" node = self while node is not None: node._owes_arrange = True node = node.parent def measured(self): """measure() once per layout pass: intrinsic sizes depend on content only, so the repeated asks of one pass (parent, flow, cross axis, scroll clamp) share one answer. Between passes the answer is kept until a touch of this panel or any descendant drops it, so a partial pass does not re-measure subtrees nobody changed. Outside a pass, a plain measure().""" memo = TuiPanel._measure_memo if memo is None or not self.memoize_measure: return self.measure() size = memo.get(self) if size is None: size = self._mcache if TuiPanel._stale is not None else None if size is None: size = self.measure() self._mcache = size memo[self] = size return size def forget_measure(self): """Drop the kept measure of this panel and of every ancestor.""" node = self while node is not None: node._mcache = None node = node.parent def measure(self): """Intrinsic content size (w, h), chrome excluded.""" widths = [] heights = [] flow_w = 0 flow_h = 0 shown = [child for child in self.children if child.visible] for child in shown: cw, ch = child.measured() outer_w = child._fixed_len(child.w, 0, cw) + child.chrome_w() outer_h = child._fixed_len(child.h, 0, ch) + child.chrome_h() if child.dock or child.x is not None or child.y is not None: widths.append(outer_w) heights.append(outer_h) elif self.flow == "cols": flow_w += outer_w heights.append(outer_h) else: flow_h += outer_h widths.append(outer_w) gaps = self.gap * max(0, len([child for child in shown if not child.dock and child.x is None and child.y is None]) - 1) if self.flow == "cols": return (max(widths + [flow_w + gaps]), max(heights + [0])) return (max(widths + [0]), max(heights + [flow_h + gaps])) def content_sized(self): """True when this panel's outer size follows its content, so a change inside it can change what its parent lays out or measures. A panel with a fixed, percent or fr size on both axes is a layout boundary: relayout stops there. (The parent's measure() reads both axes of every child, which is why one auto axis is enough.)""" return self.parent is not None and (self.w == "auto" or self.h == "auto") def arrange(self, x, y, w, h, clip): if TuiPanel._stale is not None: if not self._owes_arrange and self._arrange == (x, y, w, h, clip): return TuiPanel._arranged.append(self) self._owes_arrange = False self._arrange = (x, y, w, h, clip) ox, oy = self.offset self.ax = x + tui_int(ox) self.ay = y + tui_int(oy) self.aw = max(0, tui_int(w)) self.ah = max(0, tui_int(h)) self.clip = TuiPanel.intersect(clip, (self.ax, self.ay, self.ax + self.aw, self.ay + self.ah)) pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 box = [self.ax + edge + pad_left, self.ay + edge + pad_top, max(0, self.aw - edge * 2 - pad_left - pad_right), max(0, self.ah - edge * 2 - pad_top - pad_bottom)] shown = [child for child in self.children if child.visible] docked = [child for child in shown if child.dock] floating = [child for child in shown if not child.dock and (child.x is not None or child.y is not None)] flowing = [child for child in shown if not child.dock and child.x is None and child.y is None] for child in docked: self._place_dock(child, box) if self.flow is None: for child in flowing: cw, ch = child.measured() child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() child.arrange(box[0], box[1], child_w, child_h, self._content_clip()) elif self.flow == "cols": self._place_flow(flowing, box, horizontal=True) else: self._place_flow(flowing, box, horizontal=False) for child in floating: self._place_float(child, box) self._clamp_scroll() @staticmethod def intersect(first, second): return (max(first[0], second[0]), max(first[1], second[1]), min(first[2], second[2]), min(first[3], second[3])) def _content_clip(self): pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 inner = (self.ax + edge + pad_left, self.ay + edge + pad_top, self.ax + self.aw - edge - pad_right, self.ay + self.ah - edge - pad_bottom) return TuiPanel.intersect(self.clip, inner) def _place_dock(self, child, box): cw, ch = child.measured() side = (child.dock or "").lower() if side in ("top", "bottom"): child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() child_h = min(child_h, box[3]) at_y = box[1] if side == "top" else box[1] + box[3] - child_h child.arrange(box[0], at_y, box[2], child_h, self._content_clip()) if side == "top": box[1] += child_h box[3] -= child_h else: child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_w = min(child_w, box[2]) at_x = box[0] if side != "right" else box[0] + box[2] - child_w child.arrange(at_x, box[1], child_w, box[3], self._content_clip()) if side != "right": box[0] += child_w box[2] -= child_w def _place_flow(self, flowing, box, horizontal): total = box[2] if horizontal else box[3] measures = [child.measured() for child in flowing] sizes = [] fr_total = 0.0 fixed = 0 for child, (cw, ch) in zip(flowing, measures): spec = child.w if horizontal else child.h weight = TuiPanel._fr_weight(spec) if weight > 0: sizes.append(None) fr_total += weight else: content = cw if horizontal else ch size = child._fixed_len(spec, total, content) + (child.chrome_w() if horizontal else child.chrome_h()) sizes.append(size) fixed += size gaps = self.gap * max(0, len(flowing) - 1) leftover = max(0, total - fixed - gaps) if fr_total > 0: shares = [] for child, size in zip(flowing, sizes): if size is None: exact = leftover * TuiPanel._fr_weight(child.w if horizontal else child.h) / fr_total shares.append([int(exact), exact - int(exact)]) given = sum(share[0] for share in shares) order = sorted(range(len(shares)), key=lambda pos: shares[pos][1], reverse=True) for pos in order[:leftover - given]: shares[pos][0] += 1 it = iter(share[0] for share in shares) sizes = [size if size is not None else next(it) for size in sizes] else: sizes = [size or 0 for size in sizes] used = sum(sizes) + gaps cursor = box[0] if horizontal else box[1] main_align = self.align if horizontal else self.valign if main_align in ("center", "middle"): cursor += max(0, (total - used) // 2) elif main_align in ("right", "bottom"): cursor += max(0, total - used) for child, size in zip(flowing, sizes): if horizontal: child_w, child_h = size, self._cross_size(child, box, horizontal) at_x, at_y = cursor, self._cross_pos(child_h, box, horizontal) cursor += size + self.gap else: child_h, child_w = size, self._cross_size(child, box, horizontal) at_y, at_x = cursor, self._cross_pos(child_w, box, horizontal) cursor += size + self.gap child.arrange(at_x, at_y, child_w, child_h, self._content_clip()) def _cross_size(self, child, box, horizontal): cw, ch = child.measured() if horizontal: spec = child.h total, content = box[3], ch else: spec = child.w total, content = box[2], cw if TuiPanel._fr_weight(spec) > 0: return total if spec == "auto" and not horizontal: return total if isinstance(spec, int) or (isinstance(spec, str) and (spec.endswith("%") or spec == "auto")): return child._fixed_len(spec, total, content) + (child.chrome_h() if horizontal else child.chrome_w()) return total def _cross_pos(self, child_size, box, horizontal): if horizontal: cross_align = self.valign start, total = box[1], box[3] else: cross_align = self.align start, total = box[0], box[2] if cross_align in ("center", "middle"): return start + max(0, (total - child_size) // 2) if cross_align in ("right", "bottom"): return start + max(0, total - child_size) return start def _place_float(self, child, box): cw, ch = child.measured() child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() at_x = box[0] + self._place_pos(child.x, box[2], child_w) at_y = box[1] + self._place_pos(child.y, box[3], child_h) child.arrange(at_x, at_y, child_w, child_h, self._content_clip()) @staticmethod def _place_pos(spec, total, size): if spec is None: return 0 if isinstance(spec, int): return spec if spec >= 0 else total - size + spec if isinstance(spec, str): if spec == "center": return max(0, (total - size) // 2) pct = TuiPanel._pct(spec, total) if pct is not None: return pct return 0 def _clamp_scroll(self): cw, ch = self.measured() self._measure_seen = (cw, ch) self.scroll_x = max(0, min(self.scroll_x, max(0, cw - self._content_w()))) self.scroll_y = max(0, min(self.scroll_y, max(0, ch - self._content_h()))) def content_rect(self): """Screen rect (x0, y0, x1, y1) of the content area, clipped.""" edge = 1 if self.border else 0 pad_top, pad_right, pad_bottom, pad_left = self.padding x0, y0, x1, y1 = self.clip return (max(x0, self.ax + edge + pad_left), max(y0, self.ay + edge + pad_top), min(x1, self.ax + self.aw - edge - pad_right), min(y1, self.ay + self.ah - edge - pad_bottom)) def _content_w(self): return max(0, self.aw - self.chrome_w()) def _content_h(self): return max(0, self.ah - self.chrome_h()) def scroll_by(self, dx, dy): self.scroll_x += dx self.scroll_y += dy self._clamp_scroll() if self.app is not None: self.touch() def on_scroll(self, event): if self.scrollable and self.enabled: event.stop() if event.direction == "up": self.scroll_by(0, -3) elif event.direction == "down": self.scroll_by(0, 3) elif event.direction == "left": self.scroll_by(-3, 0) elif event.direction == "right": self.scroll_by(3, 0) # -- paint ------------------------------------------------------- # def content_rows(self): """Content-area rows as span lists; subclasses override.""" width = self._content_w() fill = [TuiSpan(" " * width, self.style)] if width else [] return [list(fill) for _row in range(self._content_h())] def paint(self): width = self._content_w() height = self._content_h() rows = [] content = self.content_rows() scroll_x = self.scroll_x scroll_y = self.scroll_y for row_pos in range(height): source = content[scroll_y + row_pos] if scroll_y + row_pos < len(content) else [] if scroll_x == 0 and source and tui_span_width(source) == width: rows.append(source) else: rows.append(tui_slice_spans(source, scroll_x, width, self.style)) pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 if pad_left or pad_right: left = [TuiSpan(" " * pad_left, self.style)] if pad_left else [] right = [TuiSpan(" " * pad_right, self.style)] if pad_right else [] rows = [left + spans + right for spans in rows] inner_w = width + pad_left + pad_right blank = [TuiSpan(" " * inner_w, self.style)] if inner_w else [] rows = [list(blank) for _row in range(pad_top)] + rows + [list(blank) for _row in range(pad_bottom)] if edge: rows = self._add_border(rows, inner_w) while len(rows) < self.ah: rows.append([TuiSpan(" " * self.aw, self.style)] if self.aw else []) rows = rows[:self.ah] if self.aw < self.chrome_w() + 2: rows = [tui_pad_spans(spans, self.aw, self.style) for spans in rows] return rows def paint_cells(self, compositor): """The panel's cell rows, exactly aw wide and ah tall. The default converts paint(); a widget whose rows are stable between paints overrides it to reuse them (TuiLog).""" return compositor.cells_from(self.paint(), self.aw) def _add_border(self, rows, inner_w): tl, tr, bl, br = "╭╮╰╯" if self.rounded else "┌┐└┘" line = self.border_style if self.focus_border_style is not None and self.app is not None and self.app.focused is self: line = self.focus_border_style top = [TuiSpan(tl + "─", line)] title = " %s " % self.title.strip() if self.title.strip() else "" room = max(0, inner_w - 2) if len(title) > room: title = title[:max(0, room - 1)] + ("…" if room else "") top.append(TuiSpan(title, self.title_style)) top.append(TuiSpan("─" * max(0, room - len(title) + 1) + tr, line)) bottom = [TuiSpan(bl + "─" * inner_w + br, line)] framed = [top] for spans in rows: framed.append([TuiSpan("│", line)] + list(spans) + [TuiSpan("│", line)]) framed.append(bottom) return framed # -- animation --------------------------------------------------- # def anim_start(self, prop): """The number an animation of `prop` starts from. x, y, w and h may be specs (a percent, "center", "auto", a negative offset), so they start from where the panel was last placed, which is what the eye sees.""" value = getattr(self, prop, 0) if prop not in TUI_GEOMETRY: return float(value or 0) if not hasattr(self, "ax"): return float(value) if isinstance(value, (int, float)) else 0.0 if prop == "w": return float(self.aw - self.chrome_w()) if prop == "h": return float(self.ah - self.chrome_h()) parent = self.parent if parent is None: return float(value) if isinstance(value, (int, float)) else 0.0 edge = 1 if parent.border else 0 pad = parent.padding ox, oy = self.offset if prop == "x": return float(self.ax - tui_int(ox) - (parent.ax + edge + pad[3])) return float(self.ay - tui_int(oy) - (parent.ay + edge + pad[0])) def animate(self, prop, to, duration=None, speed=None, easing="in_out_cubic", delay=0.0, on_done=None): if duration is None: distance = abs(float(to) - self.anim_start(prop)) duration = distance / float(speed) if speed else 0.4 anim = {"panel": self, "prop": prop, "to": float(to), "duration": max(0.001, float(duration)), "easing": easing, "delay": max(0.0, float(delay)), "on_done": on_done, "started": None, "from": None} if self.app is not None: self.app.animations.append(anim) self.app.wake() else: self._pending_anims.append(anim) return anim def focus(self): if self.app is not None: self.app.set_focus(self) def blur(self): if self.app is not None and self.app.focused is self: self.app.set_focus(None) def close(self): if self.app is not None: self.app.close_panel(self) class TuiRows(TuiPanel): """Vertical flow container.""" def __init__(self, *children, **kwargs): kwargs.setdefault("flow", "rows") super().__init__(**kwargs) if children: self.add(*children) class TuiCols(TuiPanel): """Horizontal flow container.""" def __init__(self, *children, **kwargs): kwargs.setdefault("flow", "cols") super().__init__(**kwargs) if children: self.add(*children) TUI_SYNC_START = "\x1b[?2026h" TUI_SYNC_END = "\x1b[?2026l" TUI_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h" TUI_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l" TUI_ALT_ENTER = "\x1b[?1049h" TUI_ALT_LEAVE = "\x1b[?1049l" TUI_KITTY_ENABLE = "\x1b[>10u" TUI_KITTY_DISABLE = "\x1b[ (char, key). Equal cells are the same object, so frame diffs short-circuit on identity.""" def __init__(self, key): super().__init__() self.key = key def __missing__(self, char): if len(self) >= TUI_POOL_CHARS: self.clear() cell = self[char] = (char, self.key) return cell class TuiCompositor: """Lazy compositor. Panels cache their painted cells (touch() drops one), a frame is built front to back with per-row coverage so every cell is written once and a fully covered panel is never painted, and the screen it believes is shown persists, so an update recomposes and diffs only the damaged rects. compose_reference is the original full algorithm, kept as the oracle the lazy path is verified against. Output is one single-write frame in synchronized-output markers: atomic where the terminal honors it, ignored everywhere else.""" def __init__(self): self.last = None self.pool = {} self.sgr_memo = {} self.sgr_theme = None self.stats = {"painted": 0, "composed": 0, "emitted": 0, "frames": 0, "scrolled": 0} self.margins = False def invalidate(self): self.last = None self.sgr_memo = {} def flatten(self, root): panels = [node for node in root.walk() if node.is_shown() and node.aw > 0 and node.ah > 0] panels.sort(key=TuiCompositor.stack_key) return panels @staticmethod def stack_key(node): parts = [] while node is not None: parts.append((node.effective_z(), node.seq)) node = node.parent return tuple(reversed(parts)) # -- cells ------------------------------------------------------- # def sgr(self, key): theme = tui_theme() if theme is not self.sgr_theme or len(self.sgr_memo) >= TUI_SGR_CAP: self.sgr_memo = {} self.sgr_theme = theme code = self.sgr_memo.get(key) if code is None: code = self.sgr_memo[key] = TuiStyle.sgr_from_key(key) return code def cells_from(self, painted, width): """Span rows -> cell rows of exactly width interned cells.""" pool = self.pool if len(pool) >= TUI_POOL_KEYS: pool.clear() keys = {} out = [] blank = [TUI_BLANK] * width for spans in painted: row = [] for span in spans: style = span.style key = keys.get(id(style)) if key is None: key = keys[id(style)] = style.key() chars = pool.get(key) if chars is None: chars = pool[key] = TuiCellPool(key) row.extend(map(chars.__getitem__, span.text)) if len(row) < width: row.extend(blank[:width - len(row)]) out.append(row) return out def cell(self, char, key): """The interned cell of one char in one style key.""" pool = self.pool chars = pool.get(key) if chars is None: if len(pool) >= TUI_POOL_KEYS: pool.clear() chars = pool[key] = TuiCellPool(key) return chars[char] def cells_of(self, panel, cached): """The panel's cell rows, painting only when stale. None when the widget's paint raised: it then covers nothing this frame and never garbles the rest.""" size = (panel.aw, panel.ah) if cached and panel._cells is not None and panel._cells_size == size: return panel._cells try: cells = panel.paint_cells(self) except Exception: return None self.stats["painted"] += 1 if cached: panel._cells = cells panel._cells_size = size return cells # -- compose ----------------------------------------------------- # @staticmethod def gaps(covered, lo, hi): """Sub-intervals of [lo, hi) not yet claimed in this row.""" found = [] pos = lo for start, end in covered: if end <= pos: continue if start >= hi: break if start > pos: found.append((pos, start)) pos = end if pos >= hi: return found if pos < hi: found.append((pos, hi)) return found @staticmethod def claim(covered, lo, hi): covered.append((lo, hi)) covered.sort() merged = [covered[0]] for start, end in covered[1:]: if start <= merged[-1][1]: if end > merged[-1][1]: merged[-1] = (merged[-1][0], end) else: merged.append((start, end)) covered[:] = merged def fill(self, stack, x0, y0, x1, y1, cached): """Cell rows for the rect [x0,x1) x [y0,y1), topmost panel first. A panel only paints when some of its rows are still uncovered.""" width = x1 - x0 rows = [[TUI_BLANK] * width for _y in range(y0, y1)] covers = [[] for _y in range(y0, y1)] left = width * (y1 - y0) for panel in reversed(stack): if left <= 0: break cx0, cy0, cx1, cy1 = panel.clip cx0, cy0, cx1, cy1 = max(cx0, x0), max(cy0, y0), min(cx1, x1), min(cy1, y1) if cx0 >= cx1 or cy0 >= cy1: continue todo = [] for y in range(cy0, cy1): free = TuiCompositor.gaps(covers[y - y0], cx0, cx1) if free: todo.append((y, free)) if not todo: continue cells = self.cells_of(panel, cached) if cells is None: continue origin = panel.ax for y, free in todo: local = y - panel.ay if local < 0 or local >= len(cells): continue source = cells[local] target = rows[y - y0] for start, end in free: target[start - x0:end - x0] = source[start - origin:end - origin] left -= end - start TuiCompositor.claim(covers[y - y0], cx0, cx1) self.stats["composed"] += width * (y1 - y0) return rows def compose(self, root, rows, cols): """One whole frame from a fresh paint of the tree (no caches).""" return self.fill(self.flatten(root), 0, 0, cols, rows, False) def compose_reference(self, root, rows, cols): """The original whole-tree algorithm: paint everything bottom to top, cut every row at run edges, keep the topmost run per interval. Slow on purpose and never used for output: it is the oracle the lazy pipeline is verified against (tests, TAI_TUI_VERIFY).""" panels = self.flatten(root) runs_by_row = {} for panel in panels: try: painted = panel.paint() except Exception: continue # one broken widget garbles its own rect, never the frame x0, y0, x1, y1 = panel.clip x0 = max(0, x0) y0 = max(0, y0) x1 = min(cols, x1) y1 = min(rows, y1) if x0 >= x1 or y0 >= y1: continue for local, spans in enumerate(painted): ay = panel.ay + local if ay < y0 or ay >= y1: continue runs_by_row.setdefault(ay, []).append((max(panel.ax, x0), min(panel.ax + panel.aw, x1), panel.ax, spans)) frame = [[(" ", (None, None, False, False, False, False, False)) for _x in range(cols)] for _y in range(rows)] styles = {} keys = {} for ay, runs in runs_by_row.items(): cuts = set() for x0, x1, _origin, _spans in runs: cuts.add(x0) cuts.add(x1) edges = sorted(cuts) for pos in range(len(edges) - 1): start, end = edges[pos], edges[pos + 1] if start >= end: continue winner = None for run in runs: if run[0] <= start and run[1] >= end: winner = run if winner is None: continue _x0, _x1, origin, spans = winner out_row = frame[ay] col = origin for span in spans: text = span.text nxt = col + len(text) lo = max(col, start) hi = min(nxt, end) if lo < hi: style = span.style key = keys.get(id(style)) if key is None: key = keys[id(style)] = style.key() styles[key] = style out_row[lo:hi] = [(char, key) for char in text[lo - col:hi - col]] col = nxt if col >= end: break return frame # -- output ------------------------------------------------------ # def diff_row(self, out, y, x0, new, old, current): """Append the moves and glyphs that turn old into new for one row segment starting at column x0. Returns the style now selected.""" count = len(new) col = 0 while col < count: if new[col] == old[col]: col += 1 continue start = col while col < count and new[col] != old[col]: col += 1 out.append("\x1b[%d;%dH" % (y + 1, x0 + start + 1)) for char, key in new[start:col]: if key != current: out.append(self.sgr(key)) current = key out.append(char) self.stats["emitted"] += col - start return current def refresh(self, stack, rows, cols, rects, shifts=()): """Bring the believed-shown screen up to date. rects None means the whole screen (first frame, resize, invalidate), else the damaged rects (x0, y0, x1, y1); shifts are scroll hints (x0, y0, x1, y1, dy) for panels whose scroll_y moved. Returns only the bytes that changed.""" self.stats["frames"] += 1 out = [] current = None if self.last is None or len(self.last) != rows or len(self.last[0]) != cols: self.last = [[(None, None)] * cols for _y in range(rows)] rects = None if rects is None: rects = [(0, 0, cols, rows)] shifts = () fresh = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) for rect in TuiCompositor.merge_rects(rects, cols, rows)] if shifts: self.scroll(stack, shifts, fresh, cols, rows, out) current = None for (x0, y0, x1, y1), block in fresh: for offset, row in enumerate(block): shown = self.last[y0 + offset] if row == shown[x0:x1]: continue current = self.diff_row(out, y0 + offset, x0, row, shown[x0:x1], current) shown[x0:x1] = row return "".join(out) @staticmethod def cost(pairs): """Bytes-ish cost of turning each old row segment into its new one, counted the way diff_row writes it: the changed cells, a cursor move per run of changed cells, and a style switch per change of style along the changed cells (all in C-level passes over the row).""" total = 0 for new, old in pairs: if new == old: continue mask = list(map(operator.ne, new, old)) changed = sum(mask) runs = sum(map(operator.gt, mask, [False] + mask)) keys = list(map(TUI_CELL_KEY, itertools.compress(new, mask))) switches = sum(map(operator.ne, keys, keys[1:])) + 1 total += changed + TUI_MOVE_COST * runs + TUI_SWITCH_COST * switches return total def scroll(self, stack, shifts, fresh, cols, rows, out): """Turn scroll hints into terminal scrolls where that writes less than rewriting cells. A hint (x0, y0, x1, y1, dy) says the rows of that rect moved up by dy (down when negative). It is checked against the cells that are really there: new rows equal to the old rows moved by dy, and the cost of every cell the scroll would leave wrong (the strips left and right of the rect move with it) is counted exactly. Taken, it emits a scroll of the whole rows y0..y1 (margins, then index or reverse index per row), moves the believed screen the same way and queues the strips for the normal diff, which repairs them. That diff is what keeps this correct: it compares the new content with the believed screen wherever anything may differ.""" for x0, y0, x1, y1, dy in shifts[:TUI_SCROLLS]: x0, y0, x1, y1 = max(0, x0), max(0, y0), min(cols, x1), min(rows, y1) height = y1 - y0 size = abs(dy) if x0 >= x1 or height < 3 or size >= height: continue owner = None for (rx0, ry0, rx1, ry1), block in fresh: if rx0 <= x0 and ry0 <= y0 and rx1 >= x1 and ry1 >= y1: owner = (rx0, ry0, block) break if owner is None: continue rx0, ry0, block = owner last = self.last new_rows = [row[x0 - rx0:x1 - rx0] for row in block[y0 - ry0:y1 - ry0]] if dy > 0: moved = sum(1 for pos in range(height - size) if new_rows[pos] == last[y0 + pos + size][x0:x1]) else: moved = sum(1 for pos in range(size, height) if new_rows[pos] == last[y0 + pos - size][x0:x1]) if moved < 2: continue bounded = self.margins and (x0 > 0 or x1 < cols) strips = [] if bounded else [(0, y0, x0, y1)] if x0 > 0 else [] if x1 < cols and not bounded: strips.append((x1, y0, cols, y1)) strip_blocks = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) for rect in strips] blank = [TUI_BLANK] * cols def moved_row(y): """What the terminal would show in row y after the scroll.""" src = y + size if dy > 0 else y - size if y0 <= src < y1: return last[src] if not bounded else last[y][:x0] + last[src][x0:x1] + last[y][x1:] return blank if not bounded else last[y][:x0] + blank[x0:x1] + last[y][x1:] now = TuiCompositor.cost((new, last[y0 + pos][x0:x1]) for pos, new in enumerate(new_rows)) then = TuiCompositor.cost((new, moved_row(y0 + pos)[x0:x1]) for pos, new in enumerate(new_rows)) for (sx0, _sy0, sx1, _sy1), sblock in strip_blocks: then += TuiCompositor.cost((new, moved_row(y0 + pos)[sx0:sx1]) for pos, new in enumerate(sblock)) if then + TUI_SCROLL_SLACK + 2 * size >= now: continue opening = "\x1b[0m\x1b[%d;%dr" % (y0 + 1, y1) closing = "\x1b[r" column = 1 if bounded: opening += "\x1b[?%dh\x1b[%d;%ds" % (TUI_DECLRMM, x0 + 1, x1) closing = "\x1b[?%dl" % TUI_DECLRMM + closing column = x0 + 1 if dy > 0: out.append("%s\x1b[%d;%dH%s%s" % (opening, y1, column, "\x1bD" * size, closing)) else: out.append("%s\x1b[%d;%dH%s%s" % (opening, y0 + 1, column, "\x1bM" * size, closing)) if bounded: last[y0:y1] = [moved_row(y) for y in range(y0, y1)] elif dy > 0: last[y0:y1] = last[y0 + size:y1] + [[TUI_BLANK] * cols for _row in range(size)] else: last[y0:y1] = [[TUI_BLANK] * cols for _row in range(size)] + last[y0:y1 - size] self.stats["scrolled"] += height fresh.extend(strip_blocks) @staticmethod def merge_rects(rects, cols, rows): """Clamp to the screen and drop rects contained in another.""" clamped = [] for x0, y0, x1, y1 in rects: x0, y0, x1, y1 = max(0, x0), max(0, y0), min(cols, x1), min(rows, y1) if x0 < x1 and y0 < y1: clamped.append((x0, y0, x1, y1)) clamped.sort(key=lambda r: (r[2] - r[0]) * (r[3] - r[1]), reverse=True) kept = [] for rect in clamped: if not any(big[0] <= rect[0] and big[1] <= rect[1] and big[2] >= rect[2] and big[3] >= rect[3] for big in kept): kept.append(rect) return kept def render(self, frame): """Diff a whole frame against what is shown and wrap it for the terminal.""" out = [TUI_SYNC_START, "\x1b[?25l"] if self.last is None: self.last = [[(None, None) for _cell in row] for row in frame] current = None for row_pos, (row, old_row) in enumerate(zip(frame, self.last)): current = self.diff_row(out, row_pos, 0, row, old_row, current) out.append("\x1b[0m") self.last = [[cell for cell in row] for row in frame] return "".join(out) @staticmethod def render_text(frame): return "\n".join("".join(char for char, _key in row).rstrip() for row in frame) @staticmethod def place_cursor(x, y, visible): if visible: return "\x1b[%d;%dH\x1b[?25h" % (y + 1, x + 1) return "\x1b[?25l" class TuiHitMap: """Cell-grid index over painted panels so hit-testing stays cheap no matter how many layers stack up. Lazy: a layout only hands over its stack; the grid is built by the first click or wheel event after it, so frames with no mouse input never pay for it.""" CELL_W = 8 CELL_H = 4 def __init__(self): self.grid = {} self.pending = None def rebuild(self, panels): self.pending = list(panels) def build(self): grid = {} for panel in self.pending: x0, y0, x1, y1 = panel.clip if x0 >= x1 or y0 >= y1: continue for gy in range(y0 // self.CELL_H, (y1 - 1) // self.CELL_H + 1): for gx in range(x0 // self.CELL_W, (x1 - 1) // self.CELL_W + 1): grid.setdefault((gx, gy), []).append(panel) self.grid = grid self.pending = None def at(self, x, y): if self.pending is not None: self.build() for panel in reversed(self.grid.get((x // self.CELL_W, y // self.CELL_H), [])): if not panel.is_shown(): continue x0, y0, x1, y1 = panel.clip if x0 <= x < x1 and y0 <= y < y1: return panel return None def tui_utf8_head(buf): """Decode one char from head of buf. Returns (char, length, need_more).""" first = buf[0] if first < 0x80: return (chr(first), 1, False) if 0xC2 <= first <= 0xDF: need = 2 elif 0xE0 <= first <= 0xEF: need = 3 elif 0xF0 <= first <= 0xF4: need = 4 else: return ("�", 1, False) if len(buf) < need: return (None, 0, True) try: return (bytes(buf[:need]).decode("utf-8"), need, False) except UnicodeDecodeError: return ("�", 1, False) TUI_CSI_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", "Z": "backtab"} TUI_TILDE_KEYS = {"1": "home", "2": "insert", "3": "delete", "4": "end", "5": "pageup", "6": "pagedown", "7": "home", "8": "end", "11": "f1", "12": "f2", "13": "f3", "14": "f4", "15": "f5", "17": "f6", "18": "f7", "19": "f8", "20": "f9", "21": "f10", "23": "f11", "24": "f12"} TUI_SS3_KEYS = {"P": "f1", "Q": "f2", "R": "f3", "S": "f4", "A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end"} TUI_KITTY_NAMED = {27: "escape", 13: "enter", 9: "tab", 127: "backspace", 57414: "enter", 57417: "left", 57418: "right", 57419: "up", 57420: "down", 57421: "pageup", 57422: "pagedown", 57423: "home", 57424: "end", 57425: "insert", 57426: "delete"} TUI_KITTY_MODS = {57441: "shift", 57447: "shift", 57442: "ctrl", 57448: "ctrl", 57443: "alt", 57449: "alt", 57444: "super", 57450: "super"} TUI_KITTY_PAD = {57400: "0", 57401: "1", 57402: "2", 57403: "3", 57404: "4", 57405: "5", 57406: "6", 57407: "7", 57408: "8", 57409: ".", 57410: "/", 57411: "*", 57412: "-", 57413: "+", 57415: "="} TUI_KITTY_US_SHIFT = {"1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", "[": "{", "]": "}", "\\": "|", ";": ":", "'": '"', ",": "<", ".": ">", "/": "?", "`": "~"} def tui_modifiers(code): mods = [] bits = code - 1 if bits & 1: mods.append("shift") if bits & 2: mods.append("alt") if bits & 4: mods.append("ctrl") if bits & 8: mods.append("meta") return mods def tui_parse_mouse_sgr(text): """Parse "!<=" or not all(char.isdigit() or char in ":;" for char in body): return (None, rest) parts = body.split(";") try: number = int(parts[0].split(":")[0] or 0) except ValueError: return (None, rest) mods, etype = 1, 1 if len(parts) > 1 and parts[1]: fields = parts[1].split(":") mods = tui_int(fields[0], 1) if fields[0] else 1 if len(fields) > 1 and fields[1]: etype = tui_int(fields[1], 1) if etype == 3: return (None, rest) text = "" for extra in parts[2:]: for piece in extra.split(":"): if piece.isdigit(): point = int(piece) if point <= 0x10FFFF: text += chr(point) if number in TUI_KITTY_MODS: if etype == 2: return (None, rest) name = TUI_KITTY_MODS[number] return (TuiKey(key=name, char="", modifiers=[name]), rest) if number in TUI_KITTY_NAMED: if etype == 2: return (None, rest) name = TUI_KITTY_NAMED[number] names = tui_modifiers(((mods - 1) & 0b111) + 1) key = "+".join(names + [name]) if names else name return (TuiKey(key=key, char="", modifiers=names), rest) if number in TUI_KITTY_PAD: char = TUI_KITTY_PAD[number] return (TuiKey(key=char, char=char), rest) if 1 <= number <= 26: letter = chr(number + 96) return (TuiKey(key="ctrl+" + letter, char=chr(number), modifiers=["ctrl"]), rest) if number == 0: if not text: return (None, rest) elif 57344 <= number <= 63743 or not 32 <= number <= 0x10FFFF or 0xD800 <= number <= 0xDFFF: return (None, rest) if text: key = "space" if text == " " else text return (TuiKey(key=key, char=text), rest) char = chr(number) names = tui_modifiers(((mods - 1) & 0b111) + 1) if names == ["shift"] and "a" <= char <= "z": upper = chr(ord(char) - 32) return (TuiKey(key=upper, char=upper), rest) if names == ["shift"] and char in TUI_KITTY_US_SHIFT: shifted = TUI_KITTY_US_SHIFT[char] return (TuiKey(key=shifted, char=shifted), rest) if names == ["ctrl"] and "a" <= char <= "z": return (TuiKey(key="ctrl+" + char, char=chr(ord(char) - 96), modifiers=names), rest) if names: return (TuiKey(key="+".join(names + [char]), char=char, modifiers=names), rest) key = "space" if char == " " else char return (TuiKey(key=key, char=char), rest) def tui_paste_head(buf): """True when buf starts with, or could still become, the bracketed paste opener. Used to keep slow pastes buffered instead of forcing a lone-ESC resolution through them.""" head = bytes(buf[:len(TUI_PASTE_OPEN)]) return head == TUI_PASTE_OPEN[:len(head)] def tui_parse_paste(buf, rest): """Parse a bracketed paste body. Bytes between the markers arrive literally: no escape parsing inside. An over-cap paste is dropped whole, never truncated into the session.""" rest = bytes(rest) close = rest.find(TUI_PASTE_CLOSE) if close < 0: if len(buf) > TUI_PASTE_CAP: return (None, b"") return ("need_more", buf) if close > TUI_PASTE_CAP: return (None, rest[close + len(TUI_PASTE_CLOSE):]) text = rest[:close].decode("utf-8", "replace") return (TuiPaste(text=text), rest[close + len(TUI_PASTE_CLOSE):]) def tui_parse_one(buf, final): """Parse one event from head of buf. Returns (event|"need_more"|None, rest). None means empty buffer; "need_more" means wait for bytes (or flush).""" if not buf: return (None, buf) if buf[0] != 0x1B: if buf[0] in (0x0D, 0x0A): if buf[0] == 0x0D and len(buf) > 1 and buf[1] == 0x0A: return (TuiKey(key="enter", char="\n"), buf[2:]) if buf[0] == 0x0D and len(buf) == 1 and not final: return ("need_more", buf) return (TuiKey(key="enter", char="\n"), buf[1:]) if buf[0] == 0x09: return (TuiKey(key="tab", char="\t"), buf[1:]) if buf[0] == 0x7F: return (TuiKey(key="backspace", char="\x7f"), buf[1:]) if buf[0] < 0x20: letter = chr(buf[0] + 0x60) if buf[0] != 0 else "@" return (TuiKey(key="ctrl+" + letter, char=chr(buf[0])), buf[1:]) char, length, need_more = tui_utf8_head(buf) if need_more: return ("need_more", buf) if char == " ": return (TuiKey(key="space", char=char), buf[length:]) return (TuiKey(key=char, char=char), buf[length:]) if len(buf) == 1: return (("escape", buf[1:]) if final else ("need_more", buf)) if isinstance(buf, bytearray): buf = bytes(buf) second = buf[1:2] if second == b"\x1b": return ("escape", buf[1:]) if second == b"[": if len(buf) >= 3 and buf[2:3] == b"M": if len(buf) < 6: return ("need_more", buf) event, _ok = tui_parse_mouse_legacy(buf[3:6]) return ((event, buf[6:]) if event is not None else (None, buf[6:])) if len(buf) >= 3 and buf[2:3] == b"<": end = -1 for pos in range(3, min(len(buf), 32)): if buf[pos:pos + 1] in (b"M", b"m"): end = pos break if end < 0: return ("need_more", buf) try: text = buf[2:end + 1].decode("ascii") except UnicodeDecodeError: return (None, buf[end + 1:]) event, _ok = tui_parse_mouse_sgr(text) return ((event, buf[end + 1:]) if event is not None else (None, buf[end + 1:])) end = -1 for pos in range(2, min(len(buf), 64)): if 0x40 <= buf[pos] <= 0x7E: end = pos break if end < 0: return ("need_more", buf) try: body = buf[2:end].decode("ascii") final_char = chr(buf[end]) except UnicodeDecodeError: return (None, buf[end + 1:]) rest = buf[end + 1:] if final_char == "~" and body == "200": return tui_parse_paste(buf, rest) if final_char == "u": return tui_parse_kitty(body, rest) if final_char == "y" and body[:1] == "?" and body[-1:] == "$": mode, _sep, state = body[1:-1].partition(";") return (TuiModeReply(mode=tui_int(mode, -1), state=tui_int(state, 0)), rest) if final_char == "~" and ";" not in body: return ((TuiKey(key=TUI_TILDE_KEYS.get(body, "unknown"), char=""), rest) if body in TUI_TILDE_KEYS else (None, rest)) if ";" in body: base, _, mod = body.partition(";") mods = tui_modifiers(tui_int(mod, 1)) base_key = TUI_TILDE_KEYS.get(base) if final_char == "~" else TUI_CSI_KEYS.get(final_char) if base_key is not None: return (TuiKey(key="+".join(mods + [base_key]) if mods else base_key, char="", modifiers=mods), rest) return (None, rest) if final_char in TUI_CSI_KEYS and not body: return (TuiKey(key=TUI_CSI_KEYS[final_char], char=""), rest) return (None, rest) if second == b"O": if len(buf) < 3: return ("need_more", buf) key = TUI_SS3_KEYS.get(chr(buf[2])) return ((TuiKey(key=key, char=""), buf[3:]) if key is not None else (None, buf[3:])) if second in (b"]", b"P", b"X", b"^", b"_"): return (None, buf[2:]) char, length, need_more = tui_utf8_head(buf[1:]) if need_more: return ("need_more", buf) if char == "\r": return (TuiKey(key="alt+enter", char="\n", modifiers=["alt"]), buf[1 + length:]) return (TuiKey(key="alt+" + char, char=char), buf[1 + length:]) class TuiInput: """Incremental stdin parser. feed() bytes in, events out; flush() turns a trailing lone ESC into the Escape key once input idles.""" def __init__(self): self.buf = b"" def feed(self, data): if data: self.buf += bytes(data) return self._drain(False) def flush(self): events = self._drain(True) while self.buf and self.buf[:1] == b"\x1b" and not tui_paste_head(self.buf): before = len(self.buf) events.extend(self._drain(True)) if len(self.buf) == before: events.append(TuiKey(key="escape", char="\x1b")) self.buf = self.buf[1:] events.extend(self._drain(True)) return events def _drain(self, final): events = [] while self.buf: parsed, rest = tui_parse_one(self.buf, final) if parsed == "need_more": break if parsed == "escape": events.append(TuiKey(key="escape", char="\x1b")) elif isinstance(parsed, TuiEvent): events.append(parsed) rest = rest if isinstance(rest, bytes) else bytes(rest) if rest == self.buf: break self.buf = rest return events class TuiVtScreen: """In-memory VT100/ANSI screen: grid plus streaming byte parser. Terminal widgets feed pty output through feed() and paint from the grid. A cell is None (never written) or a (char, fg, bg, attr) tuple with SGR-fragment colors and a bitmask (1 bold, 2 dim, 4 underline, 8 reverse, 16 strike). Covers shells plus well-behaved fullscreen apps; unknown sequences are skipped, never fatal. Wide characters count as one column each (known gap, see tui.md).""" ATTR_BOLD = 1 ATTR_DIM = 2 ATTR_UNDERLINE = 4 ATTR_REVERSE = 8 ATTR_STRIKE = 16 ATTR_ITALIC = 32 ATTR_BLINK = 64 ATTR_HIDDEN = 128 TAB_WIDTH = 8 GRAPHICS = {"`": "\u25c6", "a": "\u2592", "f": "\u00b0", "g": "\u00b1", "j": "\u2518", "k": "\u2510", "l": "\u250c", "m": "\u2514", "n": "\u253c", "o": "\u23ba", "p": "\u23bb", "q": "\u2500", "r": "\u23bc", "s": "\u23bd", "t": "\u251c", "u": "\u2524", "v": "\u2534", "w": "\u252c", "x": "\u2502", "y": "\u2264", "z": "\u2265", "{": "\u03c0", "|": "\u2260", "}": "\u00a3", "~": "\u00b7"} TAIL_GUARD = 65536 def __init__(self, rows, cols): self.rows = max(1, int(rows)) self.cols = max(1, int(cols)) self.grid = [[None] * self.cols for _row in range(self.rows)] self.cx = 0 self.cy = 0 self.fg = None self.bg = None self.attr = 0 self.cursor_visible = True self.region_top = 0 self.region_bottom = self.rows - 1 self.lr_mode = False self.margin_left = 0 self.margin_right = self.cols - 1 self._wrap = False self._saved = None self._alt = None self._buf = "" self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") self.history = None self.history_pushed = 0 self.app_cursor = False self.bracketed_paste = False self.title = "" self.replies = bytearray() self._graphics = False self._last_char = "" @property def alt_active(self): return self._alt is not None def take_replies(self): data = bytes(self.replies) self.replies.clear() return data def blank(self): return (" ", None, self.bg, 0) def feed(self, data): if not data: return self._buf += self._decoder.decode(bytes(data), False) if len(self._buf) > self.TAIL_GUARD: self._buf = self._buf[-self.TAIL_GUARD:] buf = self._buf pos = 0 end = len(buf) while pos < end: char = buf[pos] if char == "\x1b": nxt = self._parse_esc(buf, pos, end) if nxt is None: break pos = nxt elif char == "\x9b": nxt = self._parse_csi(buf, pos + 1, end) if nxt is None: break pos = nxt elif char in "\x90\x9d\x9e\x9f": nxt = self._parse_until_st(buf, pos + 1, end) if nxt is None: break pos = nxt else: self._ground(char) pos += 1 self._buf = buf[pos:] def _ground(self, char): if char == "\n" or char == "\x0b" or char == "\x0c": self._wrap = False self.line_feed() elif char == "\r": self._wrap = False self.cx = 0 elif char == "\b": self._wrap = False self.cx = max(0, self.cx - 1) elif char == "\t": self._wrap = False self.cx = min(self.cols - 1, (self.cx // self.TAB_WIDTH + 1) * self.TAB_WIDTH) elif char in ("\x07", "\x00", "\x0e", "\x0f"): pass elif ord(char) < 0x20 or char == "\x7f": pass else: if self._wrap: self._wrap = False self.cx = 0 self.line_feed() if self._graphics: char = self.GRAPHICS.get(char, char) self._last_char = char self.grid[self.cy][self.cx] = (char, self.fg, self.bg, self.attr) if self.cx == self.cols - 1: self._wrap = True else: self.cx += 1 def _parse_esc(self, buf, pos, end): if pos + 1 >= end: return None nxt = buf[pos + 1] if nxt == "[": return self._parse_csi(buf, pos + 2, end) if nxt == "]": stop = self._parse_until_st(buf, pos + 2, end) if stop is not None: self._osc(buf[pos + 2:stop]) return stop if nxt in "PX^_": return self._parse_until_st(buf, pos + 2, end) if nxt in "()#%": if pos + 2 >= end: return None if nxt == "(": self._graphics = buf[pos + 2] == "0" return pos + 3 if nxt == "7": self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr) elif nxt == "8": if self._saved is not None: self.cx, self.cy, self.fg, self.bg, self.attr = self._saved self._clamp_cursor() elif nxt == "M": self._wrap = False if self.cy == self.region_top: if self._inside_margins(): self.scroll_down(self.region_top, self.region_bottom) else: self.cy = max(0, self.cy - 1) elif nxt == "c": self.reset() elif nxt == "D": self.line_feed() elif nxt == "E": self.cx = 0 self.line_feed() return pos + 2 def _osc(self, body): body = body.rstrip("\x07\x1b\\") code, _sep, text = body.partition(";") if code in ("0", "2"): self.title = text[:256] def _parse_csi(self, buf, pos, end): cur = pos while cur < end and (buf[cur].isdigit() or buf[cur] in ";:?> 256: return pos + 2 if cur >= end: return None final = buf[cur] if not "@" <= final <= "~": return pos + 2 self._dispatch_csi(buf[pos:cur], final) return cur + 1 def _parse_until_st(self, buf, pos, end): cur = pos while cur < end: if buf[cur] == "\x07": return cur + 1 if buf[cur] == "\x1b": if cur + 1 >= end: return None if buf[cur + 1] == "\\": return cur + 2 cur += 2 continue cur += 1 if cur - pos > 4096: return cur return None def _params(self, body, count, default=1): if body[:1] in ("?", ">", "="): body = body[1:] elif body[:1] in ("!", "\"", "$"): body = "" parts = body.replace(":", ";").split(";")[:64] values = [] for part in parts: part = part.strip() if not part: values.append(default) continue try: values.append(int(part)) except ValueError: values.append(default) if count is not None: while len(values) < count: values.append(default) return values def _dispatch_csi(self, body, final): private = body[:1] == "?" or body[:1] == ">" if final == "m": self._sgr(body) return if final == "c" and body in ("", "0", ">", ">0"): self.replies += b"\x1b[>0;0;0c" if body[:1] == ">" else b"\x1b[?1;2c" return if final == "p" and body.endswith("$"): mode = self._params(body[:-1], 1, 0)[0] state = {25: self.cursor_visible, 1: self.app_cursor, 2004: self.bracketed_paste, 69: self.lr_mode, 1049: self._alt is not None}.get(mode) if private else None self.replies += ("\x1b[%s%d;%d$y" % ("?" if private else "", mode, 0 if state is None else 1 if state else 2)).encode("ascii") return if final == "n" and not private: code = self._params(body, 1, 0)[0] if code == 5: self.replies += b"\x1b[0n" elif code == 6: self.replies += ("\x1b[%d;%dR" % (self.cy + 1, self.cx + 1)).encode("ascii") return if final == "h" or final == "l": self._mode(body, final == "h") return if private: return if final == "A": self._wrap = False floor = self.region_top if self._in_region() else 0 self.cy = max(floor, self.cy - self._params(body, 1)[0]) elif final == "B": self._wrap = False ceil = self.region_bottom if self._in_region() else self.rows - 1 self.cy = min(ceil, self.cy + self._params(body, 1)[0]) elif final == "C": self._wrap = False self.cx = min(self.cols - 1, self.cx + self._params(body, 1)[0]) elif final == "D": self._wrap = False self.cx = max(0, self.cx - self._params(body, 1)[0]) elif final == "E": self._wrap = False self.cy = min(self.rows - 1, self.cy + self._params(body, 1)[0]) self.cx = 0 elif final == "F": self._wrap = False self.cy = max(0, self.cy - self._params(body, 1)[0]) self.cx = 0 elif final == "G": self._wrap = False self.cx = min(self.cols - 1, max(0, self._params(body, 1)[0] - 1)) elif final == "H" or final == "f": self._wrap = False row, col = self._params(body, 2) self.cy = min(self.rows - 1, max(0, row - 1)) self.cx = min(self.cols - 1, max(0, col - 1)) elif final == "d": self._wrap = False self.cy = min(self.rows - 1, max(0, self._params(body, 1)[0] - 1)) elif final == "J": self._wrap = False self._erase_display(self._params(body, 1, 0)[0]) elif final == "K": self._wrap = False self._erase_line(self._params(body, 1, 0)[0]) elif final == "L": self._wrap = False self._insert_lines(self._params(body, 1)[0]) elif final == "M": self._wrap = False self._delete_lines(self._params(body, 1)[0]) elif final == "P": self._wrap = False self._delete_chars(self._params(body, 1)[0]) elif final == "@": self._wrap = False self._insert_chars(self._params(body, 1)[0]) elif final == "X": self._wrap = False self._erase_chars(self._params(body, 1)[0]) elif final == "S": self._wrap = False for _i in range(min(self._params(body, 1)[0], self.rows)): self.scroll_up(self.region_top, self.region_bottom) elif final == "T": self._wrap = False for _i in range(min(self._params(body, 1)[0], self.rows)): self.scroll_down(self.region_top, self.region_bottom) elif final == "r": top, bottom = self._params(body, 2) top = min(self.rows, max(1, top)) bottom = min(self.rows, max(1, bottom)) if top < bottom: self.region_top = top - 1 self.region_bottom = bottom - 1 self.cx = 0 self.cy = self.region_top elif final == "b": if self._last_char: for _i in range(min(self._params(body, 1)[0], self.cols * self.rows)): self._ground(self._last_char) elif final == "s" and self.lr_mode: left, right = self._params(body, 2) left = min(self.cols, max(1, left)) right = min(self.cols, max(1, right if body.count(";") else self.cols)) if left < right: self.margin_left = left - 1 self.margin_right = right - 1 self.cx = 0 self.cy = 0 elif final == "s": self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr) elif final == "u": if self._saved is not None: self.cx, self.cy, self.fg, self.bg, self.attr = self._saved self._clamp_cursor() def _in_region(self): return self.region_top <= self.cy <= self.region_bottom def _mode(self, body, state): private = body[:1] == "?" for value in self._params(body, None, 0): if value == 25 and private: self.cursor_visible = state elif value == 1 and private: self.app_cursor = state elif value == 2004 and private: self.bracketed_paste = state elif value == 69 and private: self.lr_mode = state self.margin_left = 0 self.margin_right = self.cols - 1 elif value in (47, 1047, 1049) and private: self._alt_screen(state) def _alt_screen(self, state): if state and self._alt is None: self._alt = (self.grid, self.cx, self.cy, self.fg, self.bg, self.attr, self.region_top, self.region_bottom, self._saved, self.cursor_visible) self.grid = [[None] * self.cols for _row in range(self.rows)] self.cx = 0 self.cy = 0 self.fg = None self.bg = None self.attr = 0 self.region_top = 0 self.region_bottom = self.rows - 1 self._saved = None self._wrap = False elif not state and self._alt is not None: self.grid, self.cx, self.cy, self.fg, self.bg, self.attr, self.region_top, self.region_bottom, self._saved, self.cursor_visible = self._alt self._alt = None self._wrap = False def _sgr(self, body): values = self._params(body, None, 0) if body.strip("?") else [0] pos = 0 while pos < len(values): code = values[pos] if code == 0: self.fg = None self.bg = None self.attr = 0 elif code == 1: self.attr |= self.ATTR_BOLD elif code == 2: self.attr |= self.ATTR_DIM elif code == 3: self.attr |= self.ATTR_ITALIC elif code == 4: self.attr |= self.ATTR_UNDERLINE elif code == 5: self.attr |= self.ATTR_BLINK elif code == 7: self.attr |= self.ATTR_REVERSE elif code == 8: self.attr |= self.ATTR_HIDDEN elif code == 9: self.attr |= self.ATTR_STRIKE elif code == 22: self.attr &= ~(self.ATTR_BOLD | self.ATTR_DIM) elif code == 23: self.attr &= ~self.ATTR_ITALIC elif code == 24: self.attr &= ~self.ATTR_UNDERLINE elif code == 25: self.attr &= ~self.ATTR_BLINK elif code == 27: self.attr &= ~self.ATTR_REVERSE elif code == 28: self.attr &= ~self.ATTR_HIDDEN elif code == 29: self.attr &= ~self.ATTR_STRIKE elif 30 <= code <= 37 or 90 <= code <= 97: self.fg = str(code) elif code == 39: self.fg = None elif 40 <= code <= 47 or 100 <= code <= 107: self.bg = str(code) elif code == 49: self.bg = None elif code == 38 or code == 48: target = "fg" if code == 38 else "bg" if pos + 2 < len(values) and values[pos + 1] == 5: setattr(self, target, "%d;5;%d" % (code, values[pos + 2] & 0xFF)) pos += 2 elif pos + 4 < len(values) and values[pos + 1] == 2: setattr(self, target, "%d;2;%d;%d;%d" % (code, values[pos + 2] & 0xFF, values[pos + 3] & 0xFF, values[pos + 4] & 0xFF)) pos += 4 pos += 1 def line_feed(self): if self.cy == self.region_bottom: if self._inside_margins(): self.scroll_up(self.region_top, self.region_bottom) else: self.cy = min(self.rows - 1, self.cy + 1) def _margins(self): """Inclusive left and right column of the scrolling area: the whole width unless left/right margin mode (DECLRMM) is on.""" if self.lr_mode: return (self.margin_left, self.margin_right) return (0, self.cols - 1) def _inside_margins(self): left, right = self._margins() return left <= self.cx <= right def scroll_up(self, top, bottom, record=True): left, right = self._margins() if left > 0 or right < self.cols - 1: for row in range(top, bottom): self.grid[row] = self.grid[row][:left] + self.grid[row + 1][left:right + 1] + self.grid[row][right + 1:] self.grid[bottom] = self.grid[bottom][:left] + [self.blank()] * (right - left + 1) + self.grid[bottom][right + 1:] return if record and self.history is not None and top == 0 and bottom == self.rows - 1 and self._alt is None: self.history.append(self.grid[0]) self.history_pushed += 1 for row in range(top, bottom): self.grid[row] = self.grid[row + 1] self.grid[bottom] = [self.blank()] * self.cols def scroll_down(self, top, bottom): left, right = self._margins() if left > 0 or right < self.cols - 1: for row in range(bottom, top, -1): self.grid[row] = self.grid[row][:left] + self.grid[row - 1][left:right + 1] + self.grid[row][right + 1:] self.grid[top] = self.grid[top][:left] + [self.blank()] * (right - left + 1) + self.grid[top][right + 1:] return for row in range(bottom, top, -1): self.grid[row] = self.grid[row - 1] self.grid[top] = [self.blank()] * self.cols def _erase_display(self, mode): if mode == 2 or mode == 3: for row in range(self.rows): self.grid[row] = [self.blank()] * self.cols self.cx = 0 self.cy = 0 elif mode == 1: for row in range(0, self.cy): self.grid[row] = [self.blank()] * self.cols for col in range(0, self.cx + 1): self.grid[self.cy][col] = self.blank() else: for col in range(self.cx, self.cols): self.grid[self.cy][col] = self.blank() for row in range(self.cy + 1, self.rows): self.grid[row] = [self.blank()] * self.cols def _erase_line(self, mode): if mode == 1: for col in range(0, self.cx + 1): self.grid[self.cy][col] = self.blank() elif mode == 2: self.grid[self.cy] = [self.blank()] * self.cols else: for col in range(self.cx, self.cols): self.grid[self.cy][col] = self.blank() def _insert_lines(self, count): count = min(max(1, count), self.region_bottom - self.cy + 1) if self.cy < self.region_top or self.cy > self.region_bottom or not self._inside_margins(): return for _i in range(count): self.scroll_down(self.cy, self.region_bottom) def _delete_lines(self, count): count = min(max(1, count), self.region_bottom - self.cy + 1) if self.cy < self.region_top or self.cy > self.region_bottom or not self._inside_margins(): return for _i in range(count): self.scroll_up(self.cy, self.region_bottom, False) def _delete_chars(self, count): row = self.grid[self.cy] rest = row[self.cx + count:] self.grid[self.cy] = row[:self.cx] + rest + [self.blank()] * min(max(1, count), self.cols - self.cx) def _insert_chars(self, count): row = self.grid[self.cy] room = self.cols - self.cx count = min(max(1, count), room) self.grid[self.cy] = row[:self.cx] + ([self.blank()] * count + row[self.cx:])[:room] def _erase_chars(self, count): for col in range(self.cx, min(self.cols, self.cx + max(1, count))): self.grid[self.cy][col] = self.blank() def _clamp_cursor(self): self.cx = min(self.cols - 1, max(0, self.cx)) self.cy = min(self.rows - 1, max(0, self.cy)) def reset(self): for row in range(self.rows): self.grid[row] = [self.blank()] * self.cols self.cx = 0 self.cy = 0 self.fg = None self.bg = None self.attr = 0 self.cursor_visible = True self.region_top = 0 self.region_bottom = self.rows - 1 self.lr_mode = False self.margin_left = 0 self.margin_right = self.cols - 1 self._wrap = False self._saved = None self._alt = None self.app_cursor = False self.bracketed_paste = False self._graphics = False def resize(self, rows, cols): rows = max(1, int(rows)) cols = max(1, int(cols)) grid = [] for row in range(rows): if row < self.rows: old = self.grid[row] grid.append(old[:cols] if cols <= self.cols else old + [None] * (cols - self.cols)) else: grid.append([None] * cols) self.grid = grid self.rows = rows self.cols = cols self.region_top = 0 self.region_bottom = rows - 1 self.lr_mode = False self.margin_left = 0 self.margin_right = cols - 1 self._clamp_cursor() self._wrap = False def text(self, limit=None): rows = self.grid if limit is None else self.grid[-limit:] lines = [] for row in rows: lines.append("".join(cell[0] if cell else " " for cell in row).rstrip()) return "\n".join(lines) @staticmethod def style_for(cell, base): if cell is None: return base _char, fg, bg, attr = cell return TuiStyle(fg=TuiVtScreen._frag_to_color(fg, False) or base.fg, bg=TuiVtScreen._frag_to_color(bg, True) or base.bg, bold=bool(attr & TuiVtScreen.ATTR_BOLD), dim=bool(attr & TuiVtScreen.ATTR_DIM), underline=bool(attr & TuiVtScreen.ATTR_UNDERLINE), reverse=bool(attr & TuiVtScreen.ATTR_REVERSE), strike=bool(attr & TuiVtScreen.ATTR_STRIKE)) @staticmethod def _frag_to_color(frag, background): if frag is None: return None if ";" not in frag: try: code = int(frag) except ValueError: return None if 30 <= code <= 37: return ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 30] if 90 <= code <= 97: return "bright_" + ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 90] if 40 <= code <= 47: return ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 40] if 100 <= code <= 107: return "bright_" + ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 100] return None parts = frag.split(";") try: nums = [int(part) for part in parts] except ValueError: return None if len(nums) == 3 and nums[1] == 5: return nums[2] if len(nums) == 5 and nums[1] == 2: return (nums[2], nums[3], nums[4]) return None class TuiText(TuiPanel): """Wrapped static text with alignment.""" def __init__(self, text="", text_align="left", wrap=True, **kwargs): super().__init__(**kwargs) self.text = text self.text_align = text_align self.wrap = wrap def measure(self): lines = str(self.text).split("\n") width = 0 for line in lines: width = max(width, len(line)) return (width, max(1, len(lines))) def lines(self, width): out = [] for para in str(self.text).split("\n"): if not self.wrap or width <= 0: out.append(para) continue words = para.split(" ") line = "" for word in words: piece = word if not line else line + " " + word if len(piece) <= width or not line: line = piece else: out.append(line) line = word out.append(line) return out def content_rows(self): width = self._content_w() height = self._content_h() lines = self.lines(width) if self.valign in ("middle", "center"): top = max(0, (height - len(lines)) // 2) elif self.valign == "bottom": top = max(0, height - len(lines)) else: top = 0 rows = [] for pos in range(height): line = lines[pos - top] if top <= pos < top + len(lines) else "" if self.text_align == "center": line = line.center(width) elif self.text_align == "right": line = line.rjust(width) else: line = line.ljust(width) rows.append([TuiSpan(line[:width], self.style)]) return rows class TuiRich(TuiPanel): """Static multi-style rows: set() takes one span list or a list of span lists. Rows pad to the panel width and clip, so callers never measure. .text mirrors the plain content for tests and status reads.""" def __init__(self, rows=(), **kwargs): super().__init__(**kwargs) self.rows = [] self.text = "" self.set(rows) def set(self, rows): rows = list(rows) if rows and isinstance(rows[0], TuiSpan): rows = [rows] rows = [list(row) for row in rows] look = [tuple((span.text, span.style.key()) for span in row) for row in rows] if look == self.__dict__.get("_look"): return self._look = look self.rows = rows self.text = "\n".join("".join(span.text for span in row) for row in self.rows) if self.app is not None: self.touch() def measure(self): width = 0 for row in self.rows: width = max(width, sum(len(span.text) for span in row)) return (width, max(1, len(self.rows))) def content_rows(self): width = self._content_w() height = self._content_h() out = [] for pos in range(height): row = self.rows[pos] if pos < len(self.rows) else [] out.append(tui_slice_spans(row, 0, width, self.style)) return out class TuiButton(TuiPanel): """Clickable button: Enter/Space/click press it. on_press is the Press-event handler attribute and receives the event.""" def __init__(self, label="", on_press=None, brackets=True, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) self.label = label self.on_press = on_press self.brackets = brackets def measure(self): return (len(self.label) + 4, 1) def content_rows(self): width = self._content_w() height = self._content_h() label = (("[ %s ]" if self.brackets else " %s ") % self.label).center(width)[:width] style = self.style if not self.enabled: style = style.merged(TuiStyle(dim=True)) elif self.app is not None and self.app.focused is self: style = style.merged(TuiStyle(reverse=True, bold=True)) rows = [] for pos in range(height): if height == 1 or pos == height // 2: rows.append([TuiSpan(label, style)]) else: rows.append([TuiSpan(" " * width, self.style)]) return rows def on_key(self, event): if not self.enabled: return if event.key in ("enter", "space"): event.stop() self.press() def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() self.press() def press(self): self.emit(TuiPress(panel=self)) if self.app is not None: self.touch() class TuiOptions(TuiPanel): """Virtualized option list: arrows/mouse/wheel, Enter selects. on_select is the Select-event handler attribute and receives the event (index, option, value on it); the event also bubbles.""" def __init__(self, options=(), selected=0, multi=False, on_select=None, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("scrollable", True) super().__init__(**kwargs) self.options = [TuiOptions.norm_option(item) for item in options] self.selected = 0 self.multi = multi self.chosen = set() self.on_select = on_select if self.options: self.selected = max(0, min(int(selected), len(self.options) - 1)) @staticmethod def norm_option(item): if isinstance(item, (tuple, list)) and len(item) >= 2: return (str(item[0]), item[1]) if isinstance(item, dict): return (str(item.get("label", "")), item.get("value")) return (str(item), item) def measure(self): width = 4 for label, _value in self.options: width = max(width, len(label) + 4) return (width, max(1, len(self.options))) def content_rows(self): width = self._content_w() height = self._content_h() self._follow_selection(height) rows = [] focused = self.app is not None and self.app.focused is self for pos in range(height): index = self.scroll_y + pos if 0 <= index < len(self.options): label, _value = self.options[index] marker = ">" if self.multi: marker = "[x]" if index in self.chosen else "[ ]" elif index != self.selected: marker = " " text = (" %s %s" % (marker, label))[:width].ljust(width) if index == self.selected and self.enabled: style = self.style.merged(TuiStyle(reverse=True, bold=focused)) else: style = self.style rows.append([TuiSpan(text, style)]) else: rows.append([TuiSpan(" " * width, self.style)]) return rows def _follow_selection(self, height): if self.selected < self.scroll_y: self.scroll_y = self.selected elif self.selected >= self.scroll_y + max(1, height): self.scroll_y = self.selected - max(1, height) + 1 def move(self, delta): if not self.options or not self.enabled: return self.selected = max(0, min(len(self.options) - 1, self.selected + delta)) if self.app is not None: self.touch() def choose(self, index=None): if not self.options or not self.enabled: return if index is not None: self.selected = max(0, min(len(self.options) - 1, index)) if self.multi: if self.selected in self.chosen: self.chosen.discard(self.selected) else: self.chosen.add(self.selected) label, value = self.options[self.selected] self.emit(TuiSelect(panel=self, index=self.selected, option=label, value=value)) if self.app is not None: self.touch() def on_key(self, event): if not self.enabled: return if event.key == "up": event.stop() self.move(-1) elif event.key == "down": event.stop() self.move(1) elif event.key == "home": event.stop() self.move(-len(self.options)) elif event.key == "end": event.stop() self.move(len(self.options)) elif event.key == "pageup": event.stop() self.move(-max(1, self._content_h())) elif event.key == "pagedown": event.stop() self.move(max(1, self._content_h())) elif event.key in ("enter", "space"): event.stop() self.choose() def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() pad_top = self.padding[0] + (1 if self.border else 0) index = self.scroll_y + (event.y - self.ay - pad_top) if 0 <= index < len(self.options): self.choose(index) def on_scroll(self, event): if not self.enabled: return event.stop() if event.direction == "up": self.move(-1) elif event.direction == "down": self.move(1) COMPLETE_WORD_RE = re.compile(r"(?:^|\s)([/@])([A-Za-z0-9_./-]*)$") TUI_AVATAR_ROLES = ("accent", "accent2", "accent3", "success", "warning", "info") def tui_avatar_role(name): """Stable accent role per name so a bot keeps its color everywhere.""" return TUI_AVATAR_ROLES[sum(ord(char) for char in str(name)) % len(TUI_AVATAR_ROLES)] class TuiChatList(TuiOptions): """Two-line chat rows: avatar chip + name / snippet. Options carry (label, value) as usual; row_meta maps value -> {"snippet": str, "draft": str, "busy": bool or a spinner glyph, "unread": int}. Selection moves per chat while each chat paints two terminal rows; the selected chat gets an accent bar.""" def __init__(self, options=(), **kwargs): super().__init__(options, **kwargs) self.row_meta = {} def measure(self): width, _height = super().measure() return (width, max(1, 2 * len(self.options))) def _follow_selection(self, height): top = self.selected * 2 if top < self.scroll_y: self.scroll_y = top elif top + 1 >= self.scroll_y + max(1, height): self.scroll_y = top + 2 - max(1, height) def content_rows(self): width = self._content_w() height = self._content_h() self._follow_selection(height) focused = self.app is not None and self.app.focused is self rows = [] for pos in range(height): index = self.scroll_y + pos chat, sub = divmod(index, 2) if 0 <= chat < len(self.options): label, value = self.options[chat] meta = self.row_meta.get(value) or {} active = chat == self.selected and self.enabled base = self.style.merged(TuiStyle(bg="role:selection")) if active else self.style tint = "role:" + tui_avatar_role(label) bar = TuiSpan("▌" if active else " ", base.merged(TuiStyle(fg="role:accent"))) if sub == 0: chip = TuiSpan(" %s " % (label[:1].upper() or "?"), TuiStyle(fg="role:on_accent", bg=tint, bold=True)) name = TuiSpan(" " + label[:max(0, width - 6)], base.merged(TuiStyle(bold=active or focused))) busy = meta.get("busy") badge = [TuiSpan(" %s" % (busy if isinstance(busy, str) else "●"), base.merged(TuiStyle(fg="role:warning", bold=True)))] if busy else [] unread = int(meta.get("unread") or 0) if unread: badge.append(TuiSpan(" %d " % min(unread, 99), TuiStyle(fg="role:on_accent", bg="role:accent", bold=True))) rows.append(tui_pad_spans([bar, chip, name] + badge, width, base)) else: snippet = str(meta.get("snippet") or "") draft = str(meta.get("draft") or "") lead = TuiSpan(" ", base) if draft: head = TuiSpan("Draft: ", base.merged(TuiStyle(fg="role:error"))) tail = TuiSpan(draft[:max(0, width - 12)], base.merged(TuiStyle(fg="role:muted"))) rows.append(tui_pad_spans([bar, lead, head, tail], width, base)) else: tail = TuiSpan(snippet[:max(0, width - 6)], base.merged(TuiStyle(fg="role:muted"))) rows.append(tui_pad_spans([bar, lead, tail], width, base)) else: rows.append([TuiSpan(" " * width, self.style)]) return rows def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() pad_top = self.padding[0] + (1 if self.border else 0) index = self.scroll_y + (event.y - self.ay - pad_top) chat = index // 2 if 0 <= chat < len(self.options): self.choose(chat) class TuiAutocomplete(TuiOptions): """Non-focusable completion popup owned by a TuiTextBox. The box keeps focus and drives the list: typing filters, arrows move, Enter accepts via the Select event, Esc dismisses. Tab is never used: it always moves focus by framework rule.""" def __init__(self, **kwargs): kwargs.setdefault("focusable", False) kwargs.setdefault("border", True) kwargs.setdefault("layer", "dialog") kwargs.setdefault("z", 5) super().__init__((), **kwargs) def set_items(self, items): self.options = [TuiOptions.norm_option(item) for item in items] self.selected = 0 self.scroll_y = 0 if self.app is not None: self.touch() TUI_COMMAND_HELP = {} def tui_command_help(): """Slash-command descriptions parsed once from HELP_TEXT.""" if not TUI_COMMAND_HELP: for line in HELP_TEXT.splitlines(): match = re.match(r"\s*/(\S+)\s+(.*)", line) if not match: continue rest = match.group(2) desc = re.split(r"\s{2,}", rest.strip())[-1].strip() TUI_COMMAND_HELP.setdefault(match.group(1), desc) return TUI_COMMAND_HELP def tui_complete_mention(prefix, directory=None, bots=(), models=()): """Completion source for @mentions: [(label, value)] filtered by prefix across bots, providers, known models, and files.""" found = [] low = (prefix or "").lower() for name in bots: if name.lower().startswith(low): found.append(("@%s [bot]" % name, name)) for label in PROVIDER_LABELS: if label.lower().startswith(low): found.append(("@%s [provider]" % label, label)) if MENTION_AUTO.startswith(low): found.append(("@%s [reset pins]" % MENTION_AUTO, MENTION_AUTO)) if not models: models = sorted(set(ZEN_FREE_MODELS) | set(POLLINATIONS_MODELS) | set(DEVPLACE_FREE_ROUTES) | {OPENROUTER_MODEL}) for mid in models: if mid.lower().startswith(low): found.append(("@%s [model]" % mid, mid)) for rel in complete_files(prefix or "", directory or dir_key()): found.append(("@%s [file]" % rel, rel)) return found[:100] def tui_complete_command(prefix): """Completion source for slash commands: [(label, name)] filtered by prefix. Labels carry the HELP_TEXT description when one exists.""" help_map = tui_command_help() found = [] for name in COMMANDS: if not name.startswith(prefix or ""): continue desc = help_map.get(name, "") label = "/%s - %s" % (name, desc) if desc else "/" + name found.append((label, name)) return found class VaultHistory: """History backend over the vault's input_history table: load() gives the profile's entries oldest to newest, append(text) records one. The REPL and the chat scene both use it, so they share one sealed list.""" def __init__(self, store, profile, bot=lambda: "main"): self.store = store self.profile = profile self.bot = bot @staticmethod def for_agent(agent): store = getattr(agent, "store", None) if store is None or not hasattr(store, "history_add"): return None return VaultHistory(store, agent.profile, lambda: agent.bot) def load(self): return self.store.history_load(self.profile) def append(self, text): return self.store.history_add(self.profile, self.bot(), text) class TuiHistory: """Input history with readline semantics: up recalls older entries, down walks back toward the unfinished draft you started from. Persistence is a pluggable backend with load() and append(text) (VaultHistory); without one it lives in memory only. Capped at INPUT_HISTORY_CAP entries.""" def __init__(self, backend=None, cap=INPUT_HISTORY_CAP): self.backend = backend self.cap = max(1, int(cap)) self.entries = [] self.pos = None self.draft = "" self.write_errors = 0 if backend is not None: try: self.entries = [str(item) for item in backend.load()][-self.cap:] except Exception: self.entries = [] def add(self, text): text = str(text or "").strip() self.reset() if not text or (self.entries and self.entries[-1] == text): return self.entries.append(text) del self.entries[:-self.cap] if self.backend is not None: try: self.backend.append(text) except Exception: self.write_errors += 1 def reset(self): self.pos = None self.draft = "" def older(self, current): """Previous entry, or None at the oldest. The first step remembers current as the draft.""" if not self.entries: return None if self.pos is None: self.draft = str(current or "") self.pos = len(self.entries) if self.pos == 0: return None self.pos -= 1 return self.entries[self.pos] def newer(self): """Next entry; past the newest it returns the draft and leaves browsing. None when not browsing.""" if self.pos is None: return None self.pos += 1 if self.pos >= len(self.entries): draft = self.draft self.reset() return draft return self.entries[self.pos] class TuiTextBox(TuiPanel): """Multiline text editor. Arrows/home/end/pgup/pgdn move, Enter splits lines, Backspace/Delete join them, clicks position the cursor. Escape is left alone so dialogs can close on it, unless a completion popup is open: then Esc dismisses the popup first. complete= wires a prefix-to-items callable for /words and mentions= the same for @words, both driving a TuiAutocomplete popup.""" def __init__(self, text="", complete=None, mentions=None, send_on_enter=False, on_send=None, max_chars=0, placeholder="", history=None, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) self.lines = str(text).split("\n") or [""] self.cx = len(self.lines[-1]) self.cy = len(self.lines) - 1 self.top = 0 self.complete_fn = complete self.mention_fn = mentions self.complete_popup = None self.send_on_enter = send_on_enter self.on_send = on_send self.max_chars = max(0, int(max_chars or 0)) self.placeholder = placeholder or "" self.history = history self._rows_seen = len(self.lines) def touch(self): """Besides repainting, tell the owner when the number of lines changed (on_change, on the loop), so a composer can resize without a polling timer.""" super().touch() rows = len(self.lines) if rows != self._rows_seen and self.app is not None: self._rows_seen = rows self.app.run_on_loop(lambda: self.emit(TuiChange(panel=self, rows=rows))) @property def value(self): return "\n".join(self.lines) def measure(self): width = 0 for line in self.lines: width = max(width, len(line)) return (max(1, width), max(1, len(self.lines))) def _clamp(self): self.cy = max(0, min(len(self.lines) - 1, self.cy)) self.cx = max(0, min(len(self.lines[self.cy]), self.cx)) def _touch(self): if self.app is not None: self.touch() def _origin(self): edge = 1 if self.border else 0 return (self.ax + edge + self.padding[3], self.ay + edge + self.padding[0]) def content_rows(self): width = self._content_w() height = self._content_h() self._clamp() if self.cy < self.top: self.top = self.cy if self.cy >= self.top + height: self.top = self.cy - height + 1 left = max(0, self.cx - width + 1) focused = self.app is not None and self.app.focused is self and self.enabled hint = self.placeholder if (self.placeholder and not self.value.strip()) else "" hint_style = self.style.merged(TuiStyle(fg="role:muted", dim=True)) if hint else self.style rows = [] for pos in range(height): index = self.top + pos line = self.lines[index][left:left + width] if 0 <= index < len(self.lines) else "" style = self.style if hint and index == 0: line, style = hint, hint_style line = line.ljust(width) if focused and index == self.cy: col = self.cx - left before, at, after = line[:col], line[col:col + 1], line[col + 1:] rows.append([TuiSpan(before, style), TuiSpan(at or " ", style.merged(TuiStyle(reverse=True))), TuiSpan(after, style)]) else: rows.append([TuiSpan(line[:width], style)]) return rows def insert(self, text): text = str(text) if self.max_chars: room = self.max_chars - len(self.value) if room <= 0: return text = text[:room] parts = text.split("\n") line = self.lines[self.cy] head, tail = line[:self.cx], line[self.cx:] self.lines[self.cy] = head + parts[0] self.cx = len(head) + len(parts[0]) for part in parts[1:-1]: self.cy += 1 self.lines.insert(self.cy, part) if len(parts) > 1: self.cy += 1 self.lines.insert(self.cy, parts[-1] + tail) self.cx = len(parts[-1]) else: self.lines[self.cy] += tail self._clamp() self._touch() def _complete_refresh(self): popup = self.complete_popup if self.app is None: if popup is not None: self._complete_hide() return match = COMPLETE_WORD_RE.search(self.lines[self.cy][:self.cx]) fn = None if match: fn = self.complete_fn if match.group(1) == "/" else self.mention_fn try: items = fn(match.group(2)) if (match and callable(fn)) else [] except Exception: items = [] if not items: if popup is not None: self._complete_hide() return if popup is None: popup = TuiAutocomplete() popup.on_select = self._complete_picked self.complete_popup = popup self.root().add(popup) popup.set_items(items) self._complete_place(popup) def _complete_place(self, popup): root = self.root() edge = 1 if root.border else 0 ox = root.ax + edge + root.padding[3] oy = root.ay + edge + root.padding[0] room_w = max(0, root._content_w()) room_h = max(0, root._content_h()) chrome_w = popup.chrome_w() chrome_h = popup.chrome_h() width = 4 for label, _value in popup.options: width = max(width, len(label) + 4) width = max(4, min(width, max(4, room_w - chrome_w))) height = max(1, min(len(popup.options), TUI_COMPLETE_MAXH)) view_h = self._content_h() if self.cy < self.top: self.top = self.cy elif self.cy >= self.top + view_h: self.top = self.cy - view_h + 1 box_ox, box_oy = self._origin() left = max(0, self.cx - self._content_w() + 1) cur_x = box_ox + (self.cx - left) cur_y = box_oy + (self.cy - self.top) popup.x = min(max(0, cur_x - ox), max(0, room_w - width - chrome_w)) below = cur_y - oy + 1 if below + height + chrome_h <= room_h: popup.y = below else: popup.y = max(0, cur_y - oy - height - chrome_h) popup.w = width popup.h = height def _complete_move(self, key): popup = self.complete_popup if popup is None: return if key == "up": popup.move(-1) elif key == "down": popup.move(1) elif key == "home": popup.move(-len(popup.options)) elif key == "end": popup.move(len(popup.options)) elif key == "pageup": popup.move(-max(1, popup._content_h())) elif key == "pagedown": popup.move(max(1, popup._content_h())) def _complete_hide(self): popup = self.complete_popup self.complete_popup = None if popup is not None and popup.parent is not None: popup.parent.remove(popup) def _complete_picked(self, event): if event.panel is not self.complete_popup: return event.stop() self._complete_accept(event.value) def _complete_accept(self, value): line = self.lines[self.cy] match = COMPLETE_WORD_RE.search(line[:self.cx]) if not match: self._complete_hide() return trigger = match.group(1) word_start = match.start(1) chars = "_-" if trigger == "/" else "_-./" end = self.cx while end < len(line) and (line[end].isalnum() or line[end] in chars): end += 1 replacement = trigger + str(value) self.lines[self.cy] = line[:word_start] + replacement + line[end:] self.cx = word_start + len(replacement) if self.cx >= len(self.lines[self.cy]): self.lines[self.cy] += " " self.cx += 1 self._clamp() self._touch() self._complete_hide() def on_paste(self, event): if not self.enabled: return event.stop() text = (event.text or "").replace("\r\n", "\n").replace("\r", "\n") if text: self.insert(text) self._complete_refresh() def on_key(self, event): if not self.enabled: return key = event.key or "" if key == "escape" and self.complete_popup is not None: event.stop() self._complete_hide() return if key in ("escape", "tab", "backtab"): return if self.complete_popup is not None: if key in ("up", "down", "home", "end", "pageup", "pagedown"): event.stop() self._complete_move(key) return if key == "enter": event.stop() self.complete_popup.choose() return if key == "left": event.stop() self.cx -= 1 elif key == "right": event.stop() self.cx += 1 elif key == "up": event.stop() if self.history is not None and self.cy == 0: self._recall(self.history.older(self.value)) return self.cy -= 1 elif key == "down": event.stop() if self.history is not None and self.cy == len(self.lines) - 1 and self.history.pos is not None: self._recall(self.history.newer()) return self.cy += 1 elif key == "home": event.stop() self.cx = 0 elif key == "end": event.stop() self.cx = len(self.lines[self.cy]) elif key in ("pageup", "pagedown"): event.stop() step = max(1, self._content_h()) self.cy += -step if key == "pageup" else step elif key == "enter": event.stop() if self.send_on_enter and callable(self.on_send): self._complete_hide() self.on_send(self.value) return self.insert("\n") self._complete_refresh() return elif key in ("shift+enter", "alt+enter"): event.stop() self.insert("\n") self._complete_refresh() return elif key == "backspace": event.stop() if self.cx > 0: line = self.lines[self.cy] self.lines[self.cy] = line[:self.cx - 1] + line[self.cx:] self.cx -= 1 elif self.cy > 0: prev = self.lines[self.cy - 1] self.cx = len(prev) self.lines[self.cy - 1] = prev + self.lines[self.cy] del self.lines[self.cy] self.cy -= 1 elif key == "delete": event.stop() line = self.lines[self.cy] if self.cx < len(line): self.lines[self.cy] = line[:self.cx] + line[self.cx + 1:] elif self.cy < len(self.lines) - 1: self.lines[self.cy] = line + self.lines[self.cy + 1] del self.lines[self.cy + 1] else: char = event.char or "" if char and "+" not in key and not key.startswith(("ctrl+", "alt+")): event.stop() self.insert(char) self._complete_refresh() return return self._clamp() self._touch() self._complete_refresh() def _recall(self, text): """Replace the buffer with a history entry, cursor at the end like readline. None (nothing older/newer) leaves the buffer alone.""" if text is None: return self.lines = str(text).split("\n") or [""] self.cy = len(self.lines) - 1 self.cx = len(self.lines[-1]) self.top = 0 self._complete_hide() self._clamp() self._touch() def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() ox, oy = self._origin() left = max(0, self.cx - self._content_w() + 1) self.cy = self.top + (event.y - oy) self.cx = left + (event.x - ox) self._clamp() self._touch() self._complete_refresh() def on_blur(self, event): self._complete_hide() def on_unmount(self, event): self._complete_hide() class TuiDialog(TuiPanel): """Centered modal panel with a button row. Esc closes.""" def __init__(self, *children, **kwargs): kwargs.setdefault("layer", "dialog") kwargs.setdefault("modal", True) kwargs.setdefault("border", True) kwargs.setdefault("x", "center") kwargs.setdefault("y", "center") self.on_close = kwargs.pop("on_close", None) self.closable = kwargs.pop("closable", True) super().__init__(**kwargs) if children: self.add(*children) def buttons(self, *specs): row = TuiCols(gap=2) for label, callback in specs: row.add(TuiButton(label, on_press=callback)) self.add(row) return row def on_key(self, event): if event.key == "escape": event.stop() self.close() def on_mount(self, event): if self.app is not None: self.app.remember_focus() self.app.focus_first(self) class TuiQuestion(TuiDialog): """Prompt plus clickable/keyboard options. Answers via on_done(value).""" def __init__(self, prompt="", options=(), on_done=None, multi=False, **kwargs): super().__init__(**kwargs) self.on_done = on_done self.prompt = self.add(TuiText(prompt, wrap=True)) self.picker = TuiOptions(options, multi=multi) self.add(self.picker) if multi: self.buttons(("OK", lambda _event: self._finish()), ("Cancel", lambda _event: self.close())) else: self.buttons(("Cancel", lambda _event: self.close())) def on_select(self, event): if event.panel is not self.picker or self.picker.multi: return event.stop() if callable(self.on_done): self.on_done(event.value) self.close() def _finish(self): values = [self.picker.options[pos][1] for pos in sorted(self.picker.chosen)] if callable(self.on_done): self.on_done(values) self.close() class TuiPromptDialog(TuiDialog): """Pretty prompt box: label, multiline editor, OK/Cancel. OK runs on_execute(value) then closes; Cancel and Esc just close.""" def __init__(self, label="Enter prompt:", box_h=3, on_execute=None, complete=None, mentions=None, **kwargs): kwargs.setdefault("title", "Prompt") kwargs.setdefault("w", 56) super().__init__(**kwargs) self.on_execute = on_execute self.add(TuiText(label, style=TuiStyle(dim=True))) self.box = TuiTextBox(h=box_h, style=TuiStyle(bg="black", fg="white"), complete=complete, mentions=mentions) self.add(self.box) self.buttons(("OK", lambda _event: self._run()), ("Cancel", lambda _event: self.close())) def _run(self): if callable(self.on_execute): self.on_execute(self.box.value) self.close() class TuiProgress(TuiPanel): """Fraction bar with percent label.""" def __init__(self, value=0.0, **kwargs): super().__init__(**kwargs) self.value = 0.0 self.set(value) def measure(self): return (20, 1) def set(self, value): self.value = max(0.0, min(1.0, float(value))) if self.app is not None: self.touch() def content_rows(self): width = self._content_w() filled = int(width * self.value) bar = "█" * filled + "░" * max(0, width - filled - 7) + " %3d%%" % int(self.value * 100) return [[TuiSpan(bar[:width].ljust(width), self.style)]] class TuiLog(TuiPanel): """Virtualized scrollback view. Wheel scrolls; appending follows the tail only while already pinned there. Lines are (text, spans) pairs: append() takes plain text plus one style, append_spans() takes pre-built multi-style runs (chat bubbles).""" shifts = True def __init__(self, **kwargs): kwargs.setdefault("scrollable", True) super().__init__(**kwargs) self.lines = [] def measure(self): width = 0 for text, _spans in self.lines[-500:]: width = max(width, len(text)) return (width, max(1, len(self.lines))) def _push(self, text, spans): pinned = self.scroll_y >= max(0, len(self.lines) - self._content_h()) self.lines.append((text, spans)) if pinned: self.scroll_y = max(0, len(self.lines) - self._content_h()) if self.app is not None: self.touch() def append(self, text, style=None): text = str(text) self._push(text, [TuiSpan(text, style or self.style)]) def append_spans(self, spans): spans = list(spans) or [TuiSpan("", self.style)] self._push("".join(span.text for span in spans), spans) def content_rows(self): """Every line, unscrolled: TuiPanel.paint applies scroll_x/scroll_y itself, so scrolling here too would shift the view twice.""" return [spans for _text, spans in self.lines] def paint_cells(self, compositor): """paint() in cell space. A line's converted row is kept between paints (looked up by the identity of its span list), so scrolling or repainting after a neighbor changed converts only lines it has not seen. Lines are append-only span lists, which is what makes that safe. A subclass that draws its own rows or content gets the plain path.""" kind = type(self) if kind.paint is not TuiPanel.paint or kind.content_rows is not TuiLog.content_rows or self.aw < self.chrome_w() + 2: return TuiPanel.paint_cells(self, compositor) return self._log_cells(compositor) def _log_cells(self, compositor): width = self._content_w() height = self._content_h() pad_top, pad_right, pad_bottom, pad_left = self.padding style = self.style key = style.key() blank = compositor.cell(" ", key) inner_w = width + pad_left + pad_right edge_left = [] edge_right = [] top = bottom = None if self.border: framed = self._add_border([[]], inner_w) top, bottom = compositor.cells_from([framed[0], framed[-1]], self.aw) bar_left, bar_right = compositor.cells_from([framed[1]], 2)[0] edge_left = [bar_left] edge_right = [bar_right] head = edge_left + [blank] * pad_left tail = [blank] * pad_right + edge_right pad_row = edge_left + [blank] * inner_w + edge_right empty = [blank] * width rows = [top] if top is not None else [] rows.extend([pad_row] * pad_top) memo = self.__dict__.get("_row_memo") if memo is None: memo = self._row_memo = {} lines = self.lines total = len(lines) scroll_x = self.scroll_x sig = (scroll_x, width, key) first = self.scroll_y found = [] missing = [] for index in range(first, min(first + height, total)): spans = lines[index][1] entry = memo.get(id(spans)) if entry is not None and entry[0] is spans and entry[1] == sig: found.append(entry[2]) else: found.append(None) missing.append(spans) if missing: if len(memo) + len(missing) > TUI_ROW_MEMO: memo.clear() converted = iter(compositor.cells_from([tui_slice_spans(spans, scroll_x, width, style) for spans in missing], width)) for pos, cells in enumerate(found): if cells is None: spans = lines[first + pos][1] cells = found[pos] = next(converted) memo[id(spans)] = (spans, sig, cells) for cells in found: rows.append(head + cells + tail) for _index in range(height - len(found)): rows.append(head + empty + tail) rows.extend([pad_row] * pad_bottom) if bottom is not None: rows.append(bottom) full = [blank] * self.aw while len(rows) < self.ah: rows.append(full) return rows[:self.ah] def on_scroll(self, event): event.stop() if event.direction == "up": self.scroll_by(0, -3) elif event.direction == "down": self.scroll_by(0, 3) def on_key(self, event): if event.key == "pageup": event.stop() self.scroll_by(0, -max(1, self._content_h())) elif event.key == "pagedown": event.stop() self.scroll_by(0, max(1, self._content_h())) elif event.key == "home": event.stop() self.scroll_y = 0 if self.app is not None: self.touch() elif event.key == "end": event.stop() self.scroll_y = max(0, len(self.lines) - self._content_h()) if self.app is not None: self.touch() TUI_TERM_KEYS = {"enter": b"\r", "tab": b"\t", "backtab": b"\x1b[Z", "backspace": b"\x7f", "escape": b"\x1b", "up": b"\x1b[A", "down": b"\x1b[B", "right": b"\x1b[C", "left": b"\x1b[D", "home": b"\x1b[H", "end": b"\x1b[F", "pageup": b"\x1b[5~", "pagedown": b"\x1b[6~", "delete": b"\x1b[3~", "insert": b"\x1b[2~", "f1": b"\x1bOP", "f2": b"\x1bOQ", "f3": b"\x1bOR", "f4": b"\x1bOS", "f5": b"\x1b[15~", "f6": b"\x1b[17~", "f7": b"\x1b[18~", "f8": b"\x1b[19~", "f9": b"\x1b[20~", "f10": b"\x1b[21~", "f11": b"\x1b[23~", "f12": b"\x1b[24~"} class TuiTerminal(TuiPanel): """A live sub-terminal: real pty child streamed through a VT screen.""" memoize_measure = False def __init__(self, cmd=None, interactive=True, **kwargs): kwargs.setdefault("focusable", interactive) kwargs.setdefault("border", True) super().__init__(**kwargs) self.cmd = cmd or [os.environ.get("SHELL") or "/bin/sh"] self.interactive = interactive self.screen = TuiVtScreen(24, 80) self.pid = None self.fd = None self.exit_status = None self.error = "" self._row_memo = {} self._pending = bytearray() def measure(self): return (self.screen.cols, self.screen.rows) def arrange(self, x, y, w, h, clip): super().arrange(x, y, w, h, clip) width = self._content_w() height = self._content_h() if width != self.screen.cols or height != self.screen.rows: self.screen.resize(max(1, height), max(1, width)) self._apply_winsize() def spawn(self): if self.pid is not None or pty is None: if pty is None: self.error = "pty not available on this platform" return False pid, fd = pty.fork() if pid == 0: env = dict(os.environ) env.setdefault("TERM", "xterm-256color") try: os.execvpe(self.cmd[0], self.cmd, env) except OSError as exc: sys.stderr.write("tui terminal: cannot exec %s: %s\n" % (self.cmd[0], exc)) os._exit(127) self.pid = pid self.fd = fd self._apply_winsize() return True def _apply_winsize(self): if self.fd is None or fcntl is None or termios is None: return try: fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.screen.rows, self.screen.cols, 0, 0)) except OSError: pass def fds(self): if self.fd is not None and self.exit_status is None: return [self.fd] return [] def feed_output(self): if self.fd is None: return False try: data = os.read(self.fd, 65536) except OSError: data = b"" if not data: self.poll_exit() return False self.screen.feed(data) if self.app is not None: self.touch() return True def drain_pending(self): while self._pending and self.fd is not None and self.exit_status is None: try: written = os.write(self.fd, bytes(self._pending[:4096])) except OSError: break if written <= 0: break del self._pending[:written] def poll_exit(self): if self.pid is None or self.exit_status is not None: return self.exit_status try: done, status = os.waitpid(self.pid, os.WNOHANG) except OSError: done, status = 1, 0 if done: self.exit_status = status try: os.close(self.fd) except OSError: pass self.fd = None if self.app is not None: self.touch() return self.exit_status def terminate(self): if self.pid is None or self.exit_status is not None: return try: os.killpg(self.pid, signal.SIGKILL) except OSError: try: os.kill(self.pid, signal.SIGKILL) except OSError: pass try: _done, status = os.waitpid(self.pid, 0) self.exit_status = status except OSError: self.exit_status = 0 if self.fd is not None: try: os.close(self.fd) except OSError: pass self.fd = None def content_rows(self): width = self._content_w() height = self._content_h() if self.error: rows = [[TuiSpan(self.error[:width].ljust(width), self.style.merged(TuiStyle(fg="red")))]] while len(rows) < height: rows.append([TuiSpan(" " * width, self.style)]) return rows show_cursor = self.app is not None and self.app.focused is self and self.screen.cursor_visible and self.exit_status is None rows = [] memo = self._row_memo if len(memo) > TUI_ROW_MEMO: memo.clear() base_key = self.style.key() for row in range(min(height, self.screen.rows)): grid_row = self.screen.grid[row] cursor_x = self.screen.cx if show_cursor and row == self.screen.cy else -1 key = (tuple(grid_row[:width]), width, base_key, cursor_x) spans = memo.get(key) if spans is None: cells = [] for col in range(min(width, self.screen.cols)): cell = grid_row[col] char = cell[0] if cell else " " style = TuiVtScreen.style_for(cell, self.style) if col == cursor_x: style = style.merged(TuiStyle(reverse=True)) cells.append((char, style)) while len(cells) < width: cells.append((" ", self.style)) spans = memo[key] = tui_runs(cells) rows.append(spans) while len(rows) < height: rows.append([TuiSpan(" " * width, self.style)]) return rows def on_click(self, event): if not event.pressed or not self.interactive: return event.stop() self.focus() def on_key(self, event): if not self.interactive or self.fd is None or self.exit_status is not None: return data = TuiTerminal.key_bytes(event) if data: event.stop() self._pending += data def send_text(self, text): self._pending += text.encode("utf-8", "replace") def on_paste(self, event): if not self.interactive or self.exit_status is not None: return event.stop() self._pending += (event.text or "").encode("utf-8", "replace") @staticmethod def key_bytes(event): key = event.key or "" if key in TUI_TERM_KEYS and not getattr(event, "modifiers", None): return TUI_TERM_KEYS[key] if key.startswith("ctrl+") and len(key) == 6: return bytes([ord(key[5:].upper()) - 0x40]) if key.startswith("alt+") and len(event.char or "") == 1: return b"\x1b" + event.char.encode("utf-8", "replace") if "+" in key: mods, _, base = key.rpartition("+") finals = {"up": "A", "down": "B", "right": "C", "left": "D", "home": "H", "end": "F"} if base in finals: code = 1 + ("shift" in mods) + ("alt" in mods) * 2 + ("ctrl" in mods) * 4 return ("\x1b[1;%d%s" % (code, finals[base])).encode("utf-8") return b"" if event.char: return event.char.encode("utf-8", "replace") return b"" class TuiError(Exception): pass class TuiDoubleTap: """Two same-key presses inside a window. Clock is injected (now) so tests stay deterministic; the app passes time.time().""" def __init__(self, timeout=0.4): self.timeout = timeout self.last_key = None self.last_at = 0.0 def hit(self, key, now): if key == self.last_key and (now - self.last_at) <= self.timeout: self.last_key = None self.last_at = 0.0 return True self.last_key = key self.last_at = now return False class TuiApp: """Owns the screen: terminal setup, tick loop, focus, timers, routing. One asyncio loop: stdin and sub-terminal ptys are loop readers/writers that dispatch as bytes arrive, a frame clock (1/fps) advances timers and animations, relayouts on demand, composites and writes one frame. Dirty flags skip idle frames entirely. Blocking work goes through offload(): it runs on its own thread and calls back on the loop, so the UI never freezes. Ctrl+C always quits (terminal state restores in a finally).""" def __init__(self, root=None, fps=TUI_FPS): self.root = root or TuiPanel(flow=None, style=TuiStyle(bg="black")) self.fps = max(1, tui_int(fps, TUI_FPS)) self.rows = 24 self.cols = 80 self.focused = None self._focus_memory = [] self.double_tap = TuiDoubleTap() self.on_double_tap = None self.compositor = TuiCompositor() self.hitmap = TuiHitMap() self.input = TuiInput() self.animations = [] self.timers = [] self._timer_seq = 0 self._need_layout = True self._need_paint = True self._lock = threading.Lock() self._drop_all = True self._damage = set() self._stack = [] self._layout_full = True self._layout_starts = [] self._watch = set() self._shifts = [] self._restack = False self._geom_rects = [] self._geom_full = True self.layouts = {"full": 0, "partial": 0, "arranged": 0} self._tree_version = 0 self._last_cursor = None self.verify = bool(os.environ.get("TAI_TUI_VERIFY")) self.verified = 0 self._running = False self._resize_pending = False self._saved_termios = None self._old_winch = None self._old_chld = None self._chld_set = False self._loop = None self._failure = None self._input_seen = False self._last_frame = 0.0 self._stdout_fd = None self._stdin_fd = None self._wake = None self._loop_thread = None self._tickers = [] self._tickers_version = -1 self._terms = [] self._terms_version = -1 self.iterations = 0 self._readers = {} self._writers = {} self._eof = set() self._workers = [] # -- structure --------------------------------------------------- # def mount(self, root=None): if root is not None: self.root = root self.root._bind_app(self) self.focused = None self._layout_full = True self._stack = [] self.layout() self.auto_focus() self.dirty_paint() return self.root def dirty_layout(self, paint=True): """Anonymous "geometry or structure changed somewhere": the next layout arranges the whole tree. paint=True (the default, for callers that also edited state without saying so) also drops every cached panel; paint=False trusts the panels' own touch() and lets the layout pass diff rects. Precise callers use relayout().""" with self._lock: self._need_layout = True self._need_paint = True self._layout_full = True if paint: self._drop_all = True self.wake() LAYOUT_STARTS = 64 def relayout(self, panel, inner=False, restack=False, only=False): """Thread-safe and precise: only this panel's subtree is arranged again. inner=False means the panel's own size, position or chrome changed, so its parent lays it out again; inner=True means its children (or content) changed, so it lays them out again. Either way the work climbs only while the panel's size follows its content (content_sized), and stops at the first fixed-size panel. restack marks a panel added, removed, shown or hidden. only=True arranges just this panel again, in the box it already has (its scroll offsets changed, so they need clamping).""" with self._lock: self._need_layout = True self._need_paint = True if len(self._layout_starts) >= TuiApp.LAYOUT_STARTS: self._layout_full = True else: self._layout_starts.append((panel, "self" if only else "inner" if inner else "own")) panel.mark_pending() if restack: self._restack = True self._tree_version += 1 self.wake() def run_on_loop(self, call): """Run call() on the loop thread: now when already there (or when no loop runs, as in headless tests), else queued thread-safely.""" loop = self._loop if loop is None or threading.get_ident() == self._loop_thread: call() return try: loop.call_soon_threadsafe(call) except RuntimeError: pass def tree_changed(self): """A panel gained or lost an on_tick handler: the cached ticker and terminal lists are stale.""" with self._lock: self._tree_version += 1 def restack(self): """Paint order changed (z or layer) without any geometry change.""" with self._lock: self._need_layout = True self._need_paint = True self._restack = True self.wake() def dirty_paint(self): """Anonymous "something changed": repaint every panel. Precise callers use panel.touch() and only that panel's rect repaints.""" with self._lock: self._need_paint = True self._drop_all = True self.wake() def damage(self, panel): """Thread-safe: turn threads touch panels while the loop composes.""" with self._lock: self._damage.add(panel) self._need_paint = True if panel.w == "auto" or panel.h == "auto": self._watch.add(panel) self._need_layout = True self.wake() def wake(self): """Thread-safe: end the main loop's sleep early. The loop sleeps until its next timer, animation frame or event, so anything that makes a frame due (a touch, a timer, a thread's result) calls this.""" loop = self._loop if loop is None or self._wake is None: return if threading.get_ident() == self._loop_thread: self._wake.set() else: try: loop.call_soon_threadsafe(self._wake.set) except RuntimeError: pass def set_theme(self, name): """Switch the global theme and repaint every cell. Cells are diffed by style key (role names), which does not change with the theme, so the compositor must forget what the terminal shows. Raises ValueError naming the available themes when unknown.""" tui_set_theme(name) self.compositor.invalidate() self.dirty_paint() return tui_theme() def after(self, delay, callback): return self._schedule(float(delay), None, callback) def every(self, interval, callback): return self._schedule(float(interval), float(interval), callback) def cancel_timer(self, handle): self.timers = [entry for entry in self.timers if entry is not handle] def _schedule(self, delay, interval, callback): self._timer_seq += 1 handle = [time.time() + max(0.0, delay), interval, callback, self._timer_seq] self.timers.append(handle) self.wake() return handle def _fire_timers(self, now): for handle in list(self.timers): deadline, interval, callback, _seq = handle if now < deadline: continue try: callback() except Exception: pass if interval is None: if handle in self.timers: self.timers.remove(handle) else: handle[0] = now + interval # -- focus ------------------------------------------------------- # def top_modal(self): best = None for node in self.root.walk(): if node.modal and node.is_shown() and node.enabled: if best is None or (node.effective_z(), node.seq) > (best.effective_z(), best.seq): best = node return best def focusables(self, scope=None): modal = self.top_modal() base = modal if modal is not None else (scope or self.root) found = [node for node in base.walk() if node.focusable and node.enabled and node.is_shown()] found.sort(key=lambda node: (node.ay, node.ax, node.seq)) return found def set_focus(self, panel): if panel is not None and (not panel.focusable or not panel.enabled or not panel.is_shown()): return modal = self.top_modal() if modal is not None and panel is not None: node = panel inside = False while node is not None: if node is modal: inside = True break node = node.parent if not inside: return if self.focused is panel: return old = self.focused self.focused = panel if old is not None: old.emit(TuiBlur(panel=old)) old.touch() if panel is not None: panel.emit(TuiFocus(panel=panel)) panel.touch() self._need_paint = True def focus_first(self, scope=None): found = self.focusables(scope) if found: self.set_focus(found[0]) def auto_focus(self): found = self.focusables() for node in found: if not isinstance(node, TuiTerminal): self.set_focus(node) return if found: self.set_focus(found[0]) def remember_focus(self): self._focus_memory.append(self.focused) def _restore_focus(self): while self._focus_memory: prev = self._focus_memory.pop() if (prev is not None and prev.focusable and prev.enabled and prev.is_shown() and prev.root() is self.root): self.set_focus(prev) if self.focused is prev: return self.auto_focus() def focus_next(self): found = self.focusables() if not found: return if self.focused in found: self.set_focus(found[(found.index(self.focused) + 1) % len(found)]) else: self.set_focus(found[0]) def focus_prev(self): found = self.focusables() if not found: return if self.focused in found: self.set_focus(found[(found.index(self.focused) - 1) % len(found)]) else: self.set_focus(found[-1]) def ensure_focus(self): node = self.focused if (node is None or node.root() is not self.root or not node.is_shown() or not node.enabled or not node.focusable): self.focused = None self.auto_focus() def close_panel(self, panel): panel.emit(TuiClose(panel=panel)) if panel.parent is not None: panel.parent.remove(panel) node = self.focused if node is not None and (node is panel or node.root() is not self.root or not node.is_shown()): self.focused = None self._restore_focus() # -- layout + paint ---------------------------------------------- # def layout(self): """Arrange, restack, and work out what the pass changed. Only the subtrees that asked (relayout) are arranged again, each from the nearest ancestor whose size does not follow its content; an anonymous dirty_layout, a resize or the first pass arranges everything. Moved or resized panels damage only their old and new rects, and a stack that gained or lost panels damages those panels' rects; a stack whose remaining panels changed order is the whole screen.""" with self._lock: self._need_layout = False full, self._layout_full = self._layout_full, False restack, self._restack = self._restack, False starts, self._layout_starts = self._layout_starts, [] watch, self._watch = self._watch, set() touched = list(self._damage) for panel in watch: seen = panel._measure_seen size = panel.measure() if size != seen and panel.root() is self.root: outer = seen is None or (panel.w == "auto" and size[0] != seen[0]) or (panel.h == "auto" and size[1] != seen[1]) starts.append((panel, "inner" if outer else "self")) everything = full or not self._stack roots = [self.root] if everything else self._layout_roots(starts) if roots is None: everything, roots = True, [self.root] TuiPanel._measure_memo = {} arranged = [] if not everything: for panel in [item[0] for item in starts] + touched: panel.mark_pending() TuiPanel._stale = True TuiPanel._arranged = arranged try: for node in roots: if everything: node.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) else: node.arrange(*node._arrange) finally: TuiPanel._measure_memo = None TuiPanel._stale = None TuiPanel._arranged = None self.layouts["full" if everything else "partial"] += 1 if self.verify and not everything: self._verify_layout() if everything or restack or self._geometry_changed_membership(arranged): self._restack_diff() else: self._geometry_diff(arranged) self.hitmap.rebuild(self._stack) def _layout_roots(self, starts): """The panels to arrange again for these requests, outermost only, or None when the request cannot be narrowed (arrange everything).""" found = [] for panel, mode in starts: if panel.root() is not self.root: continue node = panel.parent if mode == "own" else panel if node is None: return None while mode != "self" and node.content_sized(): node = node.parent while node._arrange is None and node.parent is not None: node = node.parent if node._arrange is None: return None if node.is_shown(): found.append(node) keep = set(found) roots = [] for node in found: above = node.parent while above is not None and above not in keep: above = above.parent if above is None and node not in roots: roots.append(node) return roots def _geometry_changed_membership(self, arranged): """True when a re-arranged panel entered or left the stack (it became zero-sized, or was zero-sized and grew).""" for node in arranged: listed = node._geom is not None if listed != (node.aw > 0 and node.ah > 0): return True return False def _geometry_diff(self, arranged): """Same stack: damage the old and new rects of the moved or resized panels among the panels this pass arranged.""" rects = self._geom_rects for node in arranged: geom = (node.ax, node.ay, node.aw, node.ah, node.clip) was = node._geom self.layouts["arranged"] += 1 if was is None or was == geom: continue rects.append(was[4]) rects.append(geom[4]) if was[2:4] != geom[2:4]: node._cells = None node._geom = geom def _restack_diff(self): """The stack may have changed: rebuild it and damage what differs.""" old = self._stack stack = self.compositor.flatten(self.root) self._tree_version += 1 rects = self._geom_rects if not old: self._geom_full = True elif stack != old: now = set(stack) before = set(old) if [node for node in old if node in now] != [node for node in stack if node in before]: self._geom_full = True else: for node in old: if node not in now: rects.append(node._geom[4]) node._geom = None for node in stack: geom = (node.ax, node.ay, node.aw, node.ah, node.clip) was = node._geom if was is None: rects.append(geom[4]) elif was != geom: rects.append(was[4]) rects.append(geom[4]) if was[2:4] != geom[2:4]: node._cells = None node._geom = geom self.layouts["arranged"] += len(stack) self._stack = stack def _verify_layout(self): """Oracle check: arranging the whole tree again must not move any panel the partial pass left alone. Raises TuiError naming the first.""" def snapshot(): return [(node, node.ax, node.ay, node.aw, node.ah, node.clip, node.scroll_x, node.scroll_y) for node in self.root.walk() if node._arrange is not None] before = snapshot() TuiPanel._measure_memo = {} try: self.root.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) finally: TuiPanel._measure_memo = None after = {id(row[0]): row for row in snapshot()} for row in before: want = after.get(id(row[0])) if want is not None and want[1:] != row[1:]: raise TuiError("partial layout diverged from full layout for %s id=%r: partial %r full %r" % ( type(row[0]).__name__, row[0].id, row[1:], want[1:])) def _damage_rects(self): """Rects to recompose for this frame, or None for the whole screen. Consumes the pending damage.""" with self._lock: self._need_paint = False drop = self._drop_all touched, self._damage = self._damage, set() self._drop_all = False rects, self._geom_rects = self._geom_rects, [] full = drop or self._geom_full self._geom_full = False self._shifts = [] if drop: for node in self.root.walk(): node._cells = None node._shift = 0 if full: for panel in touched: panel._shift = 0 return None shown = set(self._stack) for panel in touched: if panel in shown: rects.append(panel.clip) if panel._shift: self._shifts.append(panel.content_rect() + (panel._shift,)) panel._shift = 0 if len(rects) > TUI_DAMAGE_RECTS: self._shifts = [] return None return rects def frame_bytes(self): """The bytes that bring the terminal up to date; "" when nothing on screen changed (no cell differs and the cursor stayed).""" if self._need_layout: self.layout() compositor = self.compositor fresh = compositor.last is None rects = self._damage_rects() body = compositor.refresh(self._stack, self.rows, self.cols, rects, self._shifts if rects is not None else ()) cursor = self._cursor_bytes() if self.verify: self._verify_frame() if not body and not fresh and cursor == self._last_cursor: return "" self._last_cursor = cursor return TUI_SYNC_START + "\x1b[?25l" + body + "\x1b[0m" + cursor + TUI_SYNC_END def _verify_frame(self): """Oracle check: the lazy screen must equal a from-scratch recompose by the reference algorithm. Raises TuiError naming the first cell.""" want = self.compositor.compose_reference(self.root, self.rows, self.cols) have = self.compositor.last self.verified += 1 for y, (row_want, row_have) in enumerate(zip(want, have)): if row_want != row_have: x = next(pos for pos, (a, b) in enumerate(zip(row_want, row_have)) if a != b) owner = [node for node in self._stack if node.ax <= x < node.ax + node.aw and node.ay <= y < node.ay + node.ah] raise TuiError("lazy frame diverged from reference at x=%d y=%d: want %r have %r (top panel %s)" % ( x, y, row_want[x], row_have[x], type(owner[-1]).__name__ if owner else "none")) def _cursor_bytes(self): node = self.focused if isinstance(node, TuiTerminal) and node.exit_status is None and node.screen.cursor_visible and node.is_shown(): edge = 1 if node.border else 0 pad_top, _right, _bottom, pad_left = node.padding x = node.ax + edge + pad_left + node.screen.cx y = node.ay + edge + pad_top + node.screen.cy x0, y0, x1, y1 = node.clip if x0 <= x < x1 and y0 <= y < y1: return TuiCompositor.place_cursor(x, y, True) return TuiCompositor.place_cursor(0, 0, False) def render_text(self): if self._need_layout: self.layout() return TuiCompositor.render_text(self.compositor.compose(self.root, self.rows, self.cols)) # -- events ------------------------------------------------------ # def dispatch(self, event): if isinstance(event, TuiKey): if event.key == "ctrl+c": self.quit() return if self.on_double_tap is not None and self.double_tap.hit(event.key, time.time()): if self.on_double_tap(event): return if event.key == "tab": self.focus_next() return if event.key == "backtab": self.focus_prev() return if self.focused is not None: self.focused.emit(event) else: self.root.emit(event) return if isinstance(event, TuiModeReply): if event.mode == TUI_DECLRMM and event.state in (1, 2): self.compositor.margins = True return if isinstance(event, TuiClick): self._dispatch_click(event) return if isinstance(event, TuiScroll): self._dispatch_scroll(event) return if isinstance(event, TuiPaste): if self.focused is not None: self.focused.emit(event) else: self.root.emit(event) return self.root.emit(event) def _modal_blocks(self, panel): modal = self.top_modal() if modal is None or panel is None: return modal is not None and panel is None node = panel while node is not None: if node is modal: return False node = node.parent return True def _dispatch_click(self, event): hit = self.hitmap.at(event.x, event.y) if hit is None or self._modal_blocks(hit): return if event.pressed: node = hit while node is not None and not (node.focusable and node.enabled and node.is_shown()): node = node.parent if node is not None: self.set_focus(node) hit.emit(event) def _dispatch_scroll(self, event): hit = self.hitmap.at(event.x, event.y) if hit is None or self._modal_blocks(hit): return hit.emit(event) # -- animations -------------------------------------------------- # def tick_animations(self, now): if not self.animations: return for anim in list(self.animations): panel = anim["panel"] if anim["started"] is None: anim["started"] = now anim["from"] = panel.anim_start(anim["prop"]) elapsed = now - anim["started"] - anim["delay"] if elapsed < 0: continue progress = min(1.0, elapsed / anim["duration"]) value = anim["from"] + (anim["to"] - anim["from"]) * tui_ease(anim["easing"], progress) if anim["prop"] in TUI_GEOMETRY: value = int(round(value)) try: setattr(panel, anim["prop"], value) except (AttributeError, TypeError, ValueError): pass if progress >= 1.0: self.animations.remove(anim) if callable(anim["on_done"]): try: anim["on_done"](panel) except Exception: pass def tickers(self): """Panels with an on_tick handler, found once per tree change.""" if self._tickers_version != self._tree_version: self._tickers = [node for node in self.root.walk() if callable(getattr(node, "on_tick", None))] self._tickers_version = self._tree_version return self._tickers def tick_widgets(self, dt, now): for node in self.tickers(): handler = getattr(node, "on_tick", None) if callable(handler): try: handler(TuiTick(panel=node, dt=dt, now=now)) except Exception: pass # -- terminal ---------------------------------------------------- # def _winsize(self): rows, cols = 24, 80 if fcntl is not None and termios is not None: try: packed = fcntl.ioctl(self._stdout_fd if self._stdout_fd is not None else sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8) rows, cols = struct.unpack("HH", packed[:4]) except OSError: pass return (max(TUI_MIN_ROWS, rows or 0), max(TUI_MIN_COLS, cols or 0)) def _terminals(self): """The live terminal panels, found once per tree change.""" if self._terms_version != self._tree_version: self._terms = [node for node in self.root.walk() if isinstance(node, TuiTerminal)] self._terms_version = self._tree_version return self._terms def _on_winch(self, _signum, _frame): self._resize_pending = True self._signal_wake() def _on_chld(self, signum, frame): """A child exited: wake the loop so a terminal notices its exit status even when a grandchild still holds the pty open.""" self._signal_wake() previous = self._old_chld if callable(previous): previous(signum, frame) def _signal_wake(self): loop, wake = self._loop, self._wake if loop is not None and wake is not None: try: loop.call_soon_threadsafe(wake.set) except RuntimeError: pass def _setup(self): if select is None or termios is None or tty is None: raise TuiError("tui needs select/termios/tty (POSIX terminal)") if not sys.stdin.isatty() or not sys.stdout.isatty(): raise TuiError("tui needs an interactive terminal") self.rows, self.cols = self._winsize() self._stdout_fd = sys.stdout.fileno() fd = sys.stdin.fileno() self._saved_termios = termios.tcgetattr(fd) tty.setraw(fd) if hasattr(signal, "SIGWINCH"): try: self._old_winch = signal.signal(signal.SIGWINCH, self._on_winch) except (ValueError, OSError): self._old_winch = None if hasattr(signal, "SIGCHLD"): try: self._old_chld = signal.signal(signal.SIGCHLD, self._on_chld) self._chld_set = True except (ValueError, OSError): self._old_chld = None out = sys.stdout.fileno() os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l" + "\x1b[?%d$p" % TUI_DECLRMM).encode("utf-8")) def _teardown(self): try: out = self._stdout_fd if self._stdout_fd is not None else sys.stdout.fileno() os.write(out, (TUI_PASTE_DISABLE + TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8")) except OSError: pass if self._old_winch is not None: try: signal.signal(signal.SIGWINCH, self._old_winch) except (ValueError, OSError): pass if self._chld_set: self._chld_set = False try: signal.signal(signal.SIGCHLD, self._old_chld if self._old_chld is not None else signal.SIG_DFL) except (ValueError, OSError): pass if self._saved_termios is not None and termios is not None: try: termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._saved_termios) except (termios.error, OSError): pass for term in self._terminals(): try: term.terminate() except Exception: pass def quit(self): self._running = False self.wake() @staticmethod def write_all(fd, data): view = memoryview(data) while view: written = os.write(fd, view) view = view[written:] def run(self): self._setup() try: asyncio.run(self._main()) finally: self._teardown() if self._failure is not None: failure, self._failure = self._failure, None raise failure return 0 def offload(self, work, done): """Run work() on its own thread and call done(result, error) back on the loop thread. Without a running loop (headless tests) both run inline, so callers never branch.""" loop = self._loop if loop is None: try: result, error = work(), None except Exception as exc: result, error = None, exc done(result, error) return def finish(result, error): try: done(result, error) finally: self.dirty_paint() def body(): try: result, error = work(), None except BaseException as exc: result, error = None, exc try: loop.call_soon_threadsafe(finish, result, error) except RuntimeError: pass worker = threading.Thread(target=body, name="tui-offload", daemon=True) self._workers = [item for item in self._workers if item.is_alive()] + [worker] worker.start() def _on_stdin(self, fd): try: data = os.read(fd, 65536) except BlockingIOError: return except OSError: data = b"" if not data: self._loop.remove_reader(fd) self._running = False self.wake() return self._input_seen = True for event in self.input.feed(data): self.dispatch(event) if time.time() - self._last_frame >= TUI_INPUT_PAINT_GAP: self._paint_frame() if self.input.buf or self._need_paint or self._need_layout: self.wake() def _paint_frame(self): """Write one frame if anything is dirty. False when stdout is gone.""" if not (self._need_paint or self._need_layout): return True try: data = self.frame_bytes().encode("utf-8") if data: TuiApp.write_all(self._stdout_fd, data) except OSError: self._running = False return False self._last_frame = time.time() return True def _on_term_readable(self, fd, term): if not term.feed_output(): self._eof.add(fd) if self._readers.pop(fd, None) is not None: self._loop.remove_reader(fd) self.wake() def _on_term_writable(self, term): term.drain_pending() self.wake() def _sync_terminals(self): """Make loop readers/writers match the live terminals: add fds of new ptys, drop closed ones, hold a writer only while input is queued.""" loop = self._loop want_r = {} want_w = {} for term in self._terminals(): term.poll_exit() for fd in term.fds(): if fd not in self._eof: want_r[fd] = term if term.fd is not None and term._pending and term.exit_status is None: want_w[term.fd] = term self._eof &= {fd for term in self._terminals() for fd in term.fds()} for fd in [fd for fd in self._readers if fd not in want_r]: loop.remove_reader(fd) del self._readers[fd] for fd, term in want_r.items(): if fd not in self._readers: loop.add_reader(fd, self._on_term_readable, fd, term) self._readers[fd] = term for fd in [fd for fd in self._writers if fd not in want_w]: loop.remove_writer(fd) del self._writers[fd] for fd, term in want_w.items(): if fd not in self._writers: loop.add_writer(fd, self._on_term_writable, term) self._writers[fd] = term def _loop_failed(self, _loop, context): """An exception in a loop callback must not print over the screen: keep it, stop the loop, and re-raise it after the terminal restores.""" self._failure = context.get("exception") or RuntimeError(context.get("message", "tui loop error")) self._running = False self.wake() async def _main(self): """The event loop. It sleeps until something is due: the next timer, the next animation or ticker frame, a pending partial key sequence, or a wake() (a touch from any thread, a pty, a signal). A quiet screen costs no wakeups at all, and paints are spaced at least one frame apart.""" loop = asyncio.get_running_loop() self._loop = loop self._wake = asyncio.Event() self._loop_thread = threading.get_ident() loop.set_exception_handler(self._loop_failed) stdin_fd = self._stdin_fd if self._stdin_fd is not None else sys.stdin.fileno() self.dirty_layout() self._running = True loop.add_reader(stdin_fd, self._on_stdin, stdin_fd) frame = 1.0 / self.fps last = time.time() try: while self._running: self._wake.clear() self.iterations += 1 started = time.time() self._fire_timers(started) self._sync_terminals() if self._resize_pending: self._resize_pending = False self.rows, self.cols = self._winsize() self.compositor.invalidate() self.dirty_layout() self.root.emit(TuiResize(panel=self.root, rows=self.rows, cols=self.cols)) if not self._input_seen and self.input.buf: for event in self.input.flush(): self.dispatch(event) self._input_seen = False tick_now = time.time() self.tick_animations(tick_now) if self.tickers(): self.tick_widgets(tick_now - last, tick_now) last = tick_now self.ensure_focus() wait = self._last_frame + frame - time.time() if (self._need_paint or self._need_layout) and wait > 0: await asyncio.sleep(wait) if not self._paint_frame(): break await self._sleep(self._idle_delay(frame, started)) finally: for fd in list(self._readers): loop.remove_reader(fd) for fd in list(self._writers): loop.remove_writer(fd) self._readers.clear() self._writers.clear() try: loop.remove_reader(stdin_fd) except Exception: pass self._loop = None self._wake = None def _idle_delay(self, frame, started): """Seconds the loop may sleep: zero when a frame is already due, one frame while something animates or ticks or a key sequence is half read, else until the next timer, or None (until a wake) with no timer.""" now = time.time() if self._need_paint or self._need_layout or self._resize_pending: return max(0.0, self._last_frame + frame - now) if self.animations or self.tickers() or self.input.buf: return max(0.0, frame - (now - started)) if not self.timers: return None return max(0.0, min(deadline for deadline, _i, _c, _s in self.timers) - now) async def _sleep(self, delay): if delay is not None and delay <= 0: await asyncio.sleep(0) return try: await asyncio.wait_for(self._wake.wait(), delay) except asyncio.TimeoutError: pass def tui_build_demo(app, live=True): """The /demo scene: every widget, overlapping layers, timers, modals. live=False builds the identical tree with canned terminal bytes for headless --demo-shot instead of spawning real ptys. Returns refs.""" refs = {"live": live, "banner_on": True, "term_count": 0, "log_count": 0} root = TuiPanel(flow=None, style=TuiStyle(bg="black"), id="root") header = TuiText(" tai TUI demo -- layers, mouse, focus, animation", dock="top", h=1, style=TuiStyle(bg="blue", fg="white", bold=True), id="header") footer = TuiText(" tab/shift+tab focus - click options - q quit - n terminal - c close - F1 ask - 2xshift prompt - ctrl+c quit", dock="bottom", h=1, style=TuiStyle(dim=True), id="footer") root.add(header, footer) sidebar = TuiPanel(dock="left", w=28, border=True, title="Actions", id="sidebar", gap=1, padding=1) actions = TuiOptions(["Open question dialog", "Spawn floating terminal", "Toggle banner", "Close top overlay", "Quit demo"], id="actions") sidebar.add(TuiText("Pick an action,", style=TuiStyle(dim=True)), actions) root.add(sidebar) stage = TuiPanel(x=0, y=0, w="100%", h="100%", flow=None, id="stage") root.add(stage) shell = TuiTerminal(title="shell (interactive, click to type)", x=0, y=0, w="100%", h="100%", id="shell") stage.add(shell) refs["shell"] = shell stream = TuiTerminal(cmd=["sh", "-c", "i=0; while true; do i=$((i+1)); echo \"stream tick $i\"; sleep 1; done"], title="stream (read-only)", x="52%", y="6%", w="46%", h="42%", z=5, interactive=False, id="stream") stream.closable = True stage.add(stream) refs["stream"] = stream banner = TuiText(" ** overlapping layers - dialogs - terminals - options - animation ** ", x=10, y=1, z=6, style=TuiStyle(bg="magenta", fg="white", bold=True), id="banner") stage.add(banner) refs["banner"] = banner prog_box = TuiPanel(title="Progress", x=2, y="54%", w=36, h=8, z=5, border=True, id="progress", gap=0, padding=(0, 1)) prog_a = TuiProgress(0.35, id="prog_a") prog_b = TuiProgress(0.7, id="prog_b") prog_box.add(TuiText("download", style=TuiStyle(dim=True)), prog_a, TuiText("upload", style=TuiStyle(dim=True)), prog_b) prog_box.closable = True stage.add(prog_box) refs["prog_a"] = prog_a refs["prog_b"] = prog_b log = TuiLog(title="Events (wheel to scroll)", x=40, y="54%", w="42%", h=7, z=5, border=True, id="log") log.closable = True log.append("demo started", TuiStyle(fg="green")) log.append("click the sidebar, or press F1", TuiStyle(dim=True)) log.append("tab cycles focus, shift+tab goes back", TuiStyle(dim=True)) stage.add(log) refs["log"] = log def toast(text): note = TuiText(" " + text + " ", layer="toast", x=-2, y=1, style=TuiStyle(bg="green", fg="black", bold=True)) note.closable = True root.add(note) app.after(3.0, lambda: root.remove(note) if note.parent is not None else None) log.append(text) def ask_question(): def answered(value): toast("you picked: %s" % value) root.add(TuiQuestion("Pick a theme for the demo:", ["Ocean", "Forest", "Sunset", "Mono"], on_done=answered, title="Question", w=44, id="welcome" if refs.get("first_ask", True) else None)) refs["first_ask"] = False def open_prompt(): if root.find("prompt") is not None: return True def execute(text): shell.send_text(text + "\n") first = text.split("\n")[0][:40] or "(empty)" toast("executing: %s" % first) log.append("prompt: %s" % text.split("\n")[0][:60]) root.add(TuiPromptDialog(on_execute=execute, id="prompt", complete=tui_complete_command, mentions=tui_complete_mention, label="Enter prompt (/ commands, @ mentions):")) return True def on_double_tap(event): if event.key == "shift": return open_prompt() return False app.on_double_tap = on_double_tap def spawn_terminal(): refs["term_count"] += 1 nth = refs["term_count"] term = TuiTerminal(title="shell #%d" % (nth + 1), x=6 + nth * 4, y=3 + nth * 2, w=54, h=14, z=5, id="term%d" % nth) term.closable = True stage.add(term) if live: term.spawn() else: term.screen.feed(b"$ \r\n") term.focus() log.append("spawned terminal #%d" % (nth + 1)) return term def close_top(): best = None for node in root.walk(): if getattr(node, "closable", False) and node.is_shown() and node.enabled: key = (node.effective_z(), node.seq) if best is None or key > (best.effective_z(), best.seq): best = node if best is not None: log.append("closed %s" % (best.title or best.id or "panel")) app.close_panel(best) def on_action(event): if event.index == 0: ask_question() elif event.index == 1: spawn_terminal() elif event.index == 2: refs["banner_on"] = not refs["banner_on"] banner.visible = refs["banner_on"] elif event.index == 3: close_top() elif event.index == 4: app.quit() actions.on_select = on_action def on_root_key(event): if event.key == "q": event.stop() app.quit() elif event.key == "n": event.stop() spawn_terminal() elif event.key == "c": event.stop() close_top() elif event.key == "f1": event.stop() ask_question() root.on_key = on_root_key refs["ask"] = ask_question refs["spawn"] = spawn_terminal refs["close_top"] = close_top if live: def tick_clock(): header.text = " tai TUI demo -- %s" % now_iso() def tick_progress(): prog_a.set((prog_a.value + 0.02) % 1.0) prog_b.set((prog_b.value + 0.013) % 1.0) def tick_banner(): if not refs["banner_on"]: return room = max(1, stage.aw - 68) banner.x = 1 + int(time.time() * 14) % room def heartbeat(): refs["log_count"] += 1 log.append("heartbeat %d" % refs["log_count"], TuiStyle(dim=True)) app.every(1.0, tick_clock) app.every(0.12, tick_progress) app.every(0.06, tick_banner) app.every(5.0, heartbeat) ask_question() return root, refs def tui_demo(): app = TuiApp() try: root, _refs = tui_build_demo(app, live=True) app.mount(root) for term in app._terminals(): term.spawn() return app.run() except TuiError as exc: print("demo unavailable: %s" % exc, file=sys.stderr) return 2 def tui_demo_shot(rows=30, cols=100): app = TuiApp() app.rows = rows app.cols = cols root, refs = tui_build_demo(app, live=False) app.mount(root) app.close_panel(root.find("welcome")) refs["shell"].screen.feed(b"$ echo hello\r\nhello\r\n$ ") refs["stream"].screen.feed(b"stream tick 1\r\nstream tick 2\r\nstream tick 3\r\n") text = app.render_text() print(text) return 0 TUI_CHAT_SIDEBAR_MIN = 24 TUI_CHAT_SIDEBAR_MAX = 36 TUI_CHAT_NARROW_COLS = 80 TUI_CHAT_INPUT_CAP = 2000 TUI_CHAT_HISTORY = 40 TUI_CHAT_LINE_CAP = 500 TUI_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def tui_strip_ansi(text): return TUI_ANSI_RE.sub("", text or "") def tui_chat_sidebar_w(cols): return max(TUI_CHAT_SIDEBAR_MIN, min(TUI_CHAT_SIDEBAR_MAX, int(cols * 0.3))) TUI_CHAT_BUBBLE_MAX = 0.7 TUI_CHAT_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" TUI_CHAT_ONLINE_GLYPH = "●" TUI_CHAT_TYPE_ANSWER = object() TUI_CHAT_PROMPT_TIMEOUT = 600 TUI_CHAT_PROMPT_CHARS = 600 TUI_CHAT_REVEAL_TICK = 0.04 TUI_CHAT_REVEAL_STEPS = 40 TUI_CHAT_TICK_SEND = "◷" TUI_CHAT_TICK_SENT = "✓" TUI_CHAT_TICK_READ = "✓✓" TUI_MD_INLINE_RE = re.compile(r"(\*\*[^*\n]+\*\*|__[^_\n]+__|`[^`\n]+`|(? pos: segs.append((text[pos:match.start()], "")) token = match.group(0) if token.startswith(("**", "__")): segs.append((token[2:-2], "b")) elif token.startswith("`"): segs.append((token[1:-1], "c")) else: segs.append((token[1:-1], "i")) pos = match.end() if pos < len(text): segs.append((text[pos:], "")) return segs def tui_md_blocks(text): """Line-level markdown: returns (kind, segs, lead) rows. kinds: p, h, li (lead is the marker), quote, code, rule, blank. Fences, headings, bullets, numbered lists, quotes and rules; everything else is text.""" out = [] fence = False for raw in str(text or "").replace("\t", " ").split("\n"): line = raw.rstrip() if line.strip().startswith("```"): fence = not fence continue if fence: out.append(("code", [(line, "code")], "")) continue if not line.strip(): if out and out[-1][0] != "blank": out.append(("blank", [], "")) continue head = re.match(r"^#{1,6}\s+(.*)$", line) item = TUI_MD_LIST_RE.match(line) quote = re.match(r"^>\s?(.*)$", line) if head: out.append(("h", [(seg, "h") for seg, _tag in tui_md_inline(head.group(1))], "")) elif re.match(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$", line): out.append(("rule", [], "")) elif item: marker = "•" if item.group(2) in "-*+" else item.group(2) out.append(("li", tui_md_inline(item.group(3)), " " * (len(item.group(1)) // 2) + marker)) elif quote: out.append(("quote", tui_md_inline(quote.group(1)), "")) else: out.append(("p", tui_md_inline(line), "")) while out and out[-1][0] == "blank": out.pop() return out def tui_wrap_segs(segs, width): """Greedy word wrap over (text, tag) segments; words longer than the width are chopped. Returns a list of segment lists, never empty.""" width = max(1, width) lines = [] cur = [] used = 0 def flush(): nonlocal cur, used while cur and cur[-1][0].isspace(): cur.pop() lines.append(cur) cur, used = [], 0 for text, tag in segs: for token in re.findall(r"\s+|\S+", text): if token.isspace(): if used and used + len(token) <= width: cur.append((token, tag)) used += len(token) continue while len(token) > width: if used: flush() cur.append((token[:width], tag)) used = width flush() token = token[width:] if used + len(token) > width: flush() cur.append((token, tag)) used += len(token) flush() return lines def tui_chat_styles(side): """Style table for one bubble kind. side: out, in, note, error.""" if side == "out": bg, fg, meta = "role:bubble_out", "role:bubble_fg", "role:meta_out" elif side == "error": bg, fg, meta = "role:tool_bg", "role:error", "role:meta_in" elif side == "note": bg, fg, meta = "role:tool_bg", "role:muted", "role:meta_in" else: bg, fg, meta = "role:bubble_in", "role:bubble_fg", "role:meta_in" base = TuiStyle(bg="role:bg", fg="role:fg") return { "base": base, "bubble": TuiStyle(bg=bg, fg=fg), "meta": TuiStyle(bg=bg, fg=meta), "tick": TuiStyle(bg=bg, fg="role:tick_read" if side == "out" else meta, bold=True), "name": TuiStyle(bg=bg, fg="role:accent2" if side != "out" else "role:meta_out", bold=True), "b": TuiStyle(bg=bg, fg=fg, bold=True), "i": TuiStyle(bg=bg, fg="role:info"), "c": TuiStyle(bg="role:code_bg", fg="role:accent3"), "code": TuiStyle(bg="role:code_bg", fg="role:accent2"), "h": TuiStyle(bg=bg, fg="role:accent3" if side == "out" else "role:accent", bold=True), "bullet": TuiStyle(bg=bg, fg="role:accent2" if side != "out" else "role:meta_out", bold=True), "quote": TuiStyle(bg=bg, fg="role:muted"), "bar": TuiStyle(bg=bg, fg=("role:error" if side == "error" else "role:accent")), } TUI_CHAT_PAD = 2 def tui_chat_body_rows(text, inner, styles, markdown=True): """Bubble body as (segs, fill_tag) rows wrapped to inner columns. fill_tag code pads the row with the code-block background.""" rows = [] if not markdown: for para in str(text or "").split("\n"): for part in textwrap.wrap(para, inner, expand_tabs=True, replace_whitespace=False, drop_whitespace=False, break_long_words=True, break_on_hyphens=False) or [""]: rows.append(([(part.rstrip(), "")], "")) return rows for kind, segs, lead in tui_md_blocks(text): if kind == "blank": rows.append(([], "")) elif kind == "rule": rows.append(([("─" * inner, "quote")], "")) elif kind == "code": line = segs[0][0] for start in range(0, max(1, len(line)), inner): rows.append(([(" " + line[start:start + inner - 1], "code")], "code")) elif kind == "li": indent = len(lead) + 1 wrapped = tui_wrap_segs(segs, max(4, inner - indent)) for pos, parts in enumerate(wrapped): head = [(lead[:-1], ""), (lead[-1:], "bullet"), (" ", "")] if pos == 0 else [(" " * indent, "")] rows.append((head + parts, "")) elif kind == "quote": for parts in tui_wrap_segs([(seg, "quote") for seg, _tag in segs], max(4, inner - 2)): rows.append(([("▎ ", "bar")] + parts, "")) else: for parts in tui_wrap_segs(segs, inner): rows.append((parts, "")) return rows def tui_chat_bubble_rows(text, width, side, styles, stamp="", ticks="", ticks_read=False, sender="", markdown=True, boxed=True): """One aligned bubble as span-rows. side out (right) or in (left); boxed bubbles carry a sender row on top and the stamp+ticks row at the bottom, both inside the fill. Markdown renders to styled runs. Below usable width bubbles degrade to full-width left rows. styles needs the base, bubble, meta, tick entries; the rest fall back to bubble.""" width = max(10, int(width)) base = styles["base"] bubble = styles["bubble"] def style_of(tag): return styles.get(tag) or bubble meta = " ".join(part for part in (stamp, ticks) if part).strip() if width < 34: rows = [] if sender: rows.append([TuiSpan(sender[:width].ljust(width), style_of("name"))]) for segs, _fill in tui_chat_body_rows(text, width, styles, markdown): rows.append(tui_pad_spans([TuiSpan(seg, style_of(tag)) for seg, tag in segs], width, base)) if meta: rows.append([TuiSpan(meta.rjust(width), styles["meta"])]) return rows cap = max(14, int(width * TUI_CHAT_BUBBLE_MAX)) inner_cap = cap - 2 * TUI_CHAT_PAD body = tui_chat_body_rows(text, inner_cap, styles, markdown) need = max([sum(len(seg) for seg, _tag in segs) for segs, _fill in body] + [len(sender) + 1, len(meta), 1]) bubble_w = min(cap, need + 2 * TUI_CHAT_PAD) inner = bubble_w - 2 * TUI_CHAT_PAD pad = TuiSpan(" " * TUI_CHAT_PAD, bubble) rows = [] if boxed: rows.append([TuiSpan(" " * bubble_w, bubble)] if not sender else [pad, TuiSpan(sender[:inner].ljust(inner), style_of("name")), pad]) for segs, fill in body: spans = [TuiSpan(seg, style_of(tag)) for seg, tag in segs] used = sum(len(seg) for seg, _tag in segs) fill_style = style_of(fill) if fill else bubble rows.append([pad] + spans + [TuiSpan(" " * (inner - used), fill_style), pad]) if boxed: if meta: time_span = TuiSpan(stamp, styles["meta"]) if stamp else None gap = " " if stamp and ticks else "" tick_span = TuiSpan(ticks, styles["tick"] if ticks_read else styles["meta"]) if ticks else None tail = [span for span in (time_span, TuiSpan(gap, styles["meta"]) if gap else None, tick_span) if span is not None] rows.append([pad, TuiSpan(" " * (inner - len(meta)), bubble)] + tail + [pad]) else: rows.append([TuiSpan(" " * bubble_w, bubble)]) aligned = [] for spans in rows: if side == "out": aligned.append([TuiSpan(" " * max(0, width - bubble_w), base)] + spans) else: aligned.append(spans + [TuiSpan(" " * max(0, width - bubble_w), base)]) return aligned def tui_chat_tool_summary(args): """Compact `k=v k=v` view of a JSON tool-call argument string.""" try: data = json.loads(args) except (TypeError, ValueError): return str(args or "").strip() if not isinstance(data, dict): return str(data) return " ".join("%s=%s" % (key, value if isinstance(value, (int, float)) else json.dumps(value, ensure_ascii=False).strip('"')) for key, value in data.items()) def tui_chat_tool_row(name, args, width, base): """One-line chip for a tool call: gear, bold name, muted arguments.""" width = max(10, int(width)) chip = TuiStyle(bg="role:accent2", fg="role:on_accent", bold=True) label = TuiStyle(bg="role:tool_bg", fg="role:fg", bold=True) detail = TuiStyle(bg="role:tool_bg", fg="role:muted") room = max(0, width - len(name) - 8) text = tui_chat_tool_summary(args).replace("\n", " ") if len(text) > room: text = text[:max(0, room - 1)] + "…" spans = [TuiSpan(" ", base), TuiSpan(" ⚙ ", chip), TuiSpan(" %s " % name, label)] if text: spans.append(TuiSpan("%s " % text, detail)) return tui_pad_spans(spans, width, base) def tui_chat_live_rows(command, lines, status, width, base): """A running command as a message: header chip with the command, the last TUI_CHAT_LIVE_ROWS output lines in a fixed window, and a status row once it ended. Wide lines are cut, narrow screens just get less text.""" width = max(10, int(width)) chip = TuiStyle(bg="role:accent2", fg="role:on_accent", bold=True) head = TuiStyle(bg="role:tool_bg", fg="role:fg", bold=True) body = TuiStyle(bg="role:tool_bg", fg="role:fg") dim = TuiStyle(bg="role:tool_bg", fg="role:muted") bar = TuiStyle(bg="role:tool_bg", fg="role:accent2") title = command.replace("\n", " ") room = max(0, width - 9) if len(title) > room: title = title[:max(0, room - 1)] + "…" rows = [tui_pad_spans([TuiSpan(" ", base), TuiSpan(" ⌁ ", chip), TuiSpan(" %s " % title, head)], width, base)] shown = list(lines)[-TUI_CHAT_LIVE_ROWS:] if not shown: shown = ["(waiting for output)"] if not status else ["(no output)"] for text in shown: text = text.expandtabs(4)[:max(0, width - 4)] rows.append(tui_pad_spans([TuiSpan(" ", base), TuiSpan("│ ", bar), TuiSpan(text, body if lines else dim)], width, base)) if status: rows.append(tui_pad_spans([TuiSpan(" ", base), TuiSpan("└ %s" % status[:max(0, width - 4)], dim)], width, base)) return rows def tui_chat_note_rows(text, width, side, base): """Command output and errors: accent bar plus plain wrapped text on the tool background, no markdown so aligned columns survive.""" styles = tui_chat_styles(side) cap = max(12, int(width * 0.9)) lines = tui_chat_body_rows(text, cap - 4, styles, markdown=False) longest = max([len(segs[0][0]) if segs else 0 for segs, _fill in lines] + [1]) block_w = min(cap, longest + 4) rows = [] for segs, _fill in lines: raw = segs[0][0] if segs else "" rows.append(tui_pad_spans([TuiSpan("▎", styles["bar"]), TuiSpan(" " + raw.ljust(block_w - 3) + " ", styles["bubble"])], width, base)) return rows def tui_chat_pill_row(text, width, base_style, pill_style): label = (" %s " % (text or "").strip())[:max(0, width)] left = max(0, (width - len(label)) // 2) right = max(0, width - left - len(label)) return [TuiSpan(" " * left, base_style), TuiSpan(label, pill_style), TuiSpan(" " * right, base_style)] def tui_chat_welcome_rows(width, name): """Empty-state card shown before the first message.""" width = max(10, int(width)) base = TuiStyle(bg="role:bg", fg="role:fg") logo = TuiStyle(bg="role:bg", fg="role:accent", bold=True) sub = TuiStyle(bg="role:bg", fg="role:muted") key = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) lines = [ [TuiSpan("✦ tai", logo)], [TuiSpan("chatting with %s" % name, sub)], [], [TuiSpan(" Enter ", key), TuiSpan(" send ", sub), TuiSpan(" /help ", key), TuiSpan(" commands ", sub), TuiSpan(" @ ", key), TuiSpan(" mention a bot", sub)], [TuiSpan(" F2 ", key), TuiSpan(" bots ", sub), TuiSpan(" F4 ", key), TuiSpan(" theme", sub)], ] rows = [[TuiSpan(" " * width, base)]] for spans in lines: used = sum(len(span.text) for span in spans) left = max(0, (width - used) // 2) rows.append(tui_pad_spans([TuiSpan(" " * left, base)] + spans, width, base)) return rows class TuiChatLog(TuiLog): """Transcript view with a slim scrollbar in the last column, drawn only when there is something to scroll.""" def bar_geometry(self): """(thumb top row, thumb height) of the scrollbar, or None when there is nothing to scroll.""" width = self._content_w() height = self._content_h() total = len(self.lines) if total <= height or height < 3 or width < 2: return None thumb = max(1, height * height // total) span = max(1, total - height) return int((height - thumb) * min(1.0, self.scroll_y / float(span))), thumb def paint_cells(self, compositor): """The cell-space paint(): the log's reused rows with the scrollbar cell written over each content row's last column.""" kind = type(self) edge = 1 if self.border else 0 if (edge or kind.paint is not TuiChatLog.paint or kind.content_rows is not TuiLog.content_rows or self.aw < self.chrome_w() + 2): return TuiPanel.paint_cells(self, compositor) rows = self._log_cells(compositor) geometry = self.bar_geometry() if geometry is None: return rows top, thumb = geometry height = self._content_h() pad_top, pad_right = self.padding[0], self.padding[1] blank = compositor.cell(" ", self.style.key()) track = compositor.cell("│", TuiStyle(bg=self.style.bg, fg="role:border").key()) grip = compositor.cell("┃", TuiStyle(bg=self.style.bg, fg="role:accent", bold=True).key()) at = self.aw - pad_right - 1 tail = [blank] * pad_right for local in range(height): row = rows[pad_top + local] rows[pad_top + local] = row[:at] + [grip if top <= local < top + thumb else track] + tail return rows def paint(self): rows = super().paint() geometry = self.bar_geometry() if geometry is None: return rows top, thumb = geometry height = self._content_h() edge = 1 if self.border else 0 track = TuiStyle(bg=self.style.bg, fg="role:border") grip = TuiStyle(bg=self.style.bg, fg="role:accent", bold=True) out = [] for pos, spans in enumerate(rows): local = pos - edge - self.padding[0] if 0 <= local < height: on = top <= local < top + thumb spans = tui_slice_spans(spans, 0, self.aw - edge - self.padding[1] - 1, self.style) + [ TuiSpan("┃" if on else "│", grip if on else track)] spans = tui_pad_spans(spans, self.aw - edge, self.style) out.append(spans) return out class TuiChatSession: """One bot's conversation inside the chat scene: its own agent, its own journal of rendered blocks, its own turn state. Bots run independently, so a long task in one never blocks talking to another.""" def __init__(self, name, agent): self.name = name self.agent = agent self.blocks = [] self.pending = None self.busy = False self.phase = "idle" self.detail = "" self.since = 0.0 self.reveal = None self.pending_tick = None self.note = "" self.unread = 0 self.queued = None class TuiChatState(dict): """The chat scene's refs. Turn and journal keys read and write the active session, everything else is a plain dict entry, so scene code says refs["busy"] and means the bot on screen.""" PROXIED = ("blocks", "pending", "busy", "phase", "detail", "since", "reveal", "pending_tick", "note") def __init__(self, session): super().__init__() self.session = session def __getitem__(self, key): if key in TuiChatState.PROXIED: return getattr(self.session, key) return super().__getitem__(key) def __setitem__(self, key, value): if key in TuiChatState.PROXIED: setattr(self.session, key, value) else: super().__setitem__(key, value) def get(self, key, default=None): if key in TuiChatState.PROXIED: return getattr(self.session, key) return super().get(key, default) class TuiLiveStream: """Stream sink (feed/pause/close, like LiveStream) for a command's output in the chat transcript. The journal block ["live", command, lines, status] is the message; feeding mutates it from the turn thread and asks the scene to repaint through `push`, which coalesces.""" def __init__(self, block, push): self.block = block self.push = push def feed(self, line): lines = self.block[2] lines.append(tui_strip_ansi(line.rstrip("\n").split("\r")[-1])) del lines[:-TUI_CHAT_LIVE_KEEP] self.push() def pause(self): pass def close(self, status): self.block[3] = status or "done" self.push() class TuiStdioGuard: """Counted stdout/stdin swap for turn threads. Several bots can run at once; a plain redirect_stdout per thread would restore in the wrong order and leave sys.stdout pointing at a dead buffer. The first thread in swaps, the last one out restores.""" def __init__(self): self.lock = threading.Lock() self.depth = 0 self.saved = None def __enter__(self): with self.lock: if self.depth == 0: self.saved = (sys.stdout, sys.stdin) sys.stdout = io.StringIO() sys.stdin = io.StringIO("") self.depth += 1 return self def __exit__(self, *_exc): with self.lock: self.depth -= 1 if self.depth == 0 and self.saved is not None: sys.stdout, sys.stdin = self.saved self.saved = None return False TUI_STDIO_GUARD = TuiStdioGuard() TUI_CHAT_BLOCK_CAP = 300 TUI_CHAT_LIVE_ROWS = 8 TUI_CHAT_LIVE_KEEP = 200 TUI_CHAT_LIVE_TICK = 0.05 def tui_spawn_agent(base, name): """A fresh Agent for another bot, sharing the vault with base. Starts with empty messages so switch_bot never saves a stale copy of base's conversation over the real one.""" child = Agent(base.config, base.store, yolo=base.yolo, auto=base.auto, ctx=getattr(base, "ctx_id", "")) child.messages = [] child.switch_profile(base.profile, silent=True) child.messages = [] child.switch_bot(name, silent=True) child.quiet = True child.env = base.env child.chat.prefer = base.chat.prefer child.chat.provider_pin = base.chat.provider_pin child.chat.model_pin = base.chat.model_pin return child def tui_build_chat(app, agent, spawn=None): """Chat scene: bots sidebar, header, markdown transcript, rounded composer, status bar. Turns run inline via a deferred timer (see run_pending): the worker thread the research sketch assumed would share one sqlite connection across threads, which sqlite forbids, so the echo paints first and the turn blocks the loop like the telegram bot does.""" spawn = spawn or tui_spawn_agent sessions = {agent.bot: TuiChatSession(agent.bot, agent)} refs = TuiChatState(sessions[agent.bot]) refs.update({"drafts": {}, "sidebar_auto": False, "sidebar_hide": False, "last_key": None, "bots": [], "render_w": 0, "spin": 0, "sessions": sessions}) panel_style = TuiStyle(bg="role:bg", fg="role:fg") dim_style = TuiStyle(bg="role:bg", fg="role:muted") err_style = TuiStyle(bg="role:bg", fg="role:error") bar_style = TuiStyle(bg="role:panel", fg="role:fg") name_style = TuiStyle(bg="role:panel", fg="role:fg", bold=True) side_style = TuiStyle(bg="role:panel", fg="role:fg") pill_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) bubble_styles = {side: tui_chat_styles(side) for side in ("in", "out", "note", "error")} root = TuiPanel(flow="rows", style=TuiStyle(bg="role:bg"), id="chat") body = TuiCols(h="1fr", style=TuiStyle(bg="role:bg"), id="body") sidebar_wrap = TuiRows(w=tui_chat_sidebar_w(app.cols), h="100%", style=side_style, id="sidebar") brand = TuiRich(h=3, style=side_style, id="brand") sidebar = TuiChatList([], h="1fr", style=side_style, id="bots") sidebar_wrap.add(brand, sidebar) right = TuiRows(w="1fr", h="100%", style=TuiStyle(bg="role:bg"), id="right") header = TuiCols(h=3, style=bar_style, padding=(0, 1), id="header") avatar = TuiRich(w=7, h=3, style=bar_style, id="avatar") info = TuiRows(w="1fr", h=3, valign="middle", style=bar_style, padding=(0, 1), id="info") head_name = TuiText("", h=1, wrap=False, style=name_style, id="head_name") status_row = TuiCols(h=1, style=bar_style, id="status_row") head_dot = TuiText("● ", w=2, h=1, wrap=False, style=TuiStyle(bg="role:panel", fg="role:success"), id="head_dot") head_status = TuiRich(w="1fr", h=1, style=TuiStyle(bg="role:panel", fg="role:muted"), id="head_status") status_row.add(head_dot, head_status) info.add(head_name, status_row) head_meta = TuiRich(w=24, h=3, style=bar_style, id="head_meta") header.add(avatar, info, head_meta) transcript = TuiChatLog(focusable=True, h="1fr", style=panel_style, id="transcript") composer = TuiCols(h=3, gap=1, padding=(0, 1), style=TuiStyle(bg="role:bg"), id="composer") box = TuiTextBox(w="1fr", h=1, border=True, rounded=True, padding=(0, 1), id="input", style=TuiStyle(bg="role:surface", fg="role:fg"), border_style=TuiStyle(bg="role:bg", fg="role:border"), focus_border_style=TuiStyle(bg="role:bg", fg="role:accent"), complete=tui_complete_command, mentions=lambda prefix: tui_complete_mention(prefix, bots=tuple(refs["bots"])), send_on_enter=True, on_send=lambda value: do_send(value), max_chars=TUI_CHAT_INPUT_CAP, placeholder="Write a message...", history=TuiHistory(VaultHistory.for_agent(agent))) send_btn = TuiButton("Send ➤", brackets=False, w=10, h=1, border=True, rounded=True, border_style=TuiStyle(bg="role:bg", fg="role:accent"), focus_border_style=TuiStyle(bg="role:bg", fg="role:accent2"), style=TuiStyle(bg="role:accent", fg="role:on_accent", bold=True), on_press=lambda event: do_send(box.value), id="send") composer.add(box, send_btn) right.add(header, transcript, composer) body.add(sidebar_wrap, right) status = TuiRich(h=1, style=bar_style, id="status") root.add(body, status) refs.update({"root": root, "sidebar_wrap": sidebar_wrap, "sidebar": sidebar, "transcript": transcript, "box": box, "status": status, "composer": composer, "head_name": head_name, "head_status": head_status, "head_dot": head_dot, "avatar": avatar, "head_meta": head_meta, "send_btn": send_btn}) def render_block(block): width = max(10, transcript._content_w() - 3) base = panel_style kind = block[0] key = (kind, block[1]) if kind in ("msg", "note") else (kind,) if transcript.lines and refs["last_key"] is not None and (key != refs["last_key"] or kind == "msg"): transcript.append("") if kind == "pill": rows = [tui_chat_pill_row(block[1], width, base, pill_style)] elif kind == "welcome": rows = tui_chat_welcome_rows(width, block[1]) elif kind == "tool": rows = [tui_chat_tool_row(block[1], block[2], width, base)] elif kind == "live": rows = tui_chat_live_rows(block[1], block[2], block[3], width, base) elif kind == "note": rows = tui_chat_note_rows(block[2], width, block[1], base) else: _kind, side, text, stamp, ticks, ticks_read, sender = block rows = tui_chat_bubble_rows(text, width, side, bubble_styles[side], stamp, ticks, ticks_read, sender) margin = TuiSpan(" ", base) for spans in rows: transcript.append_spans([margin] + list(spans)) refs["last_key"] = key def replay(): if not transcript.aw: return pinned = transcript.scroll_y >= max(0, len(transcript.lines) - transcript._content_h()) transcript.lines = [] refs["last_key"] = None for block in refs["blocks"]: render_block(block) while len(transcript.lines) > TUI_CHAT_LINE_CAP and len(refs["blocks"]) > 1: refs["blocks"].pop(0) transcript.lines = [] refs["last_key"] = None for block in refs["blocks"]: render_block(block) if pinned: transcript.scroll_y = max(0, len(transcript.lines) - transcript._content_h()) else: transcript._clamp_scroll() refs["render_w"] = transcript._content_w() transcript.touch() def maybe_replay(): if transcript.aw and transcript._content_w() != refs["render_w"]: replay() def cur(): return refs.session def journal(block, session=None): """Journal one block into a session. The active session renders it when laid out (returns the block, stable across replays, so callers can mutate it and replay); a background session only keeps it.""" session = session or cur() session.blocks.append(block) if session is not cur(): del session.blocks[:-TUI_CHAT_BLOCK_CAP] return block if transcript.aw: render_block(block) if len(transcript.lines) > TUI_CHAT_LINE_CAP and len(session.blocks) > 1: session.blocks.pop(0) replay() return block def bubble(side, text, stamp="", ticks="", ticks_read=False, sender="", session=None): return journal(["msg", side, text, stamp, ticks, ticks_read, sender], session) def note(text, side="note", session=None): return journal(["note", side, text], session) def pill(text): return journal(["pill", text]) def show(sender, text, style): if sender == "you": return bubble("out", text) if style is err_style: return note(text, "error") if style is dim_style: return note(text) return bubble("in", text, sender=agent.bot) def render_history(force=True): """Rebuild the active bot's journal from its messages. force=False keeps an existing journal (switching back to a bot you already opened) and just replays it.""" session = cur() if session.blocks and not force: replay() return session.blocks = [] session.pending_tick = None transcript.lines = [] refs["last_key"] = None past = [msg for msg in agent.messages if msg.get("role") != "system"][-TUI_CHAT_HISTORY:] if not past: now = datetime.now() pill("%s %d" % (now.strftime("%B"), now.day)) journal(["welcome", agent.bot]) for msg in past: if msg.get("role") == "user": bubble("out", tui_strip_ansi(msg.get("content") or "")) elif msg.get("role") == "assistant": if msg.get("content"): bubble("in", tui_strip_ansi(msg["content"]), sender=agent.bot) for call in msg.get("tool_calls") or []: fn = (call.get("function") or {}) journal(["tool", fn.get("name") or "tool", fn.get("arguments") or ""]) def chip(text, style): return TuiSpan(" %s " % text, style) def phase_word(): """What the turn is really doing right now: thinking while the model or the loop works, running inside a tool call, typing once the reply exists and is being laid out.""" if refs["phase"] == "tool": return "running %s" % (refs["detail"] or "tool") if refs["phase"] == "typing": return "typing" if refs["phase"] == "ask": return "waiting for you" return "thinking" def status_spans(): """Header status line. Idle is a calm green dot; a live phase gets a spinner in cycling accent colors and a shimmer sweeping the word, with trailing dots that light up one after another.""" panel = "role:panel" if not (refs["busy"] or refs["pending"] is not None): return "online", TUI_CHAT_ONLINE_GLYPH, TuiStyle(bg=panel, fg="role:success"), [TuiSpan("online", TuiStyle(bg=panel, fg="role:muted"))] word = phase_word() frame = refs["spin"] tint = ("role:accent", "role:accent2", "role:accent3")[(frame // 3) % 3] glyph = TUI_CHAT_SPINNER[frame % len(TUI_CHAT_SPINNER)] if refs["phase"] != "typing" else ("✎" if frame % 4 < 2 else "✐") head = frame % (len(word) + 6) - 3 spans = [] for pos, char in enumerate(word): reach = abs(pos - head) fg = "role:fg" if reach == 0 else (tint if reach == 1 else "role:muted") spans.append(TuiSpan(char, TuiStyle(bg=panel, fg=fg, bold=reach <= 1))) lit = (frame // 4) % 4 for pos in range(3): spans.append(TuiSpan(".", TuiStyle(bg=panel, fg=tint if pos < lit else "role:border", bold=pos < lit))) return word + "...", glyph, TuiStyle(bg=panel, fg=tint, bold=True), spans def others_busy(): return sum(1 for item in sessions.values() if item is not cur() and item.busy) def paint_status(): width = max(20, app.cols) key_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) label_style = TuiStyle(bg="role:panel", fg="role:muted") gap = TuiSpan(" ", bar_style) left = [] if refs["note"]: warn = refs["busy"] or refs["pending"] is not None glyph = status_spans()[1] if warn else "●" left.append([TuiSpan(" %s " % glyph, TuiStyle(bg="role:panel", fg="role:warning" if warn else "role:success", bold=True)), TuiSpan(refs["note"], TuiStyle(bg="role:panel", fg="role:fg", bold=True))]) for keycap, label in (("F2", "Bots"), ("F4", "Theme"), ("Tab", "Focus"), ("i", "Input"), ("/help", "Commands")): left.append([chip(keycap, key_style), TuiSpan(" %s " % label, label_style)]) pins = " ".join(part for part in (agent.chat.provider_pin, agent.chat.model_pin) if part) right = [[TuiSpan(" %s/%s " % (agent.profile, agent.bot), TuiStyle(bg="role:accent", fg="role:on_accent", bold=True))], [TuiSpan(" %s " % (pins or "auto"), TuiStyle(bg="role:service_bg", fg="role:service_fg"))]] busy = running_agents() + others_busy() if busy: right.append([TuiSpan(" +%d " % busy, TuiStyle(bg="role:warning", fg="role:bg", bold=True))]) def size(groups): return sum(sum(len(span.text) for span in group) + 1 for group in groups) while len(left) > 1 and size(left) + size(right) > width: left.pop() while len(right) > 1 and size(left) + size(right) > width: right.pop() spans = [] for group in left: spans.extend(group) spans.append(gap) fill = max(0, width - size(left) - size(right)) spans.append(TuiSpan(" " * fill, bar_style)) for group in right: spans.extend(group) spans.append(gap) status.set(spans) tint = "role:" + tui_avatar_role(agent.bot) av = TuiStyle(bg=tint, fg="role:on_accent", bold=True) avatar.set([[TuiSpan(" " * 7, av)], [TuiSpan((" %s " % (agent.bot[:1].upper() or "?")).center(7), av)], [TuiSpan(" " * 7, av)]]) head_name.text = agent.bot _text, glyph, dot_style, spans = status_spans() head_status.set(spans) head_status.text = _text head_dot.text = glyph + " " head_dot.style = dot_style brand.set([[], [TuiSpan(" ✦ ", TuiStyle(bg="role:panel", fg="role:accent", bold=True)), TuiSpan("tai", TuiStyle(bg="role:panel", fg="role:fg", bold=True)), TuiSpan(" %s" % VERSION, TuiStyle(bg="role:panel", fg="role:muted"))], [TuiSpan(" Bots", TuiStyle(bg="role:panel", fg="role:accent2", bold=True))]]) meta_style = TuiStyle(bg="role:panel", fg="role:muted") label = "%s" % (pins or "auto") head_meta.set([[], [TuiSpan(("◆ " + label + " ")[-22:].rjust(24), meta_style)], []]) head_meta.visible = app.cols >= 70 def clear_box(): box.lines = [""] box.cx = box.cy = box.top = 0 box._complete_hide() box.touch() def bot_snippet(name): try: msgs = agent.messages if name == agent.bot else agent.store.load_session(agent.profile, name, ctx=getattr(agent, "ctx_id", "")) except Exception: return "" for msg in reversed(msgs or []): content = (msg.get("content") or "").strip() if content: return content.split("\n")[0][:40] return "" def bot_meta(name): """Sidebar flags from live sessions only (no vault reads), so the busy spinner and unread count can repaint at animation speed.""" item = sessions.get(name) busy = bool(item and (item.busy or item.pending is not None)) glyph = TUI_CHAT_SPINNER[refs["spin"] % len(TUI_CHAT_SPINNER)] if busy else "" if item is not None and item.queued is not None: glyph = "?" return {"busy": glyph, "unread": item.unread if item else 0} def refresh_meta(): for name, meta in sidebar.row_meta.items(): meta.update(bot_meta(name)) sidebar.touch() def refresh_bots(): names = [item["name"] for item in agent.store.list_bots(agent.profile)] for name in sessions: if name not in names: names.append(name) if agent.bot not in names: names = [agent.bot] + names refs["bots"] = names sidebar.options = [TuiOptions.norm_option(name) for name in names] sidebar.selected = names.index(agent.bot) sidebar.row_meta = {} for name in names: meta = {"snippet": bot_snippet(name), "draft": refs["drafts"].get(name, "").split("\n")[0][:40]} meta.update(bot_meta(name)) sidebar.row_meta[name] = meta sidebar.touch() def activate(session): nonlocal agent refs.session = session agent = session.agent if box.history.backend is not None: box.history.backend.bot = lambda: agent.bot def session_for(name): if name not in sessions: sessions[name] = TuiChatSession(name, spawn(agent, name)) return sessions[name] def select_bot(name): """Bring a bot on screen. Every bot has its own agent, so switching never waits for, interrupts or shares state with a running turn.""" if name == cur().name: refresh_bots() return refs["drafts"][cur().name] = box.value target = session_for(name) activate(target) target.unread = 0 draft = refs["drafts"].get(name, "") box.lines = draft.split("\n") or [""] box.cx = len(box.lines[-1]) box.cy = len(box.lines) - 1 box.top = 0 box.history.reset() refresh_bots() render_history(force=False) paint_status() if target.queued is not None: opener, target.queued = target.queued, None opener() def run_command(text): buf = io.StringIO() before = (agent.profile, agent.bot) try: with contextlib.redirect_stdout(buf): stay = handle_command(agent, text) except Exception as exc: note("error: %s" % short_error(exc), "error") return out = tui_strip_ansi(buf.getvalue()).strip() if out: note("\n".join(out.split("\n")[:100])) if not stay: app.quit() return if agent.profile != before[0]: for name in [name for name, item in sessions.items() if item is not cur() and not item.busy]: del sessions[name] cur().name = agent.bot elif agent.bot != before[1]: moved = agent.bot agent.switch_bot(before[1], silent=True) select_bot(moved) refresh_bots() paint_status() def settle_pending_tick(session=None): session = session or cur() block = session.pending_tick session.pending_tick = None if block is None or not any(entry is block for entry in session.blocks): return block[4] = TUI_CHAT_TICK_READ block[5] = True if session is cur(): replay() def do_send(value): text = (value or "").strip() session = cur() if not text or session.pending is not None or session.busy: return box.history.add(text) clear_box() refs["drafts"][agent.bot] = "" if text.startswith("/"): run_command(text) return stamp = datetime.now().strftime("%H:%M") session.pending_tick = bubble("out", text, stamp, TUI_CHAT_TICK_SEND) session.pending = text session.note = "thinking..." session.phase, session.detail = "thinking", "" refresh_bots() paint_status() app.after(0.05, lambda: run_pending(session)) def run_pending(session=None): session = session or cur() text = session.pending session.pending = None if not text: return session.busy = True session.since = time.time() arm_spin() worker = session.agent worker.on_phase = lambda name, detail="": set_phase(session, name, detail) worker.prompter = lambda kind, ask, options: prompter(kind, ask, options, session) worker.stream_factory = lambda command: live_stream(command, session) def turn(): with TUI_STDIO_GUARD: try: return worker.run_turn(text, capture=True) except KeyboardInterrupt: worker.checkpoint() return "[interrupted]" except BackendError as exc: return "error: %s" % short_error(exc) def landed(answer, error): if error is not None: answer = "error: %s" % short_error(error) settle_pending_tick(session) full = tui_strip_ansi(answer or "[no reply]") stamp = datetime.now().strftime("%H:%M") foreground = session is cur() animate = app._loop is not None and len(full) > 12 and foreground block = bubble("in", "" if animate else full, stamp, sender=session.name, session=session) if not foreground: session.unread += 1 if not animate: finish_turn(session) return session.phase = "typing" session.reveal = {"block": block, "full": full, "shown": 0, "step": max(6, -(-len(full) // TUI_CHAT_REVEAL_STEPS))} app.after(TUI_CHAT_REVEAL_TICK, lambda: reveal_step(session)) app.offload(turn, landed) def live_stream(command, session): """Called from a turn thread when a command starts: its window joins the session journal as a message, then repaints at most every TUI_CHAT_LIVE_TICK while output streams in.""" loop = app._loop if loop is None: return None block = ["live", command[:300], [], ""] flag = {"queued": False} def flush(): flag["queued"] = False if session is cur() and any(entry is block for entry in session.blocks): replay() def push(): if not flag["queued"]: flag["queued"] = True loop.call_soon_threadsafe(lambda: app.after(TUI_CHAT_LIVE_TICK, flush)) loop.call_soon_threadsafe(lambda: journal(block, session)) return TuiLiveStream(block, push) def finish_turn(session=None): session = session or cur() session.busy = False session.phase, session.detail, session.reveal = "idle", "", None session.note = "" refresh_bots() paint_status() def reveal_step(session=None): """Lay the reply out word by word: real typing, not a fake wait. Bounded to TUI_CHAT_REVEAL_STEPS ticks however long the reply is. If you switch away mid-reveal the rest lands at once.""" session = session or cur() state = session.reveal if state is None: return full = state["full"] end = min(len(full), state["shown"] + state["step"]) while end < len(full) and not full[end - 1].isspace(): end += 1 if session is not cur(): end = len(full) state["shown"] = end block = state["block"] if any(entry is block for entry in session.blocks): block[2] = full[:end] if session is cur(): replay() if end >= len(full): finish_turn(session) else: app.after(TUI_CHAT_REVEAL_TICK, lambda: reveal_step(session)) def set_phase(session, name, detail=""): session.phase, session.detail = name, detail def style_dialog(dialog): """Dress a stock dialog in the chat theme: surface fill, accent rounded border, accent buttons.""" dialog.style = TuiStyle(bg="role:surface", fg="role:fg") dialog.border_style = TuiStyle(bg="role:bg", fg="role:accent") dialog.title_style = TuiStyle(bg="role:bg", fg="role:accent2", bold=True) dialog.rounded = True dialog.padding = TuiPanel.norm_padding((0, 1)) for node in dialog.walk(): if node is dialog: continue if isinstance(node, TuiButton): node.style = TuiStyle(bg="role:accent", fg="role:on_accent", bold=True) node.rounded = True node.border_style = TuiStyle(bg="role:surface", fg="role:accent") node.focus_border_style = TuiStyle(bg="role:surface", fg="role:accent2") elif isinstance(node, TuiTextBox): node.style = TuiStyle(bg="role:bg", fg="role:fg") elif not isinstance(node, (TuiRows, TuiCols)): node.style = TuiStyle(bg="role:surface", fg="role:fg") return dialog def prompter(kind, text, options, session=None): """Called from a turn thread: show a real dialog on the loop and wait for the answer. Returns the chosen option / typed text, or None when the dialog was dismissed or TUI_CHAT_PROMPT_TIMEOUT passed (so a walked-away user means deny, never an open-ended hang). The title names the bot asking, since it may not be the one on screen.""" session = session or cur() loop = app._loop if loop is None: return None answer = {"value": None} done = threading.Event() shown = [] text = str(text)[:TUI_CHAT_PROMPT_CHARS] width = min(72, max(30, app.cols - 6)) def finish(event=None): done.set() def pick(value): answer["value"] = value def ask_text(): dialog = style_dialog(TuiPromptDialog(text if kind == "ask" else "Reply:", box_h=3, on_execute=pick, title="Question - %s" % session.name, w=width)) dialog.on_close = finish shown.append(dialog) app.root.add(dialog) def chosen(value): if value is TUI_CHAT_TYPE_ANSWER: answer["typing"] = True return pick(value) def open_dialog(): if kind == "approve": dialog = TuiQuestion("Run this?\n\n%s" % text, options, on_done=pick, title="Approval needed - %s" % session.name, w=width) elif options: dialog = TuiQuestion(text, list(options) + [("Type an answer...", TUI_CHAT_TYPE_ANSWER)], on_done=chosen, title="Question - %s" % session.name, w=width) else: ask_text() return def closed(event): if answer.get("typing"): answer["typing"] = False ask_text() return finish() dialog.on_close = closed shown.append(style_dialog(dialog)) app.root.add(dialog) before = session.phase, session.detail session.phase, session.detail = "ask", "" if session is cur(): loop.call_soon_threadsafe(open_dialog) else: session.queued = lambda: loop.call_soon_threadsafe(open_dialog) loop.call_soon_threadsafe(refresh_meta) if not done.wait(TUI_CHAT_PROMPT_TIMEOUT): session.queued = None for dialog in shown: loop.call_soon_threadsafe(dialog.close) session.queued = None session.phase, session.detail = before return answer["value"] def arm_spin(): if refs.get("spin_timer") is None: refs["spin_timer"] = app.every(0.08, spin) def spin(): live = [item for item in sessions.values() if item.busy] if not live: app.cancel_timer(refs.get("spin_timer")) refs["spin_timer"] = None return refs["spin"] += 1 for item in live: word = "typing" if item.phase == "typing" else ("running %s" % (item.detail or "tool") if item.phase == "tool" else ("waiting for you" if item.phase == "ask" else "thinking")) item.note = "%s... %ds" % (word, int(time.time() - item.since)) refresh_meta() paint_status() def toggle_sidebar(): sidebar_wrap.visible = not sidebar_wrap.visible refs["sidebar_hide"] = not sidebar_wrap.visible refs["sidebar_auto"] = False if sidebar_wrap.visible: sidebar_wrap.w = tui_chat_sidebar_w(app.cols) app.ensure_focus() def on_resize(event): sidebar_wrap.w = tui_chat_sidebar_w(app.cols) compact = app.cols < 50 send_btn.label = "➤" if compact else "Send ➤" send_btn.w = 5 if compact else 10 if app.cols < TUI_CHAT_NARROW_COLS and sidebar_wrap.visible and not refs["sidebar_hide"]: sidebar_wrap.visible = False refs["sidebar_auto"] = True elif app.cols >= TUI_CHAT_NARROW_COLS and refs["sidebar_auto"] and not refs["sidebar_hide"]: sidebar_wrap.visible = True refs["sidebar_auto"] = False app.ensure_focus() app.after(0.05, maybe_replay) def on_root_key(event): key = event.key or "" if key == "f2": event.stop() toggle_sidebar() elif key == "f4": event.stop() nxt = tui_cycle_theme_name(tui_theme().name) app.set_theme(nxt) refs["note"] = "theme: %s" % nxt paint_status() elif key == "q": event.stop() app.quit() elif key in ("i", "escape"): event.stop() box.focus() def tick_input(): rows = max(1, min(5, len(box.lines))) if box.h != rows: box.h = rows send_btn.h = rows composer.h = rows + 2 sidebar.on_select = lambda event: select_bot(event.value) root.on_key = on_root_key root.on_resize = on_resize box.on_change = lambda event: tick_input() tick_input() app.every(1.0, paint_status) on_resize(None) refresh_bots() render_history() paint_status() refs.update({"do_send": do_send, "run_pending": run_pending, "select_bot": select_bot, "toggle_sidebar": toggle_sidebar, "paint_status": paint_status, "prompter": prompter, "live_stream": live_stream, "show": show, "replay": replay, "add_note": note, "render_history": render_history}) return root, refs def tui_chat(agent, theme_name): tui_set_theme(theme_name) agent.quiet = True app = TuiApp() app.rows, app.cols = app._winsize() scheduler_stop = start_scheduler(agent.config, agent.store.seal) refs = None try: root, refs = tui_build_chat(app, agent) app.mount(root) refs["replay"]() refs["box"].focus() return app.run() except TuiError as exc: print("tui unavailable: %s" % exc, file=sys.stderr) return 2 finally: scheduler_stop.set() owners = [item.agent for item in refs["sessions"].values()] if refs is not None else [agent] for owner in owners: owner.store.save_session(owner.profile, owner.messages, owner.bot, ctx=getattr(owner, "ctx_id", "")) # --------------------------------------------------------------------------- # Grr TUI: the terminal client of the grr server. Specified in grrtui.md. # Part A: constants, palette, strings, formatting, avatars, HTTP and event # stream client, saved accounts, store, router. # --------------------------------------------------------------------------- GRR_DEFAULT_URL = "http://127.0.0.1:8765" GRR_HTTP_TIMEOUT = 30 GRR_SSE_TIMEOUT = 60 GRR_SSE_RETRY = 2.0 GRR_SSE_LINE_MAX = 1 << 20 GRR_BODY_MAX = 32 << 20 GRR_SIDE_W = 34 GRR_SIDE_MIN_COLS = 100 GRR_PANE_MIN_COLS = 150 GRR_PANE_MIN_W = 40 GRR_PANE_MAX_W = 90 GRR_MESSAGE_KEEP = 400 GRR_TOAST_SECS = 3.2 GRR_BANNER_SECS = 5.2 GRR_TOAST_MAX = 4 GRR_COMPOSER_MAX_ROWS = 6 GRR_VIEWER_MAX_BYTES = 2 << 20 GRR_DRAFT_KEEP = 64 GRR_LIST_MORE = 12 GRR_MENU_MAX_H = 14 GRR_PALETTE_FALLBACK = { "night": {"bg": "#0d0c0a", "raised": "#15130f", "sunken": "#090807", "card": "#1c1a15", "ink": "#ece6da", "soft": "#cfc8ba", "muted": "#8c857a", "faint": "#5b564e", "rule": "#2a2722", "rule-strong": "#3d3932", "accent": "#d4a020", "accent-ink": "#120f08", "success": "#7fa36b", "danger": "#c8644f", "info": "#7f9eb0", "bubble-user": "#3d3932", "bubble-bot": "#1c1a15", "code-bg": "#12110e"}, "day": {"bg": "#f6f1e6", "raised": "#fffaf0", "sunken": "#ece5d6", "card": "#fffaf0", "ink": "#1b1812", "soft": "#3d392f", "muted": "#6c675e", "faint": "#a49d8e", "rule": "#e0d8c6", "rule-strong": "#c9bfa8", "accent": "#b07d0a", "accent-ink": "#fffaf0", "success": "#4f7a3a", "danger": "#b0432e", "info": "#41697f", "bubble-user": "#e5dcc6", "bubble-bot": "#fffaf0", "code-bg": "#efe7d4"}, "variants": {"black": {"night": None, "day": None}, "blue": {"night": {"accent": "#7f9eb0", "accent-ink": "#0a1015"}, "day": {"accent": "#41697f", "accent-ink": "#f6f1e6"}}}, "bot_colors": ["#ffffff", "#9a6a3a", "#e5484d", "#f76b15", "#d4a020", "#30a46c", "#12a594", "#3e63dd", "#8e4ec6", "#e93d82", "#8c857a"], } GRR_SHAPES = ("blob", "cloud", "tilt", "plus", "flower", "leaf", "pebble", "oval", "play", "gem", "cog", "hex") # Two rows of seven cells per shape; "E" marks an eye cell, drawn in the dark eye color. GRR_AVATAR_ROWS = { "blob": ("▗█E█E█▖", "▝█E█E█▘"), "cloud": ("▟█E█E█▙", "▜█E█E█▛"), "tilt": (" ▟E█E█▘", "▗▜E█E█ "), "plus": ("▄▟E█E█▄", "▀▜E█E█▀"), "flower": ("▗▜E█E█▖", "▝▟E█E█▘"), "leaf": ("▗█E█E█▛", "▜█E█E█▖"), "pebble": ("▗█E█E█▖", "▝█E█E█▘"), "oval": (" ▟E█E▙ ", " ▜E█E▛ "), "play": ("▛█E█E█▙", "▙█E█E█▛"), "gem": ("▗▟E█E█▖", " ▜E█E█▘"), "cog": ("▟▀E▀E▀▙", "▜▄E▄E▄▛"), "hex": ("▟█E█E█▙", "▜█E█E█▛"), } GRR_ICONS = { "back": "‹", "close": "✕", "plus": "+", "search": "⌕", "gear": "⚙", "compose": "✎", "more": "⋯", "moreV": "⋮", "share": "⇧", "check": "✓", "right": "›", "down": "▾", "up": "▴", "trash": "✗", "copy": "❐", "mic": "●", "wave": "≈", "send": "↑", "stop": "■", "camera": "◘", "image": "▨", "file": "▤", "skills": "▦", "plug": "◈", "incognito": "◐", "keyboard": "▭", "clipboard": "▥", "help": "?", "bell": "♪", "sun": "☼", "moon": "☾", "contrast": "◑", "globe": "◍", "key": "⌘", "user": "☺", "folder": "▣", "box": "▢", "automation": "◷", "star": "★", "download": "⇩", "forward": "↪", "tune": "≡", "filter": "▽", "terminal": "❯", "link": "∞", "external": "↗", "sparkle": "✦", "thumbup": "▲", "thumbdown": "▼", "speaker": "♫", "refresh": "↻", "hash": "#", "group": "☻", "pause": "‖", "play": "▶", "clock": "◷", "pin": "⚑", "edit": "✎", "eye": "◉", "inbox": "▥", "rocket": "➤", "panel": "◨", "phone": "▯", "code": "‹", "expand": "⇱", "shrink": "⇲", "bolt": "ϟ", "monitor": "▭", "list": "≣", "cpu": "▦", "warn": "!", "dot": "●", } class GrrError(Exception): """A failed call to the grr server: HTTP status (0 for no answer), the stable error code, the words for the user and an optional hint.""" def __init__(self, status, code, message, hint="", vars=None): super().__init__(message) self.status = status self.code = code self.hint = hint self.vars = vars or {} def text(self): return (self.args[0] + (" " + self.hint if self.hint else "")).strip() GRR_STRING_FALLBACK = {"library.sort_new": "Newest first", "library.sort_old": "Oldest first", "library.sort_name": "Name", "library.sort_size": "Largest first"} class GrrStrings: """The server's own string table (`/locales.json`), so the TUI says the same words as the web app in every language, from one source.""" ALIAS = {"bad_value": "invalid", "bad_kind": "invalid", "bad_mode": "invalid", "bad_shape": "invalid", "bad_color": "invalid", "bad_action": "invalid", "bad_decision": "invalid", "unknown_setting": "invalid", "bot_required": "invalid", "bad_schedule": "invalid", "bad_json": "invalid"} def __init__(self, table=None, lang="en"): self.table = table or {} self.lang = lang if lang in self.table else "en" def has(self, key): return key in self.table.get(self.lang, {}) or key in self.table.get("en", {}) def t(self, key, **vars): text = self.table.get(self.lang, {}).get(key) if text is None: text = self.table.get("en", {}).get(key) if text is None: text = GRR_STRING_FALLBACK.get(key, key) for name, value in vars.items(): text = text.replace("{" + name + "}", str(value)) return text def use(self, setting, languages=(), env=None): self.lang = GrrStrings.resolve(setting, languages, env if env is not None else os.environ) if self.lang not in self.table: self.lang = "en" return self.lang @staticmethod def resolve(setting, languages, env): if setting and setting != "system": return setting raw = (env.get("LC_ALL") or env.get("LC_MESSAGES") or env.get("LANG") or "en").split(".")[0].replace("_", "-") low = raw.lower() for known in languages: if known.lower() == low: return known if low in ("zh-tw", "zh-hk"): return "zh-TW" base = low.split("-")[0] for known in languages: if known.lower() == base: return known return "en" def explain(self, status, body): """Words for an error body: the localized `apierr.` when there is one, else the server's own message.""" err = (body or {}).get("error") or {} if isinstance(body, dict) else {} code = err.get("code") or "http" vars = err.get("vars") or {} alias = GrrStrings.ALIAS.get(code) or ("missing" if code.startswith("no_") else "") key = "apierr." + code if self.has("apierr." + code) else ("apierr." + alias if alias and self.has("apierr." + alias) else "") if key: hint = self.t(key + "_hint") if self.has(key + "_hint") else "" return GrrError(status, code, self.t(key, **vars), hint, vars) return GrrError(status, code, err.get("message") or ("HTTP %d" % status), err.get("hint") or "", vars) class GrrPalette: """The grr colors for one mode (night or day) and variant, read from the server's spec so the TUI never carries its own copy of the brand.""" def __init__(self, spec=None, theme="system", variant="black"): palette = (spec or {}).get("palette") or GRR_PALETTE_FALLBACK self.mode = GrrPalette.mode_of(theme) base = dict(GRR_PALETTE_FALLBACK[self.mode]) base.update(palette.get(self.mode) or {}) change = ((palette.get("variants") or {}).get(variant) or {}).get(self.mode) if change: base.update(change) self.colors = base self.variant = variant self.bot_colors = list(palette.get("bot_colors") or GRR_PALETTE_FALLBACK["bot_colors"]) self.name = "grr-%s-%s" % (self.mode, variant) @staticmethod def mode_of(theme): if theme in ("day", "night"): return theme hint = os.environ.get("COLORFGBG", "") if hint and hint.split(";")[-1].isdigit() and int(hint.split(";")[-1]) in (7, 15): return "day" return "night" def c(self, name): return self.colors.get(name) or self.colors["ink"] def tui_theme(self): c = self.c roles = {"bg": c("bg"), "surface": c("card"), "fg": c("ink"), "muted": c("muted"), "border": c("rule-strong"), "accent": c("accent"), "on_accent": c("accent-ink"), "selection": c("rule-strong"), "success": c("success"), "warning": c("accent"), "error": c("danger"), "info": c("info"), "bubble_in": c("bubble-bot"), "bubble_out": c("bubble-user"), "bubble_fg": c("ink"), "meta_in": c("muted"), "meta_out": c("soft"), "tick_read": c("info"), "service_bg": c("rule"), "service_fg": c("muted"), "unread": c("accent"), "unread_muted": c("faint"), "panel": c("raised"), "accent2": c("info"), "accent3": c("accent"), "code_bg": c("code-bg"), "tool_bg": c("sunken")} return TuiTheme(self.name, self.mode == "night", roles) def apply(self): tui_register_theme(self.tui_theme()) tui_set_theme(self.name) @staticmethod def light(color): try: n = int(color.lstrip("#"), 16) except ValueError: return False return (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000 > 150 class GrrUi: """What every drawing routine needs: palette, strings, formatting, style cache. One instance per app, replaced as a whole on a theme change.""" def __init__(self, palette, strings, spec=None): self.palette = palette self.strings = strings self.spec = spec or {} self.version = 1 self._styles = {} self.fmt = GrrFormat(strings) def t(self, key, **vars): return self.strings.t(key, **vars) def st(self, fg="ink", bg="bg", bold=False, dim=False, underline=False, reverse=False): key = (fg, bg, bold, dim, underline, reverse) style = self._styles.get(key) if style is None: pal = self.palette style = self._styles[key] = TuiStyle(fg=pal.c(fg) if not fg.startswith("#") else fg, bg=pal.c(bg) if not bg.startswith("#") else bg, bold=bold, dim=dim, underline=underline, reverse=reverse) return style def cap(self, name, default): return ((self.spec.get("caps") or {}).get(name)) or default def icon(self, name): return GRR_ICONS.get(name, "?") class GrrFormat: """Times, sizes and schedules in the words of the server's string table.""" def __init__(self, strings): self.s = strings @staticmethod def when(ts): return datetime.fromtimestamp(float(ts)) def clock(self, ts): return GrrFormat.when(ts).strftime("%H:%M") def list_time(self, ts): if not ts: return "" d, now = GrrFormat.when(ts), datetime.now() if d.date() == now.date(): return self.clock(ts) if d.date() == (now - timedelta(days=1)).date(): return self.s.t("time.yesterday") if (now - d).days < 6: return d.strftime("%A") return "%d %s" % (d.day, d.strftime("%b")) def stamp(self, ts): d, now = GrrFormat.when(ts), datetime.now() if d.date() == now.date(): return self.s.t("time.today") + " " + self.clock(ts) if d.date() == (now - timedelta(days=1)).date(): return self.s.t("time.yesterday") + " " + self.clock(ts) if (now - d).days < 6: return d.strftime("%A") + " " + self.clock(ts) return "%d %s %s" % (d.day, d.strftime("%b"), self.clock(ts)) def short_date(self, ts): d = GrrFormat.when(ts) return "%d %s" % (d.day, d.strftime("%b")) def ago(self, ts): secs = int(float(ts) - time.time()) for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)): if abs(secs) >= size: n = int(round(secs / float(size))) if unit == "day" and n == -1: return "yesterday" word = "%d %s%s" % (abs(n), unit, "" if abs(n) == 1 else "s") return word + " ago" if n < 0 else "in " + word return "now" @staticmethod def bytes(n): n = int(n or 0) if n < 1024: return "%d B" % n if n < 1048576: return ("%.1f kB" if n < 10240 else "%.0f kB") % (n / 1024.0) if n < 1073741824: return "%.1f MB" % (n / 1048576.0) return "%.2f GB" % (n / 1073741824.0) @staticmethod def num(n): return "{:,}".format(int(n or 0)) @staticmethod def weekday(i): return ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")[int(i) % 7] @staticmethod def clock_label(hhmm): return hhmm def schedule(self, s): if not s: return "" if s.get("kind") == "interval": sec = int(s.get("every_seconds") or 0) if sec and sec % 86400 == 0: return self.s.t("sched.every_days", n=sec // 86400) if sec and sec % 3600 == 0: return self.s.t("sched.every_hours", n=sec // 3600) if sec and sec % 60 == 0: return self.s.t("sched.every_minutes", n=sec // 60) return self.s.t("sched.every_seconds", n=sec) if s.get("kind") == "daily": return self.s.t("sched.daily", time=s.get("time", "")) return self.s.t("sched.weekly", day=GrrFormat.weekday(s.get("day", 0)), time=s.get("time", "")) @staticmethod def next_run(ts): return GrrFormat.when(ts).strftime("%a %d %b %H:%M") if ts else "-" @staticmethod def domain(url): try: host = urllib.parse.urlsplit(url).hostname or url except ValueError: return url return host[4:] if host.startswith("www.") else host class GrrAvatar: """A bot's mark in cells: the character shape with two eyes. `rows` draws it 2 rows tall (5 wide when `small`, 7 wide otherwise; `hero` makes it 4 rows), `chip` is one row, `chip_rows` a tiny 2x2 body.""" @staticmethod def eye_color(color): return "#1b1812" if GrrPalette.light(color) else "#120f08" @staticmethod def rows(ui, bot, bg="bg", small=False, hero=False): shape = (bot or {}).get("shape") or "blob" color = (bot or {}).get("color") or ui.palette.c("accent") body = TuiStyle(fg=color, bg=ui.palette.c(bg)) dark = TuiStyle(fg=GrrAvatar.eye_color(color), bg=color) top, bottom = GRR_AVATAR_ROWS.get(shape, GRR_AVATAR_ROWS["blob"]) if small: top = top[0] + "E█E" + top[-1] bottom = bottom[0] + "E█E" + bottom[-1] templates = [top, bottom] if hero: mid = "█" + top[1:-1] + "█" templates = [top, mid, mid, bottom] out = [] for template in templates: spans = [] for ch in template: spans.append(TuiSpan("▌", dark) if ch == "E" else TuiSpan(ch, body)) out.append(tui_merge_spans(spans)) return out @staticmethod def chip(ui, bot, bg="bg"): color = (bot or {}).get("color") or ui.palette.c("accent") return [TuiSpan(" ▮ ▮ ", TuiStyle(fg=GrrAvatar.eye_color(color), bg=color))] @staticmethod def chip_rows(ui, bot, bg="bg"): color = (bot or {}).get("color") or ui.palette.c("accent") body = TuiStyle(fg=color, bg=ui.palette.c(bg)) return [[TuiSpan("▟▙", body)], [TuiSpan("▜▛", body)]] def tui_merge_spans(spans): """Join neighbouring spans that share a style into one.""" out = [] for span in spans: if out and out[-1].style is span.style: out[-1] = TuiSpan(out[-1].text + span.text, span.style) else: out.append(span) return out class GrrClient: """Blocking JSON client of the grr REST API on the standard library. Each call opens its own connection, so any thread may use it. Call it from a worker (`app.offload`), never from the loop.""" def __init__(self, base=GRR_DEFAULT_URL, token="", strings=None, timeout=GRR_HTTP_TIMEOUT): self.base = (base or GRR_DEFAULT_URL).rstrip("/") self.token = token self.strings = strings or GrrStrings() self.timeout = timeout self.on_unauthorized = None def _conn(self, timeout=None): parts = urllib.parse.urlsplit(self.base) host = parts.hostname or "127.0.0.1" cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection return cls(host, parts.port or (443 if parts.scheme == "https" else 80), timeout=timeout or self.timeout) def request(self, method, path, body=None, raw=None, ctype="", timeout=None, quiet=False): headers = {"Accept": "application/json"} if self.token: headers["Authorization"] = "Bearer " + self.token payload = None if raw is not None: payload = raw headers["Content-Type"] = ctype or "application/octet-stream" elif body is not None: payload = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" conn = self._conn(timeout) try: conn.request(method, path, body=payload, headers=headers) res = conn.getresponse() data = res.read(GRR_BODY_MAX + 1) except (OSError, http.client.HTTPException) as exc: raise GrrError(0, "offline", self.strings.t("err.offline"), "", {"reason": str(exc)}) finally: conn.close() if len(data) > GRR_BODY_MAX: raise GrrError(res.status, "too_big", "The answer was larger than %s." % GrrFormat.bytes(GRR_BODY_MAX)) if res.status == 204: return {} try: parsed = json.loads(data.decode("utf-8")) if data else {} except ValueError: parsed = None if res.status >= 400: if res.status == 401 and not quiet and callable(self.on_unauthorized): self.on_unauthorized() raise self.strings.explain(res.status, parsed) return parsed if parsed is not None else {} def get(self, path, **kw): return self.request("GET", path, **kw) def post(self, path, body=None, **kw): return self.request("POST", path, {} if body is None else body, **kw) def patch(self, path, body, **kw): return self.request("PATCH", path, body, **kw) def put(self, path, body, **kw): return self.request("PUT", path, body, **kw) def delete(self, path, body=None, **kw): return self.request("DELETE", path, body, **kw) def upload(self, name, data, chat="", bot=""): query = urllib.parse.urlencode({k: v for k, v in (("name", name), ("chat", chat), ("bot", bot)) if v}) return self.request("POST", "/api/uploads?" + query, raw=data) def url(self, path): return self.base + path + ("&" if "?" in path else "?") + "token=" + urllib.parse.quote(self.token) def file_url(self, file_id, download=False): return self.url("/api/files/%s%s" % (file_id, "?download=1" if download else "")) def fetch(self, path, limit=GRR_VIEWER_MAX_BYTES): """The raw bytes of a file path, capped at limit, with the type.""" headers = {"Authorization": "Bearer " + self.token} if self.token else {} conn = self._conn() try: conn.request("GET", path, headers=headers) res = conn.getresponse() data = res.read(limit + 1) if res.status >= 400: raise GrrError(res.status, "http", "HTTP %d" % res.status) return data[:limit], len(data) > limit, res.getheader("Content-Type") or "" except (OSError, http.client.HTTPException) as exc: raise GrrError(0, "offline", self.strings.t("err.offline"), "", {"reason": str(exc)}) finally: conn.close() class GrrStream: """Server-sent events on a thread of its own. `on_event(name, data)` and `on_link(up)` are posted to the app loop through `post`. It reconnects every GRR_SSE_RETRY seconds until stop(); a line over GRR_SSE_LINE_MAX ends the connection (it is counted, never parsed).""" def __init__(self, client, path, on_event, post, on_link=None, reconnect=True): self.client = client self.path = path self.on_event = on_event self.on_link = on_link self.post = post self.reconnect = reconnect self.wanted = False self.thread = None self.conn = None self.oversize = 0 self.sock_lock = threading.Lock() def start(self): if self.thread is not None and self.thread.is_alive(): return self.wanted = True self.thread = threading.Thread(target=self.run, name="grr-stream", daemon=True) self.thread.start() def stop(self): self.wanted = False with self.sock_lock: conn = self.conn if conn is not None: try: if conn.sock is not None: conn.sock.shutdown(socket.SHUT_RDWR) except OSError: pass try: conn.close() except OSError: pass def run(self): while self.wanted: try: self.pump() except (OSError, http.client.HTTPException, ValueError): pass if self.on_link is not None: self.post(lambda: self.on_link(False)) if not self.reconnect: return waited = 0.0 while self.wanted and waited < GRR_SSE_RETRY: time.sleep(0.1) waited += 0.1 def pump(self): conn = self.client._conn(GRR_SSE_TIMEOUT) with self.sock_lock: self.conn = conn try: headers = {"Accept": "text/event-stream"} if self.client.token: headers["Authorization"] = "Bearer " + self.client.token conn.request("GET", self.path, headers=headers) res = conn.getresponse() if res.status != 200: return if self.on_link is not None: self.post(lambda: self.on_link(True)) name, data = "message", [] while self.wanted: line = res.readline(GRR_SSE_LINE_MAX + 1) if not line: return if len(line) > GRR_SSE_LINE_MAX: self.oversize += 1 return text = line.decode("utf-8", "replace").rstrip("\r\n") if not text: if data: self.emit(name, "\n".join(data)) name, data = "message", [] elif text.startswith(":"): continue elif text.startswith("event:"): name = text[6:].strip() elif text.startswith("data:"): data.append(text[5:].lstrip(" ")) finally: conn.close() def emit(self, name, raw): try: data = json.loads(raw) except ValueError: data = {"raw": raw} self.post(lambda: self.on_event(name, data)) class GrrSession: """The signed-in accounts and the server address, kept in `/grr.json` (mode 0600). The token is the grr session token, not the DevPlace key.""" def __init__(self, path=None): home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") self.path = path or os.path.join(home, "grr.json") self.url = os.environ.get("GRR_URL") or GRR_DEFAULT_URL self.accounts = [] self.active = "" self.load() def load(self): try: with open(self.path, "r", encoding="utf-8") as fh: data = json.load(fh) except (OSError, ValueError): return if not os.environ.get("GRR_URL"): self.url = data.get("url") or self.url self.accounts = [a for a in data.get("accounts", []) if isinstance(a, dict) and a.get("token")] self.active = data.get("active") or "" def save(self): os.makedirs(os.path.dirname(self.path), exist_ok=True) tmp = self.path + ".tmp" fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump({"url": self.url, "accounts": self.accounts, "active": self.active}, fh) os.replace(tmp, self.path) @property def token(self): for account in self.accounts: if account["token"] == self.active: return self.active return self.accounts[0]["token"] if self.accounts else "" def add(self, token, account): self.accounts = [a for a in self.accounts if a.get("username") != account.get("username")] self.accounts.append({"token": token, "username": account.get("username", ""), "email": account.get("email", "")}) self.active = token self.save() def switch(self, token): self.active = token self.save() def drop(self, token): self.accounts = [a for a in self.accounts if a["token"] != token] if self.active == token: self.active = self.accounts[0]["token"] if self.accounts else "" self.save() class GrrEmitter: """Tiny publish and subscribe. A handler that raises is counted in `errors` and never stops the others.""" def __init__(self): self.handlers = {} self.errors = 0 def on(self, kind, fn): self.handlers.setdefault(kind, []).append(fn) return lambda: self.off(kind, fn) def off(self, kind, fn): if fn in self.handlers.get(kind, []): self.handlers[kind].remove(fn) def emit(self, kind, data=None): for fn in list(self.handlers.get(kind, [])): try: fn(data) except Exception: self.errors += 1 class GrrStore(GrrEmitter): """Bots, chats and live working status, kept fresh by the event stream. All state is touched on the loop thread only.""" def __init__(self, app, client): super().__init__() self.app = app self.client = client self.bots = [] self.chats = [] self.status = {} self.turn = 0 self.pending = None def refresh(self, done=None): self.turn += 1 turn = self.turn def work(): return self.client.get("/api/bots"), self.client.get("/api/chats") def landed(result, error): if error is None and turn == self.turn: self.bots = result[0].get("bots", []) self.chats = result[1].get("chats", []) self.emit("changed") if done is not None: done(error) self.app.offload(work, landed) def soon(self): if self.pending is not None: return self.pending = self.app.after(0.15, self._fire) def _fire(self): self.pending = None self.refresh() def bot(self, bot_id): for bot in self.bots: if bot["id"] == bot_id: return bot return None def chat(self, chat_id): for chat in self.chats: if chat["id"] == chat_id: return chat return None def status_of(self, chat_id, bot_id=""): return self.status.get("%s:%s" % (chat_id, bot_id)) def set_status(self, data): key = "%s:%s" % (data.get("chat_id"), data.get("bot_id") or "") if data.get("state") == "idle": self.status.pop(key, None) else: self.status[key] = data self.emit("status", data) def is_working(self, bot_id): for key, value in self.status.items(): if key.endswith(":" + bot_id) and value.get("state") != "idle": return value return None def active_in(self, chat_id): return [v for v in self.status.values() if v.get("chat_id") == chat_id and v.get("state") != "idle"] class GrrRoute: """A path pattern like `/bot/:id/skills` and the view factory it opens.""" def __init__(self, pattern, factory): self.pattern = pattern self.factory = factory self.names = re.findall(r":([a-z_]+)", pattern) self.rx = re.compile("^" + re.sub(r":[a-z_]+", "([^/]+)", pattern) + "$") class GrrRouter: """Hash-style routes with a back stack, like the web app's router. The stack is capped at 60. `on_route(route, params, query, raw)` fires on every change.""" STACK_MAX = 60 def __init__(self, on_route): self.routes = [] self.stack = [] self.on_route = on_route def add(self, pattern, factory): self.routes.append(GrrRoute(pattern, factory)) @staticmethod def parse(raw): raw = (raw or "/").lstrip("#") or "/" path, _, qs = raw.partition("?") query = {k: v[-1] for k, v in urllib.parse.parse_qs(qs).items()} return path or "/", query def match(self, path): for route in self.routes: m = route.rx.match(path) if m: return route, {n: urllib.parse.unquote(m.group(i + 1)) for i, n in enumerate(route.names)} return None, {} def go(self, raw, replace=False): raw = raw or "/" if replace and self.stack: self.stack[-1] = raw elif len(self.stack) >= 2 and self.stack[-2] == raw: self.stack.pop() elif not self.stack or self.stack[-1] != raw: self.stack.append(raw) if len(self.stack) > GrrRouter.STACK_MAX: del self.stack[0] self.fire(raw) def fire(self, raw): path, query = GrrRouter.parse(raw) route, params = self.match(path) if route is None: route, params = self.match("/") self.on_route(route, params, query, raw) def back(self, fallback="/"): if len(self.stack) > 1: self.stack.pop() self.fire(self.stack[-1]) else: self.go(fallback, replace=True) @property def current(self): return self.stack[-1] if self.stack else "/" # --------------------------------------------------------------------------- # Part B: the page engine. A screen body is a list of items (rows in cards, # conversation rows, buttons, tabs, tiles ...). The page scrolls them, moves a # selection with the keyboard and routes clicks to the item under the pointer. # --------------------------------------------------------------------------- def grr_text(value): """A server value as text: None is empty, anything else its string.""" return "" if value is None else str(value) def grr_clip(text, width): text = str(text).replace("\n", " ") if width <= 0: return "" return text if len(text) <= width else text[:max(0, width - 1)] + "…" def grr_wrap(text, width): lines = [] for para in str(text).split("\n"): lines.extend(textwrap.wrap(para, max(1, width), replace_whitespace=False, drop_whitespace=True, break_long_words=True) or [""]) return lines def grr_line(spans, width, fill): return tui_slice_spans(list(spans), 0, width, fill) def grr_margin(width): return 1 if width < 48 else 2 class GrrCard: """The rounded card every grouped row sits in. Top and bottom edges are half-height rows with quarter-block corners, the sides are half blocks, so the card reads as rounded in any terminal font.""" @staticmethod def inner(width): return max(1, width - 2 * grr_margin(width) - 2) @staticmethod def top(ui, width, bg="card"): m = grr_margin(width) cw = max(2, width - 2 * m) return grr_line([TuiSpan(" " * m + "▗" + "▄" * (cw - 2) + "▖", ui.st(bg, "bg"))], width, ui.st("ink", "bg")) @staticmethod def bottom(ui, width, bg="card"): m = grr_margin(width) cw = max(2, width - 2 * m) return grr_line([TuiSpan(" " * m + "▝" + "▀" * (cw - 2) + "▘", ui.st(bg, "bg"))], width, ui.st("ink", "bg")) @staticmethod def line(ui, width, spans, bg="card"): m = grr_margin(width) page = ui.st("ink", "bg") edge = ui.st(bg, "bg") body = grr_line(list(spans), GrrCard.inner(width), ui.st("ink", bg)) return [TuiSpan(" " * m, page), TuiSpan("▐", edge)] + body + [TuiSpan("▌", edge), TuiSpan(" " * m, page)] class GrrItem: """One block of a page. `build(ui, width, selected)` returns span rows that are exactly `width` cells wide. Rows are cached until the item's version, the width, the selection or the palette changes. `hit(...)` registers clickable regions during build: (row, x0, x1, callback).""" selectable = False key = None def __init__(self): self.page = None self.version = 0 self.stop = 0 self.stops = 1 self._cache = None self._hits = [] def touch(self): self.version += 1 if self.page is not None: self.page.item_changed(self) def rows(self, ui, width, selected): key = (ui.version, width, selected, self.version, self.stop) if self._cache is not None and self._cache[0] == key: return self._cache[1] self._hits = [] rows = self.build(ui, width, selected) self._cache = (key, rows, self._hits) return rows def build(self, ui, width, selected): return [] def hit(self, row, x0, x1, fn): self._hits.append((row, x0, x1, fn)) def hit_at(self, ui, width, row, x): self.rows(ui, width, False) for hit_row, x0, x1, fn in self._cache[2]: if hit_row == row and x0 <= x < x1: return fn return None def ident(self): """What identifies this item when a page is rebuilt: its key, or its kind and words, so the selection stays where the user put it.""" if self.key is not None: return self.key return (type(self).__name__, str(getattr(self, "title", None) or getattr(self, "text", None) or getattr(self, "label", None) or "")) def activate(self): action = getattr(self, "action", None) if callable(action): action() def nav(self, direction): return False class GrrSpacer(GrrItem): def __init__(self, n=1): super().__init__() self.n = n def build(self, ui, width, selected): return [[TuiSpan(" " * width, ui.st())] for _ in range(self.n)] class GrrLabel(GrrItem): """A small muted caption above a group, like Character or Planning.""" def __init__(self, text): super().__init__() self.text = text def build(self, ui, width, selected): m = grr_margin(width) + 1 return [grr_line([TuiSpan(" " * m + grr_clip(self.text, width - m * 2), ui.st("muted"))], width, ui.st())] class GrrHeading(GrrItem): def __init__(self, text, center=False): super().__init__() self.text = text self.center = center def build(self, ui, width, selected): m = grr_margin(width) text = grr_clip(self.text, width - 2 * m) pad = (width - len(text)) // 2 if self.center else m return [grr_line([TuiSpan(" " * pad + text, ui.st("ink", bold=True))], width, ui.st())] class GrrNote(GrrItem): def __init__(self, text, center=False, tone="faint"): super().__init__() self.text = text self.center = center self.tone = tone def build(self, ui, width, selected): m = grr_margin(width) + 1 out = [] for line in grr_wrap(self.text, width - 2 * m): pad = (width - len(line)) // 2 if self.center else m out.append(grr_line([TuiSpan(" " * pad + line, ui.st(self.tone))], width, ui.st())) return out class GrrRowItem(GrrItem): """A row inside a card group: icon or lead, title, sub, value and a switch, check or chevron on the right. Activate runs `action`, or flips the switch when `toggle` is given.""" def __init__(self, title, sub="", icon="", lead=None, value="", chevron=False, check=False, toggle=None, on_toggle=None, action=None, danger=False, link=False, wrap=False, pos="only", key=None, tone=""): super().__init__() self.title = title self.sub = sub self.icon = icon self.lead = lead self.value = value self.chevron = chevron self.check = check self.toggle = toggle self.on_toggle = on_toggle self.action = action self.danger = danger self.link = link self.wrap = wrap self.pos = pos self.key = key self.tone = tone self.selectable = action is not None or toggle is not None def set_toggle(self, on): self.toggle = on self.touch() def activate(self): if self.toggle is not None and self.on_toggle is not None: nxt = not self.toggle self.toggle = nxt self.touch() self.on_toggle(nxt) elif callable(self.action): self.action() def build(self, ui, width, selected): inner = GrrCard.inner(width) bg = "rule-strong" if selected else "card" base = ui.st("ink", bg) title_fg = "danger" if self.danger else ("accent" if self.link else "ink") lead = list(self.lead(ui, bg)) if callable(self.lead) else [] lead_w = tui_span_width(lead) icon = (ui.icon(self.icon) + " ") if self.icon else "" right = [] if self.value: right.append(TuiSpan(grr_clip(self.value, max(6, inner // 2)) + " ", ui.st("muted", bg))) if self.toggle is not None: on = bool(self.toggle) edge = ui.st("accent" if on else "rule-strong", bg) pill = TuiStyle(fg=ui.palette.c("accent-ink" if on else "muted"), bg=ui.palette.c("accent" if on else "rule-strong")) right += [TuiSpan("▐", edge), TuiSpan(" ●" if on else "● ", pill), TuiSpan("▌", edge), TuiSpan(" ", base)] if self.check: right.append(TuiSpan(ui.icon("check") + " ", ui.st("ink", bg, bold=True))) if self.chevron: right.append(TuiSpan(ui.icon("external" if self.chevron == "ext" else "right") + " ", ui.st("muted", bg))) right_w = tui_span_width(right) lead_cols = len(icon) + lead_w title_w = max(4, inner - 1 - lead_cols - right_w) lines = grr_wrap(self.title, title_w)[:3] if self.wrap else [grr_clip(self.title, title_w)] subs = grr_wrap(self.sub, inner - 1 - lead_cols - 1)[:2] if self.sub else [] body = [] for pos, text in enumerate(lines): spans = [TuiSpan(" ", base)] if pos == 0: if icon: spans.append(TuiSpan(icon, ui.st("soft", bg))) spans.extend(lead) else: spans.append(TuiSpan(" " * lead_cols, base)) spans.append(TuiSpan(text, ui.st(title_fg, bg, bold=selected and not self.danger))) body.append(grr_line(spans, inner - right_w, base) + (right if pos == 0 else [TuiSpan(" " * right_w, base)])) for text in subs: body.append(grr_line([TuiSpan(" " + " " * lead_cols + text, ui.st(self.tone or "muted", bg))], inner, base)) out = [] if self.pos in ("first", "only"): out.append(GrrCard.top(ui, width)) for row in body: out.append(GrrCard.line(ui, width, row, bg)) if self.pos in ("last", "only"): out.append(GrrCard.bottom(ui, width)) return out def grr_group(rows): """Mark the rows of one card as first, middle and last, return them.""" rows = [r for r in rows if r is not None] for pos, row in enumerate(rows): row.pos = "only" if len(rows) == 1 else ("first" if pos == 0 else ("last" if pos == len(rows) - 1 else "mid")) return rows class GrrConvItem(GrrItem): """A conversation row: avatar, name, time, preview, unread dot.""" selectable = True def __init__(self, avatar, title, time_text="", preview="", dot=False, tag="", current=False, action=None, menu=None, key=None, busy=False): super().__init__() self.avatar = avatar self.title = title self.time_text = time_text self.preview = preview self.dot = dot self.tag = tag self.current = current self.action = action self.menu = menu self.key = key self.busy = busy def build(self, ui, width, selected): m = grr_margin(width) bg = "rule-strong" if selected else ("card" if self.current else "bg") base = ui.st("ink", bg) art = self.avatar(ui, bg) if callable(self.avatar) else [[TuiSpan(" ", base)], [TuiSpan(" ", base)]] art_w = tui_span_width(art[0]) text_w = width - 2 * m - art_w - 2 stamp = self.time_text tag = (" " + self.tag + " ") if self.tag else "" name_w = max(4, text_w - len(stamp) - 1 - len(tag)) top = [TuiSpan(grr_clip(self.title, name_w), ui.st("ink", bg, bold=True))] if tag: top.append(TuiSpan(tag, ui.st("muted", "rule", ))) top = grr_line(top, text_w - len(stamp), base) + [TuiSpan(stamp, ui.st("muted", bg))] preview = grr_clip(self.preview, text_w - (2 if self.dot or self.busy else 0)) sub = [TuiSpan(preview, ui.st("accent" if self.busy else "muted", bg))] sub = grr_line(sub, text_w - 2, base) + [TuiSpan(" ", base), TuiSpan("●" if (self.dot or self.busy) else " ", ui.st("accent" if self.dot else "success", bg))] out = [] for pos, art_row in enumerate(art[:2]): text = top if pos == 0 else sub row = [TuiSpan(" " * m, ui.st("ink", "bg"))] + list(art_row) + [TuiSpan(" ", base)] + text + [TuiSpan(" " * m, ui.st("ink", "bg"))] out.append(grr_line(row, width, ui.st("ink", "bg"))) if self.menu is not None: self.hit(0, width - m - 2, width, self.menu) return out class GrrSectionHead(GrrItem): def __init__(self, text, icon="", action=None, aria=""): super().__init__() self.text = text self.icon = icon self.action = action self.selectable = action is not None def build(self, ui, width, selected): m = grr_margin(width) bg = "rule-strong" if selected else "bg" right = (" " + ui.icon(self.icon) + " ") if self.icon else "" row = grr_line([TuiSpan(" " * m + self.text, ui.st("muted", bg, bold=True))], width - len(right) - m, ui.st("ink", bg)) row += [TuiSpan(right, ui.st("soft", bg)), TuiSpan(" " * m, ui.st("ink", bg))] if right and self.action is not None: self.hit(0, width - m - len(right), width, self.action) return [row] class GrrButtonItem(GrrItem): """A pill button: three rows (cap, label, cap) so the ends look round.""" def __init__(self, label, action, kind="primary", inline=False, icon="", disabled=False): super().__init__() self.label = label self.action = action self.kind = kind self.inline = inline self.icon = icon self.disabled = disabled self.selectable = not disabled def build(self, ui, width, selected): m = grr_margin(width) text = ((ui.icon(self.icon) + " ") if self.icon else "") + self.label if self.kind == "primary": fg, bg = "accent-ink", "accent" elif self.kind == "danger": fg, bg = "danger", "card" elif self.kind == "ghost": fg, bg = "muted", "bg" else: fg, bg = "ink", "card" if selected: bg = "ink" if self.kind in ("primary",) else "rule-strong" fg = "bg" if self.kind in ("primary",) else fg cw = min(width - 2 * m, len(text) + 8) if self.inline else width - 2 * m lead = (width - cw) // 2 page = ui.st("ink", "bg") edge = ui.st(bg, "bg") body = ui.st(fg, bg, bold=True, dim=self.disabled) out = [grr_line([TuiSpan(" " * lead + "▗" + "▄" * (cw - 2) + "▖", edge)], width, page), grr_line([TuiSpan(" " * lead, page), TuiSpan(text.center(cw)[:cw], body)], width, page), grr_line([TuiSpan(" " * lead + "▝" + "▀" * (cw - 2) + "▘", edge)], width, page)] if not self.disabled: for pos in range(3): self.hit(pos, lead, lead + cw, self.action) return out class GrrTextCard(GrrItem): """Text in a card, wrapped. Selectable when it has an action (edit).""" def __init__(self, text, action=None, placeholder="", mono=False, label=""): super().__init__() self.text = text self.action = action self.placeholder = placeholder self.mono = mono self.label = label self.selectable = action is not None def build(self, ui, width, selected): inner = GrrCard.inner(width) bg = "rule-strong" if selected else "card" text = self.text if self.text else self.placeholder tone = "ink" if self.text else "muted" lines = grr_wrap(text, inner - 2) if len(lines) > 24: lines = lines[:24] + ["…"] out = [GrrCard.top(ui, width, bg)] for line in lines: out.append(GrrCard.line(ui, width, [TuiSpan(" " + line, ui.st(tone, bg))], bg)) out.append(GrrCard.bottom(ui, width, bg)) return out class GrrTabsItem(GrrItem): """Tabs along the top of a body. When the labels do not fit the width they fold into a single dropdown chip, like the web app does on a phone.""" selectable = True def __init__(self, tabs, value, on_pick): super().__init__() self.tabs = list(tabs) self.value = value self.on_pick = on_pick self.stops = len(self.tabs) def pick(self, tab_id): self.value = tab_id self.touch() self.on_pick(tab_id) def index(self): for pos, tab in enumerate(self.tabs): if tab["id"] == self.value: return pos return 0 def nav(self, direction): if direction in ("left", "right"): pos = max(0, min(len(self.tabs) - 1, self.index() + (1 if direction == "right" else -1))) if self.tabs[pos]["id"] != self.value: self.pick(self.tabs[pos]["id"]) return True return False def menu(self): app = self.page.app if self.page is not None else None if app is not None and getattr(app, "grr", None) is not None: app.grr.menu([{"label": t["label"], "checked": t["id"] == self.value, "action": (lambda i=t["id"]: self.pick(i))} for t in self.tabs]) def activate(self): if self._folded: self.menu() _folded = False def build(self, ui, width, selected): m = grr_margin(width) need = sum(len(t["label"]) + 4 for t in self.tabs) page = ui.st("ink", "bg") self._folded = need > width - 2 * m if self._folded: label = next((t["label"] for t in self.tabs if t["id"] == self.value), "") text = " %s %s " % (label, ui.icon("down")) row = [TuiSpan(" " * m, page), TuiSpan(text, ui.st("ink", "rule-strong" if selected else "card", bold=True))] self.hit(0, m, m + len(text), self.menu) return [grr_line(row, width, page), [TuiSpan(" " * width, page)]] spans, under, x = [TuiSpan(" " * m, page)], [TuiSpan(" " * m, page)], m for tab in self.tabs: on = tab["id"] == self.value text = " %s " % tab["label"] focus = selected and on spans.append(TuiSpan(text + " ", ui.st("ink" if on else "muted", "rule-strong" if focus else "bg", bold=on))) under.append(TuiSpan(("▀" * len(text)) if on else (" " * len(text)), ui.st("accent", "bg"))) under.append(TuiSpan(" ", page)) self.hit(0, x, x + len(text), (lambda i=tab["id"]: self.pick(i))) x += len(text) + 2 return [grr_line(spans, width, page), grr_line(under, width, page)] class GrrSegmentedItem(GrrItem): """A pill group of exclusive choices (Every, Daily, Weekly).""" selectable = True def __init__(self, items, value, on_pick): super().__init__() self.items = list(items) self.value = value self.on_pick = on_pick def pick(self, item_id): self.value = item_id self.touch() self.on_pick(item_id) def nav(self, direction): if direction in ("left", "right"): ids = [i["id"] for i in self.items] pos = max(0, min(len(ids) - 1, ids.index(self.value) + (1 if direction == "right" else -1))) if self.value in ids else 0 self.pick(ids[pos]) return True return False def build(self, ui, width, selected): m = grr_margin(width) page = ui.st("ink", "bg") each = max(6, (width - 2 * m) // max(1, len(self.items))) spans, x = [TuiSpan(" " * m, page)], m for item in self.items: on = item["id"] == self.value spans.append(TuiSpan(grr_clip(item["label"], each - 2).center(each), ui.st("accent-ink" if on else "ink", "accent" if on else "card", bold=on))) self.hit(1, x, x + each, (lambda i=item["id"]: self.pick(i))) x += each edge_up = [TuiSpan(" " * m, page)] + [TuiSpan("▄" * each, ui.st("accent" if i["id"] == self.value else "card", "bg")) for i in self.items] edge_dn = [TuiSpan(" " * m, page)] + [TuiSpan("▀" * each, ui.st("accent" if i["id"] == self.value else "card", "bg")) for i in self.items] return [grr_line(edge_up, width, page), grr_line(spans, width, page), grr_line(edge_dn, width, page)] class GrrChipsItem(GrrItem): """A row of chips; each opens a menu or runs an action. Used for mode, tier, sort and filter dropdowns.""" selectable = True def __init__(self, chips): super().__init__() self.chips = list(chips) self.stops = len(self.chips) def nav(self, direction): if direction in ("left", "right"): nxt = max(0, min(len(self.chips) - 1, self.stop + (1 if direction == "right" else -1))) if nxt != self.stop: self.stop = nxt self.touch() return True return False def activate(self): chip = self.chips[min(self.stop, len(self.chips) - 1)] chip["action"]() def build(self, ui, width, selected): m = grr_margin(width) page = ui.st("ink", "bg") spans, x = [TuiSpan(" " * m, page)], m for pos, chip in enumerate(self.chips): label = ((ui.icon(chip["icon"]) + " ") if chip.get("icon") else "") + chip["label"] + (" " + ui.icon("down") if chip.get("caret", True) else "") text = " %s " % label focus = selected and pos == self.stop spans.append(TuiSpan(text, ui.st("ink", "rule-strong" if focus else "card", bold=focus))) spans.append(TuiSpan(" ", page)) self.hit(0, x, x + len(text), chip["action"]) x += len(text) + 1 return [grr_line(spans, width, page)] class GrrDividerItem(GrrItem): def __init__(self, text, accent=False): super().__init__() self.text = text self.accent = accent def build(self, ui, width, selected): label = " %s " % self.text side = max(0, (width - len(label)) // 2) tone = "accent" if self.accent else "muted" spans = [TuiSpan("─" * side if self.accent else " " * side, ui.st("accent", "bg")), TuiSpan(label, ui.st(tone, "bg", bold=self.accent))] spans.append(TuiSpan("─" * (width - side - len(label)) if self.accent else "", ui.st("accent", "bg"))) return [grr_line(spans, width, ui.st("ink", "bg"))] class GrrEmptyItem(GrrItem): def __init__(self, icon, text, sub=""): super().__init__() self.icon = icon self.text = text self.sub = sub def build(self, ui, width, selected): page = ui.st("ink", "bg") out = [[TuiSpan(" " * width, page)] for _ in range(2)] out.append(grr_line([TuiSpan((" %s " % ui.icon(self.icon)).center(width), ui.st("muted", "bg", bold=True))], width, page)) out.append([TuiSpan(" " * width, page)]) for line in grr_wrap(self.text, width - 6): out.append(grr_line([TuiSpan(line.center(width), ui.st("ink", "bg", bold=True))], width, page)) for line in (grr_wrap(self.sub, width - 8) if self.sub else []): out.append(grr_line([TuiSpan(line.center(width), ui.st("muted", "bg"))], width, page)) return out class GrrKvItem(GrrItem): """Key and value rows in one card (usage details, server health).""" def __init__(self, pairs, tone=None): super().__init__() self.pairs = list(pairs) self.tone = tone or {} def build(self, ui, width, selected): inner = GrrCard.inner(width) out = [GrrCard.top(ui, width)] for key, value in self.pairs: value = str(value) left = grr_clip(key, max(4, inner - len(value) - 4)) row = [TuiSpan(" " + left, ui.st("ink", "card")), TuiSpan(" " * max(1, inner - 2 - len(left) - len(value)), ui.st("ink", "card")), TuiSpan(value + " ", ui.st(self.tone.get(key, "muted"), "card"))] out.append(GrrCard.line(ui, width, row)) out.append(GrrCard.bottom(ui, width)) return out class GrrProgressItem(GrrItem): def __init__(self, label, pct, right="", foot="", level=""): super().__init__() self.label = label self.pct = max(0.0, float(pct or 0)) self.right = right self.foot = foot self.level = level def build(self, ui, width, selected): inner = GrrCard.inner(width) base = ui.st("ink", "card") tone = "danger" if self.pct >= 100 else ("accent" if self.pct >= 80 else "info") bar_w = max(4, inner - 4) filled = int(round(min(100.0, self.pct) / 100.0 * bar_w)) head = grr_line([TuiSpan(" " + self.label, ui.st("ink", "card", bold=True))], inner - len(self.right) - 1, base) + [TuiSpan(self.right + " ", ui.st("muted", "card"))] bar = [TuiSpan(" ", base), TuiSpan("█" * filled, ui.st(tone, "card")), TuiSpan("▒" * (bar_w - filled), ui.st("rule-strong", "card")), TuiSpan(" ", base)] rows = [head, bar] if self.foot: rows.append([TuiSpan(" " + self.foot, ui.st("muted", "card"))]) out = [GrrCard.top(ui, width)] for row in rows: out.append(GrrCard.line(ui, width, row)) out.append(GrrCard.bottom(ui, width)) return out class GrrBarsItem(GrrItem): """A small column chart of the last days of usage.""" def __init__(self, days): super().__init__() self.days = list(days) def build(self, ui, width, selected): inner = GrrCard.inner(width) peak = max([d.get("tokens", 0) for d in self.days] + [1]) levels = " ▁▂▃▄▅▆▇█" cell = max(3, min(8, (inner - 2) // max(1, len(self.days)))) top = [TuiSpan(" ", ui.st("ink", "card"))] + [TuiSpan((levels[min(8, max(1, int(round(d.get("tokens", 0) / float(peak) * 8))))] * (cell - 1)) + " ", ui.st("accent", "card")) for d in self.days] low = [TuiSpan(" ", ui.st("ink", "card"))] + [TuiSpan(str(d.get("day", ""))[5:].ljust(cell), ui.st("muted", "card")) for d in self.days] return [GrrCard.top(ui, width), GrrCard.line(ui, width, top), GrrCard.line(ui, width, low), GrrCard.bottom(ui, width)] class GrrTileGridItem(GrrItem): """Tiles in columns (library, themes, shapes): a box with an icon, a name and a sub line. Arrows move between tiles.""" selectable = True def __init__(self, tiles, cols=0, tile_h=4, min_w=22, max_cols=4, on_pick=None): super().__init__() self.tiles = list(tiles) self.cols_wanted = cols self.tile_h = tile_h self.min_w = min_w self.max_cols = max_cols self.on_pick = on_pick self.stops = max(1, len(self.tiles)) self.cols = 1 def nav(self, direction): step = {"left": -1, "right": 1, "up": -self.cols, "down": self.cols}.get(direction) if step is None: return False nxt = self.stop + step if 0 <= nxt < len(self.tiles): self.stop = nxt self.touch() return True return False def activate(self): if self.tiles: tile = self.tiles[min(self.stop, len(self.tiles) - 1)] (tile.get("action") or (lambda: None))() def build(self, ui, width, selected): m = grr_margin(width) gap = 1 cols = self.cols_wanted or max(1, min(self.max_cols, (width - 2 * m + gap) // (self.min_w + gap))) self.cols = cols tw = (width - 2 * m - gap * (cols - 1)) // cols page = ui.st("ink", "bg") out = [] for start in range(0, len(self.tiles), cols): chunk = self.tiles[start:start + cols] lines = [[TuiSpan(" " * m, page)] for _ in range(self.tile_h)] for off, tile in enumerate(chunk): focus = selected and start + off == self.stop on = tile.get("on") bg = "rule-strong" if focus else "card" edge = ui.st("accent" if on else bg, "bg") body = ui.st("ink", bg) art = tile.get("art") shown = art(ui, bg) if callable(art) else [[TuiSpan(ui.icon(tile.get("icon", "file")).center(tw), ui.st("soft", bg, bold=True))]] art_rows = [] for part in shown: pad = max(0, (tw - tui_span_width(part)) // 2) art_rows.append(grr_line([TuiSpan(" " * pad, body)] + list(part), tw, body)) rows = [[TuiSpan("▗" + "▄" * (tw - 2) + "▖", edge)]] + art_rows if tile.get("label"): rows.append(grr_line([TuiSpan(grr_clip(tile["label"], tw - 2).center(tw), ui.st("ink", bg, bold=on))], tw, body)) if tile.get("sub") and self.tile_h >= 6: rows.append(grr_line([TuiSpan(grr_clip(tile["sub"], tw - 2).center(tw), ui.st("muted", bg))], tw, body)) rows.append([TuiSpan("▝" + "▀" * (tw - 2) + "▘", edge)]) for pos in range(self.tile_h): cell = rows[pos] if pos < len(rows) else [TuiSpan(" " * tw, page)] lines[pos].extend(cell) lines[pos].append(TuiSpan(" " * gap, page)) self.hit(len(out) + pos, m + off * (tw + gap), m + off * (tw + gap) + tw, tile.get("action") or (lambda: None)) for line in lines: out.append(grr_line(line, width, page)) return out class GrrPage(TuiPanel): """A scrolling list of items with a keyboard selection. Up and Down move the selection (an item may take Left and Right first), Enter or Space activates, the wheel and PageUp/PageDown scroll, a click activates the item under the pointer or the region it registered. `stick` keeps the view on the newest row while the user is at the bottom.""" def __init__(self, ui, stick=False, auto_select=True, **kwargs): kwargs.setdefault("focusable", True) super().__init__(**kwargs) self.ui = ui self._items = [] self._sel = -1 self._top = 0 self._stick = stick self._follow = True self._auto = auto_select self._starts = [] self._total = 0 self._width = -1 self._dirty = True self._visible = [] self._reveal = False self.on_bottom = None @property def items(self): return self._items def set_items(self, items): keep = self._items[self._sel].ident() if 0 <= self._sel < len(self._items) else None for item in self._items: item.page = None self._items = list(items) for item in self._items: item.page = self self._sel = -1 if keep is not None: for pos, item in enumerate(self._items): if item.selectable and item.ident() == keep: self._sel = pos break if self._sel < 0 and self._auto: self._sel = self._first_selectable() self._dirty = True self.touch() def append(self, item): item.page = self self._items.append(item) self._dirty = True self.touch() def item_changed(self, item): self._dirty = True self.touch() def _first_selectable(self): for pos, item in enumerate(self._items): if item.selectable: return pos return -1 def selectables(self): return [pos for pos, item in enumerate(self._items) if item.selectable] def _layout(self, width): if not self._dirty and width == self._width: return self._width = width self._dirty = False self._starts = [] row = 0 for item in self._items: self._starts.append(row) row += len(item.rows(self.ui, width, False)) self._total = row def total_rows(self): self._layout(self._content_w()) return self._total def scroll_to_end(self): self._follow = True self._top = max(0, self.total_rows() - self._content_h()) self.touch() def at_bottom(self): return self._top >= max(0, self._total - self._content_h()) def scroll_by(self, delta): height = self._content_h() self._layout(self._content_w()) self._top = max(0, min(max(0, self._total - height), self._top + delta)) self._follow = self._top >= max(0, self._total - height) self.touch() if self.on_bottom is not None: self.on_bottom(self._follow) def content_rows(self): width, height = self._content_w(), self._content_h() self._layout(width) ui = self.ui focused = self.app is not None and self.app.focused is self if self._stick and self._follow: self._top = max(0, self._total - height) if self._reveal and 0 <= self._sel < len(self._items): start = self._starts[self._sel] end = start + len(self._items[self._sel].rows(ui, width, False)) if start < self._top: self._top = start elif end > self._top + height: self._top = min(start, end - height) self._reveal = False self._top = max(0, min(self._top, max(0, self._total - height))) rows, visible = [], [] pos = self._first_at(self._top) blank = ui.st("ink", "bg") while pos < len(self._items) and len(rows) < height: item = self._items[pos] sel = focused and pos == self._sel built = item.rows(ui, width, sel) skip = max(0, self._top - self._starts[pos]) if not rows else 0 take = built[skip:skip + (height - len(rows))] visible.append((len(rows), pos, skip, len(take))) rows.extend(take) pos += 1 self._visible = visible while len(rows) < height: rows.append([TuiSpan(" " * width, blank)]) return rows def _first_at(self, row): lo, hi = 0, len(self._starts) while lo < hi: mid = (lo + hi) // 2 if self._starts[mid] + len(self._items[mid].rows(self.ui, self._width, False)) <= row: lo = mid + 1 else: hi = mid return lo def _select(self, pos): if pos != self._sel: self._sel = pos self._reveal = True self.touch() def move(self, delta): if 0 <= self._sel < len(self._items) and self._items[self._sel].nav("down" if delta > 0 else "up"): self._reveal = True self.touch() return True order = self.selectables() if not order: self.scroll_by(delta * 3) return False if self._sel not in order: self._select(order[0] if delta > 0 else order[-1]) return True at = order.index(self._sel) + delta if 0 <= at < len(order): self._select(order[at]) return True return False def focus_last(self): order = self.selectables() if order and self._sel < 0: self._select(order[-1]) def on_key(self, event): key = event.key or "" char = getattr(event, "char", "") or "" if key == "up": event.stop() self.move(-1) elif key == "down": event.stop() self.move(1) elif key in ("left", "right") and 0 <= self._sel < len(self._items): if self._items[self._sel].nav(key): event.stop() self._reveal = True self.touch() elif key in ("enter", "space") or (char == " " and not key.startswith("ctrl")): if 0 <= self._sel < len(self._items): event.stop() self._items[self._sel].activate() elif key == "pageup": event.stop() self.scroll_by(-max(1, self._content_h() - 1)) elif key == "pagedown": event.stop() self.scroll_by(max(1, self._content_h() - 1)) elif key == "home": event.stop() order = self.selectables() if order: self._select(order[0]) self._top = 0 self._follow = False self.touch() elif key == "end": event.stop() order = self.selectables() if order: self._select(order[-1]) self.scroll_to_end() def on_scroll(self, event): event.stop() self.scroll_by(-3 if event.direction == "up" else 3) def on_click(self, event): if not event.pressed: return event.stop() local_y = event.y - self.ay local_x = event.x - self.ax for start, pos, skip, count in self._visible: if start <= local_y < start + count: item = self._items[pos] row = skip + local_y - start fn = item.hit_at(self.ui, self._width, row, local_x) if fn is not None: if item.selectable: self._sel = pos fn() return if item.selectable: self._sel = pos self._reveal = False self.touch() item.activate() return # --------------------------------------------------------------------------- # Part C: bars, overlays and the shell (boot, login, layout, routes, events). # --------------------------------------------------------------------------- GRR_KEYS = ( ("Esc", "back, or close the open dialog or menu"), ("Up Down", "move the selection"), ("Left Right", "move inside tabs, chips, tiles and card buttons"), ("Enter, Space", "open the selected row, press the selected button, flip the selected switch"), ("PageUp PageDown", "scroll the page or the conversation"), ("Home End", "first or last row, End jumps to the newest message"), ("Tab, Shift+Tab", "next or previous area"), ("Ctrl+N", "new chat"), ("Ctrl+F", "search"), ("Ctrl+G", "settings"), ("Ctrl+O, F10", "the More menu of the screen"), ("Ctrl+P", "the side pane of a bot chat"), ("Ctrl+]", "leave the terminal of a bot computer"), ("F1", "key help"), ("Ctrl+Q", "quit"), ) class GrrBar(TuiPanel): """One row of clickable parts placed left to right. A part is a dict: text or spans, fn, fg, bg, bold, fill (takes the leftover width) and align ("center" for a title). Page headers and the bottom bar use it.""" def __init__(self, ui, **kwargs): kwargs.setdefault("h", 1) super().__init__(**kwargs) self.ui = ui self._parts = [] self._bg = "bg" self._regions = [] def set(self, parts, bg="bg"): self._parts = list(parts) self._bg = bg self.touch() def content_rows(self): width = self._content_w() ui = self.ui base = ui.st("ink", self._bg) self._regions = [] sized = [] for part in self._parts: spans = part.get("spans") if spans is None and not part.get("fill"): spans = [TuiSpan(part.get("text", ""), ui.st(part.get("fg", "ink"), part.get("bg", self._bg), bold=part.get("bold", False)))] sized.append((part, spans, tui_span_width(spans) if spans is not None else 0)) fixed = sum(w + 1 for part, spans, w in sized if not part.get("fill")) fills = [part for part, spans, w in sized if part.get("fill")] room = max(0, width - 1 - fixed - (1 if fills else 0)) each = room // len(fills) if fills else 0 row, x = [TuiSpan(" ", base)], 1 for part, spans, w in sized: if part.get("fill"): style = ui.st(part.get("fg", "ink"), part.get("bg", self._bg), bold=part.get("bold", False)) text = grr_clip(part.get("text", ""), each) spans = [TuiSpan(text.center(each) if part.get("align") == "center" else text.ljust(each), style)] w = each row.extend(spans) row.append(TuiSpan(" ", base)) if part.get("fn"): self._regions.append((x, x + w, part["fn"])) x += w + 1 return [grr_line(row, width, base)] def last_fn(self): """The action of the right-most clickable part (the More menu).""" return self._regions[-1][2] if self._regions else None def on_click(self, event): if not event.pressed: return event.stop() x = event.x - self.ax for x0, x1, fn in self._regions: if x0 <= x < x1: fn() return class GrrTuiApp(TuiApp): """The stock app plus two hooks the grr scene needs: where the last click landed (menus open there) and a click outside a modal popup, which closes it.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.last_click = (0, 0, 0.0) def _dispatch_click(self, event): if event.pressed: self.last_click = (event.x, event.y, time.time()) modal = self.top_modal() if modal is not None and event.pressed: hit = self.hitmap.at(event.x, event.y) if hit is None or self._modal_blocks(hit): outside = getattr(modal, "on_outside", None) if callable(outside): outside(event) return super()._dispatch_click(event) class GrrSecretBox(TuiTextBox): """A one-line box that shows bullets instead of what is typed.""" def content_rows(self): width, height = self._content_w(), self._content_h() self._clamp() left = max(0, self.cx - width + 1) focused = self.app is not None and self.app.focused is self and self.enabled hint = self.placeholder if (self.placeholder and not self.value) else "" rows = [] for pos in range(height): raw = self.lines[pos] if pos < len(self.lines) else "" line = ("•" * len(raw))[left:left + width] style = self.style if hint and pos == 0: line, style = hint, self.style.merged(TuiStyle(fg="role:muted", dim=True)) line = line.ljust(width) if focused and pos == self.cy: col = self.cx - left rows.append([TuiSpan(line[:col], style), TuiSpan(line[col:col + 1] or " ", style.merged(TuiStyle(reverse=True))), TuiSpan(line[col + 1:], style)]) else: rows.append([TuiSpan(line[:width], style)]) return rows class GrrMenu(TuiPanel): """A popup list of actions near the click that opened it. Up and Down move, Enter runs, Esc or a click outside closes.""" def __init__(self, shell, items, anchor=None, align_left=False): ui = shell.ui self.shell = shell self.entries = list(items) labels = [e["label"] for e in self.entries if e != "-"] icons = any(e != "-" and e.get("icon") for e in self.entries) width = min(shell.app.cols - 2, max([len(s) for s in labels] + [10]) + 6 + (3 if icons else 0)) height = min(GRR_MENU_MAX_H, len(self.entries)) cols, rows = shell.app.cols, shell.app.rows if anchor is None: x, y = max(0, (cols - width) // 2), max(0, (rows - height) // 3) else: ax, ay = anchor x = ax if align_left else ax - width + 1 x = max(1, min(x, cols - width - 1)) y = ay + 1 if y + height > rows - 1: y = max(0, ay - height) super().__init__(layer="dialog", modal=True, focusable=True, x=x, y=y, w=width, h=height, style=ui.st("ink", "card")) self.ui = ui self.sel = next((i for i, e in enumerate(self.entries) if e != "-"), 0) self.top = 0 self.icons = icons def content_rows(self): width, height = self._content_w(), self._content_h() ui = self.ui if self.sel < self.top: self.top = self.sel elif self.sel >= self.top + height: self.top = self.sel - height + 1 rows = [] for pos in range(height): index = self.top + pos entry = self.entries[index] if index < len(self.entries) else None if entry is None: rows.append([TuiSpan(" " * width, ui.st("ink", "card"))]) elif entry == "-": rows.append([TuiSpan("─" * width, ui.st("rule-strong", "card"))]) else: on = index == self.sel bg = "rule-strong" if on else "card" tone = "danger" if entry.get("danger") else "ink" lead = (ui.icon(entry["icon"]) + " ") if entry.get("icon") else (" " if self.icons else "") mark = ui.icon("check") if entry.get("checked") else " " text = (" " + lead + entry["label"]).ljust(width - 2)[:width - 2] + mark + " " rows.append([TuiSpan(text, ui.st(tone, bg, bold=on))]) return rows def run(self, index): entry = self.entries[index] if entry == "-": return self.shell.close_menu() action = entry.get("action") if callable(action): action() def step(self, delta): pos = self.sel while True: pos += delta if pos < 0 or pos >= len(self.entries): return if self.entries[pos] != "-": self.sel = pos self.touch() return def on_key(self, event): key = event.key or "" event.stop() if key == "escape": self.shell.close_menu() elif key == "up": self.step(-1) elif key == "down": self.step(1) elif key == "home": self.sel = next((i for i, e in enumerate(self.entries) if e != "-"), 0) self.touch() elif key == "end": self.sel = max(i for i, e in enumerate(self.entries) if e != "-") self.touch() elif key in ("enter", "space"): self.run(self.sel) def on_click(self, event): if not event.pressed: return event.stop() index = self.top + (event.y - self.ay) if 0 <= index < len(self.entries): self.run(index) def on_outside(self, event): self.shell.close_menu() def on_scroll(self, event): event.stop() self.step(-1 if event.direction == "up" else 1) class GrrToast(TuiRich): """A short message over the screen. Clicking a banner runs its action.""" def __init__(self, rows, w, y, click=None, **kwargs): super().__init__(rows, layer="toast", x="center", y=y, w=w, h=len(rows) if rows and isinstance(rows[0], list) else 1, **kwargs) self.click = click def on_click(self, event): if event.pressed and self.click is not None: event.stop() self.click() class GrrWelcome(TuiPanel): """The empty main pane of the two-pane layout: the mark, the name and the tagline, centered.""" def __init__(self, shell): super().__init__(w="1fr", h="100%", style=shell.ui.st("ink", "bg")) self.shell = shell def content_rows(self): shell = self.shell ui, width, height = shell.ui, self._content_w(), self._content_h() art = GrrAvatar.rows(ui, {"shape": "blob", "color": ui.palette.c("accent")}, "bg", hero=True) lines = [grr_line([TuiSpan(" " * ((width - tui_span_width(r)) // 2), ui.st())] + r, width, ui.st()) for r in art] lines.append([TuiSpan(" " * width, ui.st())]) lines.append([TuiSpan("grr".center(width), ui.st("ink", "bg", bold=True))]) for line in grr_wrap(shell.t("app.tagline"), max(10, min(width - 4, 46))): lines.append([TuiSpan(line.center(width), ui.st("muted", "bg"))]) top = max(0, (height - len(lines)) // 2) blank = [TuiSpan(" " * width, ui.st())] rows = [blank] * top + lines return (rows + [blank] * height)[:height] class GrrScreen: """The standard screen: header bar, scrolling page, optional foot page. `right` is a list of bar parts, `back` a route (False for none) and `close` draws a close mark instead of a back arrow.""" def __init__(self, shell, title="", back="/", close=False, right=(), on_back=None, foot=None, stick=False, titleless=False, auto_select=True): ui = shell.ui self.shell = shell self.title = title self.back = back self.close = close self.on_back = on_back self.right = list(right) self.titleless = titleless self.center = None self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.page = GrrPage(ui, stick=stick, auto_select=auto_select, style=ui.st("ink", "bg"), h="1fr") self.foot_page = None self.root.add(self.bar, self.page) if foot is not None: self.foot_page = GrrPage(ui, style=ui.st("ink", "bg"), h=3) self.foot_page.set_items(foot) self.root.add(self.foot_page) self.refresh() def go_back(self): if self.on_back is not None: self.on_back() else: self.shell.router.back(self.back or "/") def refresh(self): ui = self.shell.ui parts = [] if self.back is not False or self.close: parts.append({"text": " %s " % ui.icon("close" if self.close else "back"), "fn": self.go_back, "bg": "card", "bold": True}) if self.center is not None: parts.append(self.center) else: parts.append({"text": self.title, "fill": True, "align": "center", "bold": True}) parts.extend(self.right) self.bar.set(parts) def set_title(self, title): self.title = title self.refresh() def chip(self, icon, fn): return {"text": " %s " % self.shell.ui.icon(icon), "fn": fn, "bg": "card", "bold": True} def items(self, items): self.page.set_items(items) class GrrView: """A screen of the app. `open()` builds panels and calls `show`. Server calls go through `get`, `post` ... which drop their answer when the user has already left the screen.""" fallback = "/" def __init__(self, shell, params=None, query=None): self.g = shell self.ui = shell.ui self.params = params or {} self.query = query or {} self.closed = False self.screen = None self.subs = [] self.timers = [] def open(self): pass def destroy(self): self.closed = True for off in self.subs: off() self.subs = [] for handle in self.timers: self.g.app.cancel_timer(handle) self.timers = [] def sub(self, emitter, kind, fn): self.subs.append(emitter.on(kind, fn)) def every(self, seconds, fn): handle = self.g.app.every(seconds, fn) self.timers.append(handle) return handle def show(self, panel, focus=None): self.g.show_main(panel, focus) def guard(self, fn): def run(*args): if not self.closed: fn(*args) return run def call(self, method, path, body=None, then=None, fail=None): self.g.call(method, path, body, self.guard(then) if then else None, fail, view=self) def get(self, path, then, fail=None): self.call("GET", path, None, then, fail) def batch(self, calls, then, fail=None): self.g.batch(calls, self.guard(then), fail, view=self) def on_escape(self): if self.screen is not None: self.screen.go_back() return True return False def hotkey(self, key): """Keys the shell offers the open screen before it uses them itself. Returns True when handled.""" return False def header_action(self): bar = getattr(self.screen, "bar", None) or getattr(self, "bar", None) fn = bar.last_fn() if bar is not None else None if fn is None: return False fn() return True def standard(self, title="", **kwargs): self.screen = GrrScreen(self.g, title, **kwargs) self.show(self.screen.root, self.screen.page) return self.screen def fail_page(self, err, back="/"): screen = self.screen or self.standard("", back=back) screen.items([GrrEmptyItem("warn", err.text() if isinstance(err, GrrError) else str(err))]) class GrrShell: """The grr scene: boots against the server, owns the session, the store, the router and the two panes, and turns server events into UI changes.""" def __init__(self, app, session=None): self.app = app self.session = session or GrrSession() self.strings = GrrStrings() self.client = GrrClient(self.session.url, self.session.token, self.strings) self.client.on_unauthorized = lambda: self.app.run_on_loop(self.expired) self.spec = {} self.options = {} self.me = None self.settings = {"theme": "system", "variant": "black", "language": "system", "tier": "auto"} self.palette = GrrPalette() self.ui = GrrUi(self.palette, self.strings) self.store = GrrStore(app, self.client) self.events = GrrEmitter() self.router = GrrRouter(self.on_route) self.view = None self.home = None self.stream = None self.link_up = False self.menu_panel = None self.dialogs = [] self.toasts = [] self.signed_in = False self.drafts = {} self.pane_state = {} self.root = TuiCols(style=self.ui.st("ink", "bg"), id="grr") self.side = TuiRows(w=GRR_SIDE_W, h="100%", style=self.ui.st("ink", "bg"), id="side") self.main = TuiRows(w="1fr", h="100%", style=self.ui.st("ink", "bg"), id="main") self.root.add(self.side, self.main) self.side.visible = False self.root.on_key = self.on_root_key self.root.on_resize = lambda event: self.layout_sync() app.grr = self self.register_routes() # ---- routes def register_routes(self): r = self.router r.add("/", None) for pattern, name in GRR_ROUTES: r.add(pattern, name) def factory(self, name): return globals()[name] # ---- server calls def call(self, method, path, body=None, then=None, fail=None, view=None): def work(): return self.client.request(method, path, body) def landed(result, error): if view is not None and view.closed: return if error is not None: if fail is not None: fail(error) else: self.error(error) elif then is not None: then(result) self.app.offload(work, landed) def batch(self, calls, then, fail=None, view=None): def work(): return [self.client.request(m, p, b) for (m, p, b) in calls] def landed(result, error): if view is not None and view.closed: return if error is not None: (fail or self.error)(error) else: then(result) self.app.offload(work, landed) def error(self, err): if isinstance(err, GrrError): self.toast(err.text(), "bad") else: self.toast(str(err), "bad") def t(self, key, **vars): return self.strings.t(key, **vars) # ---- boot def boot(self): self.show_main(self.message_panel("grr ...")) def work(): return self.client.get("/api/spec"), self.client.get("/locales.json"), self.client.get("/api/auth/options") def landed(result, error): if error is not None: self.show_offline(error) return self.spec, locales, self.options = result self.strings.table = locales self.settings["language"] = "system" self.strings.use("system", self.spec.get("languages", [])) self.apply_settings() if self.session.token: self.enter() else: self.show_login() self.app.offload(work, landed) def message_panel(self, text): ui = self.ui return TuiRich([TuiSpan(text.center(60), ui.st("muted", "bg"))], h="100%", valign="middle", style=ui.st("muted", "bg")) def show_offline(self, err): ui = self.ui screen = GrrScreen(self, "grr", back=False) reason = (err.vars or {}).get("reason", "") if isinstance(err, GrrError) else str(err) screen.items([GrrEmptyItem("warn", "grr cannot reach its server", "%s\n%s" % (self.session.url, reason)), GrrNote("Start it with: grr-serve (or python3 -m grr serve), or point TAI at another one with GRR_URL.", center=True), GrrButtonItem("Try again", self.boot, inline=True), GrrButtonItem("Change address", self.change_url, "secondary", inline=True)]) self.layout_single(screen.root) self.app.set_focus(screen.page) def change_url(self): def done(value, values): if value == "ok" and values[0].strip(): self.session.url = values[0].strip().rstrip("/") self.session.save() self.client.base = self.session.url self.boot() self.dialog(title="grr server", text="Address of the grr server", fields=[{"value": self.session.url, "label": ""}], actions=[{"label": self.t("ui.cancel"), "value": "cancel"}, {"label": self.t("ui.save"), "value": "ok", "primary": True}], done=done) def show_login(self, error=""): self.signed_in = False self.stop_stream() view = GrrLoginView(self, {}, {"error": error}) self.set_view(view) view.open() def apply_settings(self): s = self.settings self.strings.use(s.get("language", "system"), self.spec.get("languages", [])) self.palette = GrrPalette(self.spec, s.get("theme", "system"), s.get("variant", "black")) self.palette.apply() self.ui = GrrUi(self.palette, self.strings, self.spec) self.restyle() def restyle(self): """Apply a new palette: containers get the new background, the drawer and the open screen are built again so every item reads the new ui.""" ui = self.ui for node in (self.root, self.side, self.main): node.style = ui.st("ink", "bg") for node in self.root.walk(): if hasattr(node, "ui"): node.ui = ui if self.signed_in and self.home is not None: self.home.destroy() for child in list(self.side.children): self.side.remove(child) self.home = GrrHome(self) self.side.add(self.home.root) self.router.fire(self.router.current) self.app.dirty_paint() def enter(self): def landed(result, error): if error is not None: if isinstance(error, GrrError) and error.status == 401: self.session.drop(self.session.token) self.client.token = self.session.token self.show_login() else: self.show_offline(error) return self.me = result self.settings.update(result.get("settings") or {}) self.apply_settings() self.store.refresh(self.entered) self.app.offload(lambda: self.client.get("/api/me"), landed) def entered(self, error): if error is not None: self.show_offline(error) return self.signed_in = True self.sync_timezone() self.start_stream() self.home = GrrHome(self) self.layout_dual() self.router.go("/") def sync_timezone(self): try: zone = os.environ.get("TZ") or "" if not zone and os.path.exists("/etc/timezone"): with open("/etc/timezone", "r", encoding="utf-8") as fh: zone = fh.read().strip() except OSError: zone = "" if zone and self.settings.get("auto_tz") and self.settings.get("timezone") != zone: self.call("PATCH", "/api/me/settings", {"timezone": zone}, then=lambda me: self.merge_me(me)) def merge_me(self, me): self.me = me self.settings.update(me.get("settings") or {}) def save_settings(self, patch, then=None): def landed(me): self.merge_me(me) self.apply_settings() if then is not None: then(me) self.call("PATCH", "/api/me/settings", patch, then=landed) def expired(self): if self.signed_in: self.stop_stream() self.session.drop(self.client.token) self.client.token = self.session.token self.show_login() def sign_out(self): self.call("POST", "/api/auth/logout", {}, then=lambda _r: self.after_sign_out(), fail=lambda _e: self.after_sign_out()) def after_sign_out(self): self.stop_stream() self.session.drop(self.client.token) self.client.token = self.session.token self.me = None self.signed_in = False if self.client.token: self.enter() else: self.show_login() def switch_account(self, token): self.stop_stream() self.session.switch(token) self.client.token = token self.signed_in = False self.enter() # ---- live events def start_stream(self): self.stop_stream() path = "/api/events?token=" + urllib.parse.quote(self.client.token) self.stream = GrrStream(self.client, path, self.on_event, self.app.run_on_loop, on_link=self.on_link) self.stream.start() def stop_stream(self): if self.stream is not None: self.stream.stop() self.stream = None def on_link(self, up): was = self.link_up self.link_up = up if up and not was: self.store.soon() self.events.emit("link", up) PASS = ("message.new", "message.update", "routine.update", "automation.update", "library.update", "usage.update", "builder.update", "files.changed", "port.opened", "port.closed", "preview.ready", "preview.failed", "chat.draft", "connector.update") def on_event(self, name, data): if name in ("bot.update", "bot.delete", "chat.update", "chat.delete"): self.store.soon() self.events.emit(name, data) elif name in GrrShell.PASS: self.events.emit(name, data) elif name == "status": self.store.set_status(data) self.events.emit("status", data) elif name == "toast": self.banner_event(data) def banner_event(self, data): viewing = isinstance(self.view, GrrChatView) and self.view.chat and self.view.chat.get("id") == data.get("chat_id") if viewing: return bot = next((b for b in self.store.bots if b.get("name") == data.get("bot")), None) chat_id = data.get("chat_id") self.banner(bot, data.get("text", ""), (lambda: self.router.go("/chat/" + chat_id)) if chat_id else None) # ---- layout def set_view(self, view): if self.view is not None: self.view.destroy() self.view = view def show_main(self, panel, focus=None): for child in list(self.main.children): self.main.remove(child) self.main.add(panel) self.layout_sync() if focus is not None: self.app.set_focus(focus) else: self.app.ensure_focus() def layout_single(self, panel): self.signed_in = False self.side.visible = False self.show_main(panel) def layout_dual(self): self.layout_sync() def on_route(self, route, params, query, raw): self.close_layers() path, _q = GrrRouter.parse(raw) if not self.signed_in: return self.set_view(None) if route is None or route.factory is None: self.view = None self.show_main(self.welcome_panel()) else: view = self.factory(route.factory)(self, params, query) self.view = view view.open() if self.home is not None: self.home.select(path) self.layout_sync() def welcome_panel(self): return GrrWelcome(self) def layout_sync(self): cols = self.app.cols wide = cols >= GRR_SIDE_MIN_COLS at_home = self.router.current == "/" if not self.signed_in or self.home is None: self.side.visible = False self.main.visible = True return if self.side.children == []: self.side.add(self.home.root) if wide: self.side.visible = True self.side.w = GRR_SIDE_W self.main.visible = True else: self.side.visible = at_home self.side.w = "100%" self.main.visible = not at_home self.app.ensure_focus() def on_root_key(self, event): key = event.key or "" if key == "ctrl+q": event.stop() self.app.quit() elif key == "escape" and self.signed_in: event.stop() if self.view is not None and self.view.on_escape(): return if self.router.current != "/": self.router.go("/") elif key == "f1" and self.signed_in: event.stop() self.help() elif key in ("f10", "ctrl+o") and self.signed_in and self.view is not None: if self.view.header_action(): event.stop() elif self.signed_in and self.view is not None and self.view.hotkey(key): event.stop() elif key == "ctrl+n" and self.signed_in: event.stop() self.router.go("/chat/new") elif key == "ctrl+f" and self.signed_in: event.stop() self.router.go("/search") elif key == "ctrl+g" and self.signed_in: event.stop() self.router.go("/settings") elif key == "f6" and self.signed_in: event.stop() self.cycle_panes() def cycle_panes(self): pages = [n for n in self.root.walk() if isinstance(n, (GrrPage, TuiTextBox)) and n.is_shown()] if pages: self.app.focus_next() def help(self): text = "\n".join("%-16s %s" % row for row in GRR_KEYS) self.dialog(title=self.t("settings.help"), text=text, w=min(self.app.cols - 2, 96), actions=[{"label": self.t("ui.ok"), "value": True, "primary": True}]) # ---- overlays def close_layers(self): self.close_menu() for dlg in list(self.dialogs): self.close_dialog(dlg, None, quiet=True) def menu(self, items, anchor=None, align_left=False): self.close_menu() if anchor is None: x, y, at = self.app.last_click if time.time() - at < 1.0: anchor = (x, y) panel = GrrMenu(self, items, anchor, align_left) self.menu_panel = panel self.root.add(panel) self.app.remember_focus() self.app.set_focus(panel) def close_menu(self): panel = self.menu_panel if panel is not None: self.menu_panel = None self.app.close_panel(panel) def toast(self, text, kind=""): ui = self.ui text = " %s " % grr_clip(text, self.app.cols - 8) style = ui.st("ink" if kind != "bad" else "danger", "rule-strong", bold=True) panel = GrrToast([TuiSpan(text, style)], len(text), 1 + len(self.toasts)) self.toasts.append(panel) self.root.add(panel) if len(self.toasts) > GRR_TOAST_MAX: self.drop_toast(self.toasts[0]) self.app.after(GRR_TOAST_SECS, lambda: self.drop_toast(panel)) def drop_toast(self, panel): if panel in self.toasts: self.toasts.remove(panel) if panel.parent is not None: panel.parent.remove(panel) for pos, other in enumerate(self.toasts): other.y = 1 + pos def banner(self, bot, text, click=None): ui = self.ui name = (bot or {}).get("name", "grr") width = min(self.app.cols - 4, 60) chip = GrrAvatar.chip(ui, bot) if bot else [TuiSpan(" ♪ ", ui.st("accent", "card"))] rows = [chip + [TuiSpan(" " + grr_clip(name, width - 8), ui.st("ink", "card", bold=True))], [TuiSpan(" " + grr_clip(text, width - 2), ui.st("muted", "card"))]] rows = [grr_line(r, width, ui.st("ink", "card")) for r in rows] panel = GrrToast(rows, width, 1 + len(self.toasts) * 2, click=(lambda: (self.drop_toast(panel), click and click()))) self.toasts.append(panel) self.root.add(panel) self.app.after(GRR_BANNER_SECS, lambda: self.drop_toast(panel)) def dialog(self, title="", text="", fields=(), actions=None, done=None, w=None, body=None, rows=None, page_h=0): """Open a modal dialog. fields: dicts with value, label, multiline, rows, max, placeholder, secret. actions: dicts with label, value, primary, danger. done(value, texts) runs once with the pressed action's value (None for Esc). `rows` puts a page of items in the dialog (pickers).""" ui = self.ui width = min(self.app.cols - 2, w or 64) dlg = TuiDialog(w=width, layer="dialog", modal=True, border=True, rounded=True, padding=(0, 2), gap=0, style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), title_style=ui.st("ink", "bg", bold=True)) dlg.grr_done = done dlg.grr_fields = [] dlg.grr_actions = actions or [{"label": self.t("ui.ok"), "value": True, "primary": True}] dlg.grr_open = True if title: dlg.add(TuiText(title, wrap=True, style=ui.st("ink", "card", bold=True))) dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) if text: dlg.add(TuiText(text, wrap=True, style=ui.st("soft", "card"))) dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) primary = next((a for a in dlg.grr_actions if a.get("primary")), dlg.grr_actions[-1]) for spec in fields: if spec.get("label"): dlg.add(TuiText(spec["label"], style=ui.st("muted", "card"))) kind = GrrSecretBox if spec.get("secret") else TuiTextBox box = kind(spec.get("value", ""), h=spec.get("rows", 1), border=True, rounded=True, padding=(0, 1), max_chars=spec.get("max", 0), placeholder=spec.get("placeholder", ""), style=ui.st("ink", "bg"), border_style=ui.st("rule-strong", "card"), focus_border_style=ui.st("accent", "card")) if spec.get("rows", 1) <= 1: box.send_on_enter = True box.on_send = lambda _v, d=dlg, p=primary: self.dialog_pick(d, p) dlg.grr_fields.append(box) dlg.add(box) if rows is not None: page = GrrPage(ui, style=ui.st("ink", "card"), h=page_h or min(len(rows) * 2 + 2, max(4, self.app.rows - 12))) dlg.grr_page = page dlg.add(page) page.set_items(rows) if body is not None: dlg.add(body) if title or text or fields or rows is not None or body is not None: dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) bar = TuiCols(gap=2, style=ui.st("ink", "card"), align="right") for act in dlg.grr_actions: tone = "accent" if act.get("primary") else ("danger" if act.get("danger") else "ink") bg = "accent" if act.get("primary") and not act.get("danger") else "rule-strong" fg = "accent-ink" if act.get("primary") and not act.get("danger") else tone btn = TuiButton(act["label"], brackets=False, border=False, style=ui.st(fg, bg, bold=True), on_press=lambda _e, d=dlg, a=act: self.dialog_pick(d, a)) btn.label = " %s " % act["label"] bar.add(btn) dlg.add(bar) dlg.on_close = lambda _event, d=dlg: self.close_dialog(d, None) dlg.on_key_extra = None orig = dlg.on_key def on_key(event, d=dlg, original=orig, p=primary): if event.key == "ctrl+s": event.stop() self.dialog_pick(d, p) return original(event) dlg.on_key = on_key self.dialogs.append(dlg) self.root.add(dlg) return dlg def dialog_pick(self, dlg, action): self.close_dialog(dlg, action.get("value")) def close_dialog(self, dlg, value, quiet=False): if not getattr(dlg, "grr_open", False): return dlg.grr_open = False texts = [box.value for box in dlg.grr_fields] if dlg in self.dialogs: self.dialogs.remove(dlg) if dlg.parent is not None: self.app.close_panel(dlg) done = dlg.grr_done if done is not None and not quiet: done(value, texts) def confirm(self, title, text, yes, then, danger=False): self.dialog(title=title, text=text, actions=[{"label": self.t("ui.cancel"), "value": False}, {"label": yes, "value": True, "primary": True, "danger": danger}], done=lambda value, _t: then() if value else None) def alert(self, title, text): self.dialog(title=title, text=text) def edit(self, title, value, then, multiline=False, rows=6, max_chars=0, text=""): def done(action, texts): if action == "save": then(texts[0]) self.dialog(title=title, text=text, w=72 if multiline else 56, fields=[{"value": value, "rows": rows if multiline else 1, "multiline": multiline, "max": max_chars}], actions=[{"label": self.t("ui.cancel"), "value": "cancel"}, {"label": self.t("ui.save"), "value": "save", "primary": True}], done=done) def pick(self, title, rows, text=""): """A picker: rows are GrrRowItem with their own actions; the dialog closes before an action runs.""" holder = {} for row in rows: original = row.action def wrapped(row=row, original=original): self.close_dialog(holder["dlg"], None, quiet=True) if original: original() row.action = wrapped row.selectable = True holder["dlg"] = self.dialog(title=title, text=text, rows=grr_group(rows), actions=[{"label": self.t("ui.close"), "value": None}]) return holder["dlg"] # --------------------------------------------------------------------------- # Part D: the sidebar, login, and the screens of the app (bots, profile, # automations, library, projects, search, settings, templates). # --------------------------------------------------------------------------- GRR_LANG_NAMES = {"zh-CN": "简体中文", "zh-TW": "繁體中文", "en": "English", "hi": "हिन्दी", "pl": "Polski", "es": "Español", "pt": "Português", "ja": "日本語", "fr": "Français", "ko": "한국어", "de": "Deutsch", "nl": "Nederlands"} GRR_TEMPLATE_ASK = "Create a template of yourself that I can share with somebody else." def grr_bot_avatar(bot): return lambda ui, bg: GrrAvatar.rows(ui, bot, bg, small=True) def grr_user_avatar(account): def art(ui, bg): letter = ((account or {}).get("username") or "?")[:1].upper() style = ui.st("ink", "rule-strong", bold=True) return [[TuiSpan((" %s " % letter), style)], [TuiSpan(" ", style)]] return art def grr_group_avatar(bots): def art(ui, bg): rows = [[], []] for bot in bots[:3]: for pos, row in enumerate(GrrAvatar.chip_rows(ui, bot, bg)): rows[pos].extend(row) width = tui_span_width(rows[0]) for pos in range(2): rows[pos] = grr_line(rows[pos], 5, ui.st("ink", bg)) return rows return art class GrrHome: """The drawer: account, shortcuts, bots, pinned chats, bottom bar.""" def __init__(self, shell): self.g = shell ui = shell.ui self.root = TuiRows(w="100%", h="100%", style=ui.st("ink", "bg")) self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.root.add(self.page, self.bar) self.path = "" self.pinned_open = True self.offs = [shell.store.on("changed", lambda _d: self.paint()), shell.store.on("status", lambda _d: self.paint())] self.paint() def destroy(self): for off in self.offs: off() def select(self, path): self.path = path self.paint() def latest(self): g = self.g chats = sorted([c for c in g.store.chats if c.get("kind") != "private"], key=lambda c: -c.get("updated", 0)) g.router.go("/chat/" + chats[0]["id"] if chats else "/chat/new") def paint(self): g = self.g ui, t, store = g.ui, g.t, g.store go = g.router.go bots = sorted(store.bots, key=lambda b: -(((b.get("chat") or {}).get("updated")) or b.get("updated", 0))) pinned = [c for c in store.chats if c.get("pinned") and c.get("kind") in ("general", "group")] groups = [c for c in store.chats if c.get("kind") == "group" and not c.get("pinned")] me = g.me or {} items = [GrrConvItem(grr_user_avatar(me), me.get("username", ""), "", "grr " + str(me.get("version", "")), action=self.latest, key="me"), GrrSpacer(1)] items += grr_group([GrrRowItem(t("home.automations"), icon="automation", action=lambda: go("/automations"), key="n-auto"), GrrRowItem(t("home.library"), icon="box", action=lambda: go("/library"), key="n-lib"), GrrRowItem(t("home.projects"), icon="folder", action=lambda: go("/projects"), key="n-proj")]) items += [GrrSpacer(1), GrrSectionHead(t("home.bots"), "plus", self.plus_menu)] for bot in bots[:3]: chat = bot.get("chat") or {} working = store.is_working(bot["id"]) items.append(GrrConvItem(grr_bot_avatar(bot), bot["name"], ui.fmt.list_time(chat.get("updated")), (working.get("label") or t("chat.working")) if working else (chat.get("preview") or bot.get("description", "")), dot=bool(chat.get("unread")), current=self.path == "/chat/" + chat.get("id", "-"), busy=bool(working), action=(lambda c=chat: go("/chat/" + c["id"])), key="bot-" + bot["id"])) for chat in groups[:2]: members = [store.bot(i) for i in chat.get("bot_ids", [])] items.append(GrrConvItem(grr_group_avatar([m for m in members if m]), chat.get("title", ""), ui.fmt.list_time(chat.get("updated")), chat.get("preview") or t("bots.group"), dot=bool(chat.get("unread")), current=self.path == "/chat/" + chat["id"], action=(lambda c=chat: go("/chat/" + c["id"])), key="grp-" + chat["id"])) if len(bots) + len(groups) > 3: items.append(GrrRowItem(t("home.show_more"), link=True, action=lambda: go("/bots"), key="more", pos="only")) if not bots: items.append(GrrNote(t("home.no_bots"))) if pinned: items += [GrrSpacer(1), GrrSectionHead(t("home.pinned"), "up" if self.pinned_open else "down", self.toggle_pinned)] if self.pinned_open: for chat in pinned: items.append(GrrConvItem(lambda ui_, bg_: [[TuiSpan(" ", ui_.st("ink", bg_))], [TuiSpan(" ", ui_.st("ink", bg_))]], chat.get("title") or t("chat.untitled"), "", ui.fmt.short_date(chat.get("updated", 0)), current=self.path == "/chat/" + chat["id"], action=(lambda c=chat: go("/chat/" + c["id"])), menu=(lambda c=chat: self.chat_menu(c)), key="pin-" + chat["id"])) self.page.set_items(items) self.bar.set([{"text": " %s %s" % (ui.icon("search"), t("home.search")), "fn": lambda: go("/search"), "bg": "card", "fill": True}, {"text": " %s " % ui.icon("gear"), "fn": lambda: go("/settings"), "bg": "card"}, {"text": " %s " % ui.icon("compose"), "fn": lambda: go("/chat/new"), "bg": "card"}]) def toggle_pinned(self): self.pinned_open = not self.pinned_open self.paint() def plus_menu(self): g, t = self.g, self.g.t g.menu([{"icon": "plus", "label": t("bots.new_bot"), "action": lambda: GrrBots.new_bot(g)}, {"icon": "group", "label": t("bots.new_group"), "action": lambda: g.router.go("/bots?group=1")}]) def chat_menu(self, chat): g, t = self.g, self.g.t def rename(): g.edit(t("chat.rename"), chat.get("title", ""), lambda v: g.call("PATCH", "/api/chats/" + chat["id"], {"title": v})) def delete(): def go(): g.call("DELETE", "/api/chats/" + chat["id"], then=lambda _r: g.router.go("/") if chat["id"] in g.router.current else None) g.confirm(t("chat.delete_title"), t("chat.delete_text"), t("ui.delete"), go, True) g.menu([{"icon": "pin", "label": t("chat.unpin") if chat.get("pinned") else t("chat.pin"), "action": lambda: g.call("PATCH", "/api/chats/" + chat["id"], {"pinned": not chat.get("pinned")})}, {"icon": "edit", "label": t("chat.rename"), "action": rename}, "-", {"icon": "trash", "label": t("ui.delete"), "danger": True, "action": delete}]) class GrrBots: """Actions shared by the bot list, the drawer and the chat menu.""" @staticmethod def new_bot(g): def landed(bot): g.store.refresh(lambda _e: g.router.go("/bot/%s?edit=1" % bot["id"])) g.call("POST", "/api/bots", {"name": g.t("bots.default_name")}, then=landed) @staticmethod def new_group(g): t = g.t bots = g.store.bots if len(bots) < 2: g.toast(t("bots.group_need_two"), "bad") return cap = g.ui.cap("GROUP_MAX_BOTS", 6) picked = [] rows = [] def toggle(bot, on): if on and len(picked) >= cap: row = next(r for r in rows if r.key == bot["id"]) row.set_toggle(False) return if on: picked.append(bot["id"]) elif bot["id"] in picked: picked.remove(bot["id"]) for bot in bots: rows.append(GrrRowItem(bot["name"], lead=(lambda ui, bg, b=bot: GrrAvatar.chip(ui, b, bg)), toggle=False, on_toggle=(lambda on, b=bot: toggle(b, on)), key=bot["id"])) def done(value, texts): if value != "create": return if len(picked) < 2: g.toast(t("bots.group_need_two"), "bad") return g.call("POST", "/api/chats", {"kind": "group", "bot_ids": list(picked), "title": texts[0]}, then=lambda chat: g.store.refresh(lambda _e: g.router.go("/chat/" + chat["id"]))) g.dialog(title=t("bots.new_group"), text=t("bots.group_hint", n=cap), fields=[{"value": "", "placeholder": t("bots.group_name"), "max": 60}], rows=grr_group(rows), actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("bots.group_create"), "value": "create", "primary": True}], done=done) class GrrLoginView(GrrView): """Sign in with a DevPlace username or email and password, or a key.""" def open(self): g, ui, t = self.g, self.ui, self.g.t self.mode = "password" self.busy = False width = max(24, min(g.app.cols - 4, 50)) self.width = width self.root = TuiRows(w="1fr", h="100%", valign="middle", align="center", style=ui.st("ink", "bg")) art = GrrAvatar.rows(ui, {"shape": "blob", "color": ui.palette.c("accent")}, "bg") head = [[TuiSpan(" " * ((width - 7) // 2), ui.st()), *row] for row in art] head += [[TuiSpan(" " * width, ui.st())], [TuiSpan("grr".center(width), ui.st("ink", "bg", bold=True))]] for line in grr_wrap(t("app.tagline"), width - 2): head.append([TuiSpan(line.center(width), ui.st("muted", "bg"))]) self.root.add(TuiRich([grr_line(r, width, ui.st()) for r in head], w=width, style=ui.st("ink", "bg"))) self.fields = TuiRows(w=width, style=ui.st("ink", "bg")) self.root.add(TuiText(" ", h=1, w=width, style=ui.st("ink", "bg")), self.fields) self.err = TuiText("", w=width, wrap=True, style=ui.st("danger", "bg")) self.root.add(self.err) self.submit = TuiButton(t("login.submit"), brackets=False, border=False, w=width, style=ui.st("accent-ink", "accent", bold=True), on_press=lambda _e: self.go(False)) self.root.add(self.submit) if (g.options or {}).get("env_key"): self.root.add(TuiButton(t("login.use_session"), brackets=False, border=False, w=width, style=ui.st("ink", "card", bold=True), on_press=lambda _e: self.go(True))) self.other = TuiButton("", brackets=False, border=False, w=width, style=ui.st("muted", "bg"), on_press=lambda _e: self.swap()) self.root.add(self.other) for line in grr_wrap(t("login.hint"), width - 2)[:6]: self.root.add(TuiText(line, w=width, style=ui.st("faint", "bg"))) if self.query.get("error"): self.err.text = self.query["error"] self.draw() self.show(self.root, self.user) def box(self, placeholder, secret=False): ui = self.ui kind = GrrSecretBox if secret else TuiTextBox box = kind("", h=1, w=self.width - 4, border=True, rounded=True, padding=(0, 1), placeholder=placeholder, style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.go(False)) return box def draw(self): t = self.g.t for child in list(self.fields.children): self.fields.remove(child) self.err.text = "" if self.mode == "key": self.key = self.box(t("login.key_placeholder"), True) self.fields.add(self.key) self.user = self.key self.other.label = t("login.use_password") else: self.user = self.box(t("login.username")) self.passw = self.box(t("login.password"), True) self.fields.add(self.user, self.passw) self.other.label = t("login.use_key") self.other.label = " %s " % self.other.label self.g.app.set_focus(self.user) def swap(self): self.mode = "key" if self.mode == "password" else "password" self.draw() def go(self, use_env): if self.busy: return g = self.g if use_env: body = {"use_env": True} elif self.mode == "key": body = {"api_key": self.key.value} else: body = {"username": self.user.value, "password": self.passw.value} self.busy = True self.err.text = "" def work(): return g.client.request("POST", "/api/auth/login", body, quiet=True) def landed(result, error): self.busy = False if self.closed: return if error is not None: self.err.text = error.text() if isinstance(error, GrrError) else str(error) return g.session.add(result["token"], result["account"]) g.client.token = result["token"] g.enter() g.app.offload(work, landed) def on_escape(self): return True class GrrStripItem(GrrItem): """The bots as big avatars in a row, names below. Left and Right walk along it, Enter opens the chat. Wraps onto more lines on a narrow screen.""" selectable = True def __init__(self, bots, on_pick): super().__init__() self.bots = list(bots) self.on_pick = on_pick self.stops = max(1, len(self.bots)) self.cols = 1 def nav(self, direction): step = {"left": -1, "right": 1, "up": -self.cols, "down": self.cols}.get(direction) if step is None: return False nxt = self.stop + step if 0 <= nxt < len(self.bots): self.stop = nxt self.touch() return True return False def activate(self): if self.bots: self.on_pick(self.bots[min(self.stop, len(self.bots) - 1)]) def build(self, ui, width, selected): m = grr_margin(width) cell = 11 self.cols = max(1, (width - 2 * m) // cell) page = ui.st("ink", "bg") out = [] for start in range(0, len(self.bots), self.cols): lines = [[TuiSpan(" " * m, page)] for _ in range(3)] for off, bot in enumerate(self.bots[start:start + self.cols]): focus = selected and start + off == self.stop bg = "rule-strong" if focus else "bg" art = GrrAvatar.rows(ui, bot, bg) pad = (cell - 7) // 2 for pos in range(2): lines[pos].extend([TuiSpan(" " * pad, ui.st("ink", bg))] + list(art[pos]) + [TuiSpan(" " * (cell - 7 - pad), ui.st("ink", bg))]) lines[2].append(TuiSpan(grr_clip(bot["name"], cell - 1).center(cell), ui.st("ink", bg, bold=focus))) for pos in range(3): self.hit(len(out) + pos, m + off * cell, m + (off + 1) * cell, (lambda b=bot: self.on_pick(b))) for line in lines: out.append(grr_line(line, width, page)) out.append([TuiSpan(" " * width, page)]) return out class GrrBotsView(GrrView): fallback = "/" def open(self): g, t, ui = self.g, self.g.t, self.ui right = [{"text": " %s " % ui.icon("search"), "fn": lambda: g.router.go("/search"), "bg": "card"}, {"text": " %s " % ui.icon("plus"), "fn": self.plus, "bg": "card"}] self.standard(t("bots.title"), back="/", right=right) self.sub(g.store, "changed", lambda _d: self.paint()) self.sub(g.store, "status", lambda _d: self.paint()) self.paint() if self.query.get("group"): g.app.after(0.05, lambda: GrrBots.new_group(g)) def plus(self): g, t = self.g, self.g.t g.menu([{"icon": "plus", "label": t("bots.new_bot"), "action": lambda: GrrBots.new_bot(g)}, {"icon": "group", "label": t("bots.new_group"), "action": lambda: GrrBots.new_group(g)}]) def paint(self): g, ui, t, store = self.g, self.ui, self.g.t, self.g.store rows = [] for bot in store.bots: chat = bot.get("chat") or {} working = store.is_working(bot["id"]) rows.append((chat.get("updated") or 0, GrrConvItem(grr_bot_avatar(bot), bot["name"], ui.fmt.list_time(chat.get("updated")), (working.get("label") or t("chat.working")) if working else (chat.get("preview") or bot.get("description", "")), dot=bool(chat.get("unread")), busy=bool(working), action=(lambda c=chat: g.router.go("/chat/" + c["id"])), key="b" + bot["id"]))) for chat in [c for c in store.chats if c.get("kind") == "group"]: members = [store.bot(i) for i in chat.get("bot_ids", [])] rows.append((chat.get("updated", 0), GrrConvItem(grr_group_avatar([m for m in members if m]), chat.get("title", ""), ui.fmt.list_time(chat.get("updated")), chat.get("preview", ""), dot=bool(chat.get("unread")), tag=t("bots.group"), action=(lambda c=chat: g.router.go("/chat/" + c["id"])), key="g" + chat["id"]))) rows.sort(key=lambda r: -r[0]) items = [r[1] for r in rows] or [GrrEmptyItem("hash", t("bots.empty"), t("bots.empty_sub"))] if store.bots: items.insert(0, GrrStripItem(store.bots, lambda b: g.router.go("/chat/" + (b.get("chat") or {}).get("id", "new")))) self.screen.items(items) class GrrProfileView(GrrView): fallback = "/bots" def open(self): g, t, ui = self.g, self.g.t, self.ui self.tab = self.query.get("tab") or "info" self.gen = 0 self.shown = GRR_LIST_MORE self.standard("", back="/bots") self.screen.items([GrrEmptyItem("hash", "...")]) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): g, t = self.g, self.g.t self.bot = bot self.screen.right = [self.screen.chip("share", self.share), self.screen.chip("more", self.more)] self.screen.refresh() self.sub(g.events, "bot.update", lambda d: self.refetch(d)) self.paint() if self.query.get("edit"): g.app.after(0.05, self.rename) def refetch(self, data): if data and data.get("id") == self.bot["id"]: self.get("/api/bots/" + self.bot["id"], lambda bot: (setattr(self, "bot", bot), self.paint())) def more(self): g, t, bot = self.g, self.g.t, self.bot g.menu([{"icon": "copy", "label": t("profile.copy_id"), "action": lambda: g.copy_text(bot["id"])}, {"icon": "trash", "label": t("profile.delete_bot"), "danger": True, "action": self.remove}]) def remove(self): g, t, bot = self.g, self.g.t, self.bot def go(): g.call("DELETE", "/api/bots/" + bot["id"], then=lambda _r: g.store.refresh(lambda _e: g.router.go("/bots", True))) g.confirm(t("profile.delete_bot"), t("profile.delete_text", name=bot["name"]), t("ui.delete"), go, True) def share(self): g, t, bot = self.g, self.g.t, self.bot def go(): chat = bot["chat"] g.call("POST", "/api/chats/%s/messages" % chat["id"], {"text": GRR_TEMPLATE_ASK}, then=lambda _r: g.router.go("/chat/" + chat["id"])) g.confirm(t("profile.share_title"), t("profile.share_text"), t("ui.continue"), go) def patch(self, body, repaint=True): def landed(res): self.bot.update(res) self.g.store.soon() if repaint: self.paint() self.call("PATCH", "/api/bots/" + self.bot["id"], body, landed) def rename(self): t = self.g.t self.g.edit(t("profile.name"), self.bot["name"], lambda v: self.patch({"name": v}) if v.strip() else None, max_chars=60) def describe(self): t = self.g.t self.g.edit(t("profile.description"), self.bot.get("description", ""), lambda v: self.patch({"description": v}), True, 4, 400) def hero_items(self): t, ui = self.g.t, self.ui return [GrrHeroItem(self.bot), GrrNameCard(self.bot["name"], self.bot.get("description", ""), t("profile.description_ph"), self.rename, self.describe)] def paint(self): t = self.g.t tabs = GrrTabsItem([{"id": "info", "label": t("profile.info")}, {"id": "documents", "label": t("profile.documents")}, {"id": "computer", "label": t("profile.computer")}, {"id": "links", "label": t("profile.links")}], self.tab, self.pick_tab) tabs.key = "tabs" self.tabs_item = tabs self.screen.items(self.hero_items() + [tabs] + self.content()) def pick_tab(self, tab): self.tab = tab self.gen += 1 self.screen.items(self.hero_items() + [self.tabs_item] + self.content()) def content(self): for handle in self.timers: self.g.app.cancel_timer(handle) self.timers = [] return {"info": self.info, "documents": self.documents, "computer": self.computer, "links": self.links}.get(self.tab, self.info)() def info(self): g, t, ui, bot = self.g, self.g.t, self.ui, self.bot go = g.router.go defaults = {"shape": "blob", "color": (ui.palette.bot_colors + ["#d4a020"])[4]} shapes = (self.g.spec.get("shapes") or list(GRR_SHAPES)) tiles = [{"label": "", "on": bot.get("shape") == s, "art": (lambda ui_, bg_, s=s: GrrAvatar.rows(ui_, {"shape": s, "color": bot.get("color")}, bg_, small=True)), "action": (lambda s=s: self.patch({"shape": s}))} for s in shapes] swatches = GrrSwatchItem(ui.palette.bot_colors, bot.get("color"), lambda c: self.patch({"color": c})) items = [GrrLabel(t("profile.character")), GrrTileGridItem(tiles, tile_h=4, min_w=9, max_cols=6), swatches, GrrRowItem(t("profile.reset_default"), action=lambda: self.patch(defaults), tone="ink"), GrrNote(t("profile.character_note")), GrrSpacer(1)] items += grr_group([GrrRowItem(t("profile.instructions"), icon="file", chevron=True, action=lambda: go("/bot/%s/instructions" % bot["id"])), GrrRowItem(t("profile.memories"), icon="inbox", value=str(bot.get("memories", 0)), chevron=True, action=lambda: go("/bot/%s/memories" % bot["id"])), GrrRowItem(t("profile.skills"), icon="skills", value=str(bot.get("skills", 0)), chevron=True, action=lambda: go("/bot/%s/skills" % bot["id"]))]) routines = bot.get("routines") or [] rr = [GrrRowItem(r["name"], icon="clock", sub=ui.fmt.schedule(r.get("schedule")) + ("" if r.get("active") else " · " + t("automations.paused")), chevron=True, action=(lambda r=r: go("/automation/" + r["id"]))) for r in routines] rr.append(GrrRowItem(t("profile.add_routine"), icon="plus", link=True, action=lambda: go("/automations/new?bot=" + bot["id"]))) items += [GrrLabel(t("profile.routines"))] + grr_group(rr) engine = bot.get("voice") or (g.spec.get("voice") or {}).get("default_engine", "browser") vr = [GrrRowItem(t("profile.voice_" + e), sub=t("profile.voice_" + e + "_sub"), icon="sparkle" if e == "neural" else "speaker", check=engine == e, action=(lambda e=e: None if engine == e else self.patch({"voice": e}))) for e in (g.spec.get("voice") or {}).get("engines", ["browser", "neural"])] items += [GrrLabel(t("profile.voice"))] + grr_group(vr) + [GrrNote(t("profile.voice_note"))] items += grr_group([GrrRowItem(t("profile.notifications"), toggle=bool(bot.get("notifications")), on_toggle=lambda v: self.patch({"notifications": v}, False))]) + [GrrNote(t("profile.notifications_note"))] items += grr_group([GrrRowItem(t("profile.auto_approve"), sub=t("profile.auto_approve_note"), toggle=bool(bot.get("auto_approve")), on_toggle=lambda v: self.patch({"auto_approve": v}, False))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("profile.share_template"), icon="share", chevron=True, action=self.share)]) return items def documents(self): t, ui = self.g.t, self.ui gen = self.gen def landed(res): if gen != self.gen: return files = res.get("files", []) if not files: rows = [GrrEmptyItem("file", t("profile.no_docs"), t("profile.no_docs_sub"))] else: rows = grr_group([GrrRowItem(f["name"], icon="image" if f.get("kind") == "media" else ("terminal" if f.get("kind") == "app" else "file"), sub=ui.fmt.ago(f.get("created", 0)) + " · " + GrrFormat.bytes(f.get("size", 0)), chevron=True, action=(lambda f=f: self.g.open_file(f))) for f in files[:self.shown]]) if len(files) > self.shown: rows.append(GrrButtonItem(t("home.show_more"), lambda: self.more_docs(), "ghost", inline=True)) self.screen.items(self.hero_items() + [self.tabs_item] + rows) self.get("/api/library?bot=" + self.bot["id"], landed) return [GrrEmptyItem("file", "...")] def more_docs(self): self.shown += GRR_LIST_MORE self.pick_tab("documents") def computer(self): g, t, ui, bot = self.g, self.g.t, self.ui, self.bot meta = GrrNote("", center=True) def refresh(): def landed(c): meta.text = t("computer.disk") + ": " + t("computer.disk_" + c["disk"]["state"]) + (" · " + t("computer.busy") if c.get("busy") else "") meta.touch() self.call("GET", "/api/bots/%s/computer" % bot["id"], None, landed, lambda _e: None) refresh() self.every(4.0, refresh) return [GrrScreenPreview(t("profile.screen_of", name=bot["name"])), GrrButtonItem(t("profile.take_control"), lambda: g.router.go("/bot/%s/screen" % bot["id"]), inline=True), GrrButtonItem(t("profile.open_terminal"), lambda: g.router.go("/bot/%s/computer" % bot["id"]), "secondary", inline=True, icon="terminal"), meta] def links(self): t, ui = self.g.t, self.ui gen = self.gen def landed(res): if gen != self.gen: return links = res.get("links", []) rows = [GrrEmptyItem("link", t("profile.no_links"), t("profile.no_links_sub"))] if not links else grr_group([ GrrRowItem(l.get("title") or GrrFormat.domain(l["url"]), sub=l["url"], chevron="ext", lead=(lambda ui_, bg, d=GrrFormat.domain(l["url"]): [TuiSpan(" %s " % ((d[:1] or "?").upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]), action=(lambda l=l: self.g.open_url(l["url"]))) for l in links]) self.screen.items(self.hero_items() + [self.tabs_item] + rows) self.get("/api/bots/%s/links" % self.bot["id"], landed) return [GrrEmptyItem("link", "...")] class GrrHeroItem(GrrItem): """A big avatar, centered.""" def __init__(self, bot): super().__init__() self.bot = bot def build(self, ui, width, selected): art = GrrAvatar.rows(ui, self.bot, "bg", hero=True) out = [[TuiSpan(" " * width, ui.st())]] for row in art: out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui.st())] + row, width, ui.st())) return out class GrrNameCard(GrrItem): """The bot's name and description in one card; each opens its editor.""" selectable = True def __init__(self, name, desc, placeholder, on_name, on_desc): super().__init__() self.name = name self.desc = desc self.placeholder = placeholder self.on_name = on_name self.on_desc = on_desc self.stops = 2 def nav(self, direction): if direction in ("up", "down"): nxt = self.stop + (1 if direction == "down" else -1) if 0 <= nxt <= 1: self.stop = nxt self.touch() return True return False def activate(self): (self.on_name if self.stop == 0 else self.on_desc)() def build(self, ui, width, selected): inner = GrrCard.inner(width) name_bg = "rule-strong" if selected and self.stop == 0 else "card" desc_bg = "rule-strong" if selected and self.stop == 1 else "card" rows = [GrrCard.top(ui, width)] rows.append(GrrCard.line(ui, width, [TuiSpan(grr_clip(self.name, inner - 2).center(inner), ui.st("ink", name_bg, bold=True))], name_bg)) self.hit(1, 0, width, self.on_name) text = self.desc or self.placeholder for line in grr_wrap(text, inner - 4)[:4]: rows.append(GrrCard.line(ui, width, [TuiSpan(line.center(inner), ui.st("muted" if self.desc else "faint", desc_bg))], desc_bg)) self.hit(len(rows) - 1, 0, width, self.on_desc) rows.append(GrrCard.bottom(ui, width)) return rows class GrrSwatchItem(GrrItem): selectable = True def __init__(self, colors, value, on_pick): super().__init__() self.colors = list(colors) self.value = value self.on_pick = on_pick self.stops = len(self.colors) if value in self.colors: self.stop = self.colors.index(value) def nav(self, direction): if direction in ("left", "right"): nxt = max(0, min(len(self.colors) - 1, self.stop + (1 if direction == "right" else -1))) if nxt != self.stop: self.stop = nxt self.touch() return True return False def activate(self): self.on_pick(self.colors[self.stop]) def build(self, ui, width, selected): m = grr_margin(width) + 1 page = ui.st("ink", "bg") spans, x = [TuiSpan(" " * m, page)], m for pos, color in enumerate(self.colors): on = color == self.value focus = selected and pos == self.stop mark = "◉" if on else "●" spans.append(TuiSpan(" " + mark + " ", TuiStyle(fg=color, bg=ui.palette.c("rule-strong" if focus else "bg")))) self.hit(0, x, x + 3, (lambda c=color: self.on_pick(c))) x += 3 return [grr_line(spans, width, page)] class GrrScreenPreview(GrrItem): """The framed placeholder of the bot's screen on the Computer tab.""" def __init__(self, caption): super().__init__() self.caption = caption def build(self, ui, width, selected): inner = GrrCard.inner(width) rows = [GrrCard.top(ui, width)] for pos in range(3): text = ui.icon("monitor") if pos == 1 else "" rows.append(GrrCard.line(ui, width, [TuiSpan(text.center(inner), ui.st("muted", "card"))])) rows.append(GrrCard.bottom(ui, width)) rows.append(grr_line([TuiSpan(grr_clip(self.caption, inner).center(width), ui.st("muted", "bg"))], width, ui.st("ink", "bg"))) return rows class GrrInstructionsView(GrrView): fallback = "/bots" def open(self): t = self.g.t self.standard(t("profile.instructions"), back="/bot/" + self.params["id"]) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): self.bot = bot self.paint() def paint(self): g, t, bot = self.g, self.g.t, self.bot cap = g.ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) prompts = (g.spec.get("prompts") or {}) def editor(key, title, hint, default=""): current = bot.get(key) or default def save(value): body = {key: "" if (default and value.strip() == default.strip()) else value} self.call("PATCH", "/api/bots/" + bot["id"], body, lambda res: (bot.update(res), self.paint(), g.toast(t("ui.saved")))) items = [GrrLabel(title), GrrTextCard(current, action=lambda: g.edit(title, current, save, True, 10, cap), placeholder="")] if default: items.append(GrrRowItem(t("profile.reset_default"), action=lambda: save(default), tone="ink")) items.append(GrrNote(hint)) return items items = editor("instructions", t("profile.instruction"), t("profile.instructions_hint")) items += editor("persona", t("profile.persona"), t("profile.persona_hint", name="{name}"), prompts.get("persona", "")) items += editor("rules", t("profile.rules"), t("profile.rules_hint"), prompts.get("rules", "")) self.screen.items(items) class GrrListView(GrrView): """Memories and skills of a bot: list, add, open.""" path = "" icon = "" detail = "" def open(self): self.standard(self.heading(), back="/bot/" + self.params["id"], right=[]) self.sub(self.g.events, "bot.update", lambda _d: self.reload()) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): self.bot = bot self.screen.right = [self.screen.chip("plus", self.add)] self.screen.refresh() self.reload() def reload(self): if getattr(self, "bot", None) is None: return self.get("/api/bots/%s/%s" % (self.bot["id"], self.path), self.paint) def paint(self, res): items = res.get(self.path, []) t = self.g.t if not items: self.screen.items(self.empty()) return self.screen.items(grr_group([GrrRowItem(self.title(it), sub=self.sub_of(it), chevron=True, wrap=True, action=(lambda it=it: self.g.router.go("/%s/%s" % (self.detail, it["id"])))) for it in items])) def sub_of(self, item): return "" class GrrMemoriesView(GrrListView): path, icon, detail = "memories", "inbox", "memory" def heading(self): return self.g.t("profile.memories") def title(self, m): return m["content"] def empty(self): t = self.g.t return [GrrEmptyItem("inbox", t("profile.no_memories"), t("profile.no_memories_sub"))] def add(self): g, t = self.g, self.g.t g.edit(t("profile.add_memory"), "", lambda v: self.call("POST", "/api/bots/%s/memories" % self.bot["id"], {"content": v}, lambda _r: self.reload()), True, 4, 1000) class GrrSkillsView(GrrListView): path, icon, detail = "skills", "skills", "skill" def heading(self): return self.g.t("profile.skills") def title(self, s): return s["name"] def sub_of(self, s): return s.get("description", "") def empty(self): t = self.g.t return [GrrEmptyItem("skills", t("profile.no_skills"), t("profile.no_skills_sub"))] def add(self): g, t = self.g, self.g.t def done(value, texts): if value == "add": self.call("POST", "/api/bots/%s/skills" % self.bot["id"], {"name": texts[0], "description": texts[1], "content": texts[2]}, lambda _r: self.reload()) g.dialog(title=t("profile.add_skill"), w=72, fields=[{"value": "", "placeholder": t("profile.skill_name"), "max": 80}, {"value": "", "placeholder": t("profile.skill_desc"), "max": 300}, {"value": "", "placeholder": t("profile.skill_content"), "rows": 6, "max": 12000}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.add"), "value": "add", "primary": True}], done=done) class GrrMemoryView(GrrView): def open(self): self.standard(self.g.t("profile.memory"), back="/bots") self.get("/api/memories/" + self.params["id"], self.loaded) def loaded(self, mem): g, t = self.g, self.g.t self.mem = mem self.screen.back = "/bot/%s/memories" % mem["bot_id"] self.screen.right = [self.screen.chip("trash", self.delete)] self.screen.refresh() self.paint() def paint(self): g, t, mem = self.g, self.g.t, self.mem def save(value): self.call("PATCH", "/api/memories/" + mem["id"], {"content": value}, lambda res: (mem.update(res), self.paint())) self.screen.items([GrrLabel(t("profile.content")), GrrTextCard(mem["content"], action=lambda: g.edit(t("profile.memory"), mem["content"], save, True, 6, 1000))]) def delete(self): g, t, mem = self.g, self.g.t, self.mem g.confirm(t("ui.delete"), t("profile.delete_memory"), t("ui.delete"), lambda: self.call("DELETE", "/api/memories/" + mem["id"], None, lambda _r: g.router.back("/bot/%s/memories" % mem["bot_id"])), True) class GrrSkillView(GrrView): def open(self): self.standard("", back="/bots") self.get("/api/skills/" + self.params["id"], self.loaded) def loaded(self, skill): self.skill = skill self.screen.back = "/bot/%s/skills" % skill["bot_id"] self.screen.title = skill["name"] self.screen.right = [self.screen.chip("trash", self.delete)] self.screen.refresh() self.paint() def paint(self): g, t, skill = self.g, self.g.t, self.skill def field(key, label, rows, cap): def save(value): self.call("PATCH", "/api/skills/" + skill["id"], {key: value}, lambda res: (skill.update(res), self.paint())) return [GrrLabel(label), GrrTextCard(skill.get(key, ""), action=lambda: g.edit(label, skill.get(key, ""), save, True, rows, cap))] self.screen.items(field("description", t("profile.description"), 3, 300) + field("content", t("profile.content"), 12, 12000)) def delete(self): g, t, skill = self.g, self.g.t, self.skill g.confirm(t("ui.delete"), t("profile.delete_skill"), t("ui.delete"), lambda: self.call("DELETE", "/api/skills/" + skill["id"], None, lambda _r: g.router.back("/bot/%s/skills" % skill["bot_id"])), True) # ---- automations class GrrRoutineItem(GrrItem): """A routine as a card: bot, name, prompt, schedule line.""" selectable = True def __init__(self, routine, on_open, on_menu): super().__init__() self.r = routine self.action = on_open self.on_menu = on_menu self.key = routine["id"] def build(self, ui, width, selected): inner = GrrCard.inner(width) r = self.r bg = "rule-strong" if selected else "card" base = ui.st("ink", bg) bot = r.get("bot") chip = GrrAvatar.chip(ui, bot, bg) if bot else [TuiSpan(" %s " % ui.icon("automation"), ui.st("soft", bg))] head = [TuiSpan(" ", base)] + chip + [TuiSpan(" " + grr_clip(r["name"], inner - 14), ui.st("ink" if r.get("active") else "muted", bg, bold=True))] head = grr_line(head, inner - 4, base) + [TuiSpan(" %s " % ui.icon("moreV"), ui.st("soft", bg))] lines = [head] for line in grr_wrap(r.get("instruction", ""), inner - 3)[:3]: lines.append([TuiSpan(" " + line, ui.st("muted", bg))]) foot = (bot["name"] + " · " if bot else "") + ui.fmt.schedule(r.get("schedule")) + ("" if r.get("active") else " · " + ui.t("automations.paused")) lines.append([TuiSpan(" " + grr_clip(foot, inner - 3), ui.st("faint", bg))]) out = [GrrCard.top(ui, width, bg)] + [GrrCard.line(ui, width, line, bg) for line in lines] + [GrrCard.bottom(ui, width, bg)] self.hit(1, width - grr_margin(width) - 6, width, lambda: self.on_menu(self.r)) return out class GrrAutomationsView(GrrView): fallback = "/" def open(self): g, t = self.g, self.g.t self.standard(t("home.automations"), close=True, back="/", right=[]) self.screen.right = [self.screen.chip("plus", lambda: g.router.go("/automations/new"))] self.screen.refresh() self.sub(g.events, "routine.update", lambda _d: self.reload()) self.reload() def reload(self): self.get("/api/routines", self.paint) def paint(self, res): g, t = self.g, self.g.t items = [GrrRoutineItem(r, (lambda r=r: g.router.go("/automation/" + r["id"])), self.menu) for r in res.get("routines", [])] if not items: items.append(GrrEmptyItem("automation", t("automations.empty"), t("automations.empty_sub"))) items.append(GrrButtonItem(t("automations.new"), lambda: g.router.go("/automations/new"), "secondary", inline=True, icon="plus")) self.screen.items(items) def menu(self, r): g, t = self.g, self.g.t def delete(): g.confirm(t("automations.delete"), t("automations.delete_text"), t("ui.delete"), lambda: g.call("DELETE", "/api/routines/" + r["id"]), True) g.menu([{"icon": "play", "label": t("automations.run_now"), "action": lambda: g.call("POST", "/api/routines/%s/run" % r["id"], {}, then=lambda _x: g.toast(t("automations.started")))}, {"icon": "pause" if r.get("active") else "play", "label": t("automations.pause") if r.get("active") else t("automations.resume"), "action": lambda: g.call("PATCH", "/api/routines/" + r["id"], {"active": not r.get("active")})}, "-", {"icon": "trash", "label": t("automations.delete"), "danger": True, "action": delete}]) def grr_schedule_dialog(g, initial, done): """The schedule picker: Every, Daily or Weekly. done(schedule) gets the picked schedule dict, never a partial one.""" t, ui = g.t, g.ui state = {"kind": "interval", "every": 5, "unit": 60, "time": "09:00", "day": 0} if initial: state["kind"] = initial.get("kind", "interval") if state["kind"] == "interval": sec = int(initial.get("every_seconds", 300)) unit = 86400 if sec % 86400 == 0 else (3600 if sec % 3600 == 0 else 60) state.update({"every": max(1, sec // unit), "unit": unit}) else: state.update({"time": initial.get("time", "09:00"), "day": initial.get("day", 0)}) kinds = (g.spec.get("schedule_kinds") or ["interval", "daily", "weekly"]) box = {} def build(): rows = [GrrSegmentedItem([{"id": k, "label": t("sched.kind_" + k)} for k in kinds], state["kind"], lambda k: (state.update(kind=k), rebuild()))] if state["kind"] == "interval": units = [(60, t("sched.minutes")), (3600, t("sched.hours")), (86400, t("sched.days"))] rows.append(GrrSegmentedItem([{"id": u, "label": l} for u, l in units], state["unit"], lambda u: state.update(unit=u))) elif state["kind"] == "weekly": days = [GrrFormat.weekday(i)[:3] for i in range(7)] rows.append(GrrSegmentedItem([{"id": i, "label": d} for i, d in enumerate(days)], state["day"], lambda i: state.update(day=i))) return rows def rebuild(): dlg = box["dlg"] dlg.grr_page.set_items(build()) field = dlg.grr_fields[0] label = t("sched.every") if state["kind"] == "interval" else t("sched.time") field.placeholder = label field.lines = [str(state["every"]) if state["kind"] == "interval" else state["time"]] field.cx = len(field.lines[0]) def finish(value, texts): if value != "done": return raw = texts[0].strip() if state["kind"] == "interval": try: n = float(raw) except ValueError: g.toast(t("sched.floor", n=g.ui.cap("ROUTINE_MIN_INTERVAL", 30)), "bad") return secs = int(round(n * state["unit"])) floor = g.ui.cap("ROUTINE_MIN_INTERVAL", 30) if secs < floor: g.toast(t("sched.floor", n=floor), "bad") return done({"kind": "interval", "every_seconds": secs}) elif not re.match(r"^\d{1,2}:\d{2}$", raw): g.toast(t("sched.need_time"), "bad") elif state["kind"] == "daily": done({"kind": "daily", "time": raw.zfill(5)}) else: done({"kind": "weekly", "day": state["day"], "time": raw.zfill(5)}) first = str(state["every"]) if state["kind"] == "interval" else state["time"] dlg = g.dialog(title=t("automations.select_schedule"), w=60, fields=[{"value": first, "placeholder": t("sched.every")}], rows=build(), page_h=5, actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.done"), "value": "done", "primary": True}], done=finish) box["dlg"] = dlg class GrrAutomationView(GrrView): fallback = "/automations" def open(self): self.standard("", back="/automations") self.get("/api/routines/" + self.params["id"], self.loaded) def loaded(self, r): g, t = self.g, self.g.t self.r = r self.screen.title = r["name"] self.screen.right = [self.screen.chip("moreV", self.more)] self.screen.refresh() self.sub(g.events, "routine.update", lambda d: self.refetch(d)) self.paint() def refetch(self, d): if d and d.get("id") == self.r["id"] and not d.get("deleted"): self.get("/api/routines/" + self.r["id"], lambda r: (setattr(self, "r", r), self.paint())) def more(self): g, t, r = self.g, self.g.t, self.r def delete(): g.confirm(t("automations.delete"), t("automations.delete_text"), t("ui.delete"), lambda: self.call("DELETE", "/api/routines/" + r["id"], None, lambda _x: g.router.back("/automations")), True) g.menu([{"icon": "trash", "label": t("automations.delete"), "danger": True, "action": delete}]) def patch(self, body): self.call("PATCH", "/api/routines/" + self.r["id"], body, lambda res: (setattr(self, "r", res), self.paint())) def paint(self): g, t, ui, r = self.g, self.g.t, self.ui, self.r cap = ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) def save_text(value): if not value.strip(): g.toast(t("automations.prompt_empty"), "bad") return self.patch({"instruction": value}) def pick_schedule(): grr_schedule_dialog(g, r.get("schedule"), lambda s: self.patch({"schedule": s})) bot = r.get("bot") items = grr_group([GrrRowItem(t("automations.active"), toggle=bool(r.get("active")), on_toggle=lambda v: self.patch({"active": v}))]) items += [GrrLabel(t("automations.instruction")), GrrTextCard(r.get("instruction", ""), action=lambda: g.edit(t("automations.instruction"), r.get("instruction", ""), save_text, True, 8, cap)), GrrLabel(t("automations.planning"))] items += grr_group([GrrRowItem(t("automations.schedule"), icon="clock", value=ui.fmt.schedule(r.get("schedule")), chevron=True, action=pick_schedule), GrrRowItem(t("automations.bot"), value=bot["name"] if bot else "-", lead=(lambda ui_, bg: GrrAvatar.chip(ui_, bot, bg)) if bot else None, chevron=True, action=(lambda: g.router.go("/bot/" + bot["id"])) if bot else None)]) items += [GrrLabel(t("automations.notifications"))] items += grr_group([GrrRowItem(t("automations.push"), icon="bell", toggle=bool(r.get("notify_push")), on_toggle=lambda v: self.patch({"notify_push": v})), GrrRowItem(t("automations.email"), icon="inbox", toggle=bool(r.get("notify_email")), on_toggle=lambda v: self.patch({"notify_email": v}))]) items += [GrrSpacer(1), GrrButtonItem(t("automations.run_now"), lambda: self.call("POST", "/api/routines/%s/run" % r["id"], {}, lambda _x: g.toast(t("automations.started"))), "secondary")] items.append(GrrNote(t("automations.next", when=ui.fmt.next_run(r.get("next_run")) if r.get("active") else "-"))) if r.get("last_run"): ok = r.get("last_status") == "ok" items.append(GrrNote(t("automations.last", when=ui.fmt.ago(r["last_run"]), status=t("automations.status_" + (r.get("last_status") or "ok"))), tone="success" if ok else "danger")) if r.get("last_result"): items.append(GrrTextCard(r["last_result"])) self.screen.items(items) class GrrNewAutomationView(GrrView): fallback = "/automations" def open(self): g, t, ui = self.g, self.g.t, self.ui self.s = None self.tier = "auto" self.bot_id = self.query.get("bot") or (g.store.bots[0]["id"] if g.store.bots else "") self.name = "" self.text = "" self.push = True self.email = False self.standard(t("automations.new"), close=True, back="/automations") self.screen.right = [{"text": " %s " % t("ui.create"), "fn": self.create, "bg": "accent", "fg": "accent-ink", "bold": True}] self.screen.refresh() self.paint() def paint(self): g, t, ui = self.g, self.g.t, self.ui cap = ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) bots = [{"label": b["name"], "checked": b["id"] == self.bot_id, "action": (lambda b=b: self.set_bot(b["id"]))} for b in g.store.bots] tiers = [{"label": t("tier." + x), "checked": x == self.tier, "action": (lambda x=x: self.set_tier(x))} for x in (g.spec.get("tiers") or ["auto", "fast", "expert"])] bot = g.store.bot(self.bot_id) items = [GrrHeading(ui.icon("automation"), center=True), GrrRowItem(self.name or t("automations.name_ph"), icon="edit", tone="", action=lambda: g.edit(t("automations.name"), self.name, self.set_name, max_chars=60), pos="only"), GrrLabel(t("automations.planning")), GrrRowItem(ui.fmt.schedule(self.s) if self.s else t("automations.select_schedule"), icon="clock" if self.s else "plus", link=not self.s, action=self.pick_schedule), GrrLabel(t("automations.instructions")), GrrTextCard(self.text, placeholder=t("automations.instruction_ph"), action=lambda: g.edit(t("automations.instructions"), self.text, self.set_text, True, 8, cap)), GrrSpacer(1), GrrChipsItem([{"label": bot["name"] if bot else "-", "icon": "user", "action": lambda: g.menu(bots)}, {"label": t("tier." + self.tier), "icon": "sparkle", "action": lambda: g.menu(tiers)}]), GrrLabel(t("automations.notifications"))] items += grr_group([GrrRowItem(t("automations.push"), icon="bell", toggle=self.push, on_toggle=lambda v: setattr(self, "push", v)), GrrRowItem(t("automations.email"), icon="inbox", toggle=self.email, on_toggle=lambda v: setattr(self, "email", v))]) self.screen.items(items) def set_bot(self, bot_id): self.bot_id = bot_id self.paint() def set_tier(self, tier): self.tier = tier self.paint() def set_name(self, value): self.name = value self.paint() def set_text(self, value): self.text = value self.paint() def pick_schedule(self): grr_schedule_dialog(self.g, self.s, lambda s: (setattr(self, "s", s), self.paint())) def create(self): g, t = self.g, self.g.t if not self.text.strip(): g.alert("", t("automations.prompt_empty")) elif not self.s: g.alert("", t("automations.need_schedule")) elif not self.bot_id: g.alert("", t("automations.need_bot")) else: body = {"bot_id": self.bot_id, "name": self.name or self.text[:40], "instruction": self.text, "schedule": self.s, "notify_push": self.push, "notify_email": self.email, "tier": self.tier} self.call("POST", "/api/routines", body, lambda r: g.router.go("/automation/" + r["id"], True)) # ---- library, projects, search GRR_FILE_ICON = {"media": "image", "app": "terminal", "audio": "mic", "file": "file"} class GrrLibraryView(GrrView): fallback = "/" def open(self): g, t, ui = self.g, self.g.t, self.ui self.tab = "all" self.q = "" self.sort = "new" self.files = [] self.standard(t("home.library"), back="/") self.sub(g.events, "library.update", lambda _d: self.reload()) self.reload() def reload(self): self.get("/api/library", self.loaded) def loaded(self, res): self.files = res.get("files", []) self.paint() def paint(self): g, t, ui = self.g, self.g.t, self.ui tabs = GrrTabsItem([{"id": "all", "label": t("library.all")}, {"id": "media", "label": t("library.media")}, {"id": "app", "label": t("library.apps")}, {"id": "file", "label": t("library.files")}], self.tab, self.pick_tab) tabs.key = "tabs" sorts = [{"label": t("library.sort_" + s), "checked": self.sort == s, "action": (lambda s=s: self.set_sort(s))} for s in ("new", "old", "name", "size")] tools = GrrChipsItem([{"label": t("library.sort_" + self.sort), "icon": "tune", "action": lambda: g.menu(sorts)}, {"label": self.q or t("ui.search"), "icon": "search", "caret": False, "action": self.ask_filter}, {"label": t("library.upload"), "icon": "plus", "caret": False, "action": self.upload}]) rows = [f for f in self.files if (self.tab == "all" or (f.get("kind") in ("file", "audio") if self.tab == "file" else f.get("kind") == self.tab)) and (not self.q or self.q in f["name"].lower())] by = {"new": lambda f: -f.get("created", 0), "old": lambda f: f.get("created", 0), "name": lambda f: f["name"].lower(), "size": lambda f: -f.get("size", 0)} rows.sort(key=by[self.sort]) if rows: tiles = [{"label": f["name"], "sub": ui.fmt.ago(f.get("created", 0)) + " · " + GrrFormat.bytes(f.get("size", 0)), "icon": GRR_FILE_ICON.get(f.get("kind"), "file"), "action": (lambda f=f: g.open_file(f))} for f in rows] body = GrrTileGridItem(tiles, tile_h=6) else: text = {"app": t("library.no_apps"), "media": t("library.no_media"), "file": t("library.no_files")}.get(self.tab, t("library.empty")) body = GrrEmptyItem("box", text, t("library.empty_sub")) self.screen.items([tabs, tools, GrrSpacer(1), body]) def pick_tab(self, tab): self.tab = tab self.paint() def set_sort(self, sort): self.sort = sort self.paint() def ask_filter(self): t = self.g.t self.g.edit(t("ui.search"), self.q, lambda v: (setattr(self, "q", v.strip().lower()), self.paint())) def upload(self): g, t = self.g, self.g.t def send(path): path = os.path.expanduser(path.strip()) try: size = os.path.getsize(path) if size > g.ui.cap("UPLOAD_MAX_BYTES", 20971520): raise GrrError(0, "too_big", t("err.too_big", max=GrrFormat.bytes(g.ui.cap("UPLOAD_MAX_BYTES", 20971520)))) with open(path, "rb") as fh: data = fh.read() except (OSError, GrrError) as exc: g.error(exc if isinstance(exc, GrrError) else GrrError(0, "file", str(exc))) return g.app.offload(lambda: g.client.upload(os.path.basename(path), data), lambda _r, e: (g.error(e) if e else self.reload())) g.edit(t("library.upload"), "", send, text="Path of the file on this machine") class GrrProjectsView(GrrView): fallback = "/" def open(self): g, t = self.g, self.g.t self.filter = "all" self.sort = "updated" self.standard(t("home.projects"), back="/") self.screen.right = [self.screen.chip("plus", self.create)] self.screen.refresh() self.load() def load(self): self.get("/api/projects?sort=%s%s" % (self.sort, "&starred=1" if self.filter == "starred" else ""), self.paint) def paint(self, res): g, t, ui = self.g, self.g.t, self.ui filters = [{"label": t("projects.all"), "checked": self.filter == "all", "action": lambda: self.set("filter", "all")}, {"label": t("projects.starred"), "checked": self.filter == "starred", "action": lambda: self.set("filter", "starred")}] sorts = [{"label": t("projects.last_edited"), "checked": self.sort == "updated", "action": lambda: self.set("sort", "updated")}, {"label": t("projects.name"), "checked": self.sort == "name", "action": lambda: self.set("sort", "name")}] chips = GrrChipsItem([{"label": t("projects.all") if self.filter == "all" else t("projects.starred"), "icon": "tune", "action": lambda: g.menu(filters)}, {"label": t("projects.last_edited") if self.sort == "updated" else t("projects.name"), "icon": "tune", "action": lambda: g.menu(sorts)}]) projects = res.get("projects", []) if projects: tiles = [{"label": p["name"], "sub": t("projects.modified", date=ui.fmt.short_date(p.get("updated", 0))), "icon": "star" if p.get("starred") else "folder", "on": bool(p.get("starred")), "action": (lambda p=p: g.router.go("/project/" + p["id"]))} for p in projects] body = GrrTileGridItem(tiles, tile_h=6) else: body = GrrEmptyItem("folder", t("projects.empty"), t("projects.empty_sub")) self.screen.items([chips, GrrSpacer(1), body]) def set(self, key, value): setattr(self, key, value) self.load() def create(self): g, t = self.g, self.g.t def done(value, texts): if value == "create": self.call("POST", "/api/projects", {"name": texts[0], "instructions": texts[1]}, lambda p: g.router.go("/project/" + p["id"])) g.dialog(title=t("projects.new"), w=64, fields=[{"value": "", "placeholder": t("projects.name_ph"), "max": 60}, {"value": "", "placeholder": t("projects.instructions_ph"), "rows": 4}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.create"), "value": "create", "primary": True}], done=done) class GrrProjectView(GrrView): fallback = "/projects" def open(self): self.standard("", back="/projects", foot=None) self.sub(self.g.events, "chat.update", lambda _d: self.reload()) self.reload() def reload(self): self.get("/api/projects/" + self.params["id"], self.loaded) def loaded(self, p): self.p = p t, ui = self.g.t, self.ui self.screen.title = ("%s " % ui.icon("star") if p.get("starred") else "") + p["name"] self.screen.right = [self.screen.chip("moreV", self.menu)] self.screen.refresh() chats = p.get("chats") or [] items = [] if not chats: items.append(GrrEmptyItem("compose", t("projects.no_chats"), t("projects.no_chats_sub"))) for c in chats: items.append(GrrConvItem(lambda ui_, bg: [[TuiSpan(" ", ui_.st("ink", bg))], [TuiSpan(" ", ui_.st("ink", bg))]], c.get("title") or t("chat.untitled"), "", ui.fmt.short_date(c.get("updated", 0)), action=(lambda c=c: self.g.router.go("/chat/" + c["id"])), menu=(lambda c=c: self.chat_menu(c)), key=c["id"])) items.append(GrrButtonItem(t("projects.new_chat"), lambda: self.g.router.go("/chat/new?project=" + p["id"]), "secondary", inline=True, icon="compose")) self.screen.items(items) def chat_menu(self, c): g, t = self.g, self.g.t g.menu([{"icon": "trash", "label": t("ui.delete"), "danger": True, "action": lambda: g.call("DELETE", "/api/chats/" + c["id"], then=lambda _r: self.reload())}]) def menu(self): g, t, p = self.g, self.g.t, self.p def rename(): g.edit(t("chat.rename"), p["name"], lambda v: self.call("PATCH", "/api/projects/" + p["id"], {"name": v}, lambda _r: self.reload()), max_chars=60) def instructions(): g.edit(t("projects.instructions"), p.get("instructions", ""), lambda v: self.call("PATCH", "/api/projects/" + p["id"], {"instructions": v}, lambda _r: self.reload()), True, 6, g.ui.cap("INSTRUCTIONS_MAX_CHARS", 12000), text=t("projects.instructions_hint")) def delete(): g.confirm(t("projects.delete"), t("projects.delete_text"), t("ui.delete"), lambda: self.call("DELETE", "/api/projects/" + p["id"], None, lambda _r: g.router.go("/projects", True)), True) g.menu([{"icon": "edit", "label": t("chat.rename"), "action": rename}, {"icon": "star", "label": t("projects.unstar") if p.get("starred") else t("projects.star"), "action": lambda: self.call("PATCH", "/api/projects/" + p["id"], {"starred": not p.get("starred")}, lambda _r: self.reload())}, {"icon": "file", "label": t("projects.instructions"), "action": instructions}, "-", {"icon": "trash", "label": t("ui.delete"), "danger": True, "action": delete}]) class GrrSearchView(GrrView): fallback = "/" def open(self): g, t, ui = self.g, self.g.t, self.ui self.kind = "all" self.q = "" self.seq = 0 self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.box = TuiTextBox("", h=1, border=True, rounded=True, padding=(0, 1), placeholder=t("ui.search"), style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.run()) self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") self.root.add(self.bar, self.box, self.page) self.screen = None self.refresh_bar() self.every(0.25, self.watch) self.run() self.show(self.root, self.box) def refresh_bar(self): g, t, ui = self.g, self.g.t, self.ui kinds = [{"label": t("search.kind_" + k), "checked": self.kind == k, "action": (lambda k=k: self.set_kind(k))} for k in (g.spec.get("search_kinds") or ["all"])] self.bar.set([{"text": " %s " % ui.icon("close"), "fn": lambda: g.router.back("/"), "bg": "card", "bold": True}, {"text": t("ui.search") + " · " + t("search.kind_" + self.kind), "fill": True, "align": "center", "bold": True}, {"text": " %s " % ui.icon("filter"), "fn": lambda: g.menu(kinds), "bg": "card"}]) def on_escape(self): self.g.router.back("/") return True def set_kind(self, kind): self.kind = kind self.refresh_bar() self.run() def watch(self): if self.box.value != self.q: self.q = self.box.value self.run() def run(self): self.seq += 1 mine = self.seq path = "/api/search?kind=%s&q=%s" % (self.kind, urllib.parse.quote(self.q)) def landed(res): if mine == self.seq: self.paint(res) self.get(path, landed) def paint(self, res): g, t, ui = self.g, self.g.t, self.ui results = res.get("results", []) if not results: self.page.set_items([GrrEmptyItem("search", t("search.none"), t("search.none_sub"))]) return def snippet(x): return re.sub(r"[\[\]]", "", str(x.get("snippet", ""))) items = [] blank = lambda ui_, bg: [[TuiSpan(" ", ui_.st("ink", bg))], [TuiSpan(" ", ui_.st("soft", bg))]] for x in results: kind = x.get("type") if kind == "bot": bot = g.store.bot(x["id"]) or x.get("bot") items.append(GrrConvItem(grr_bot_avatar(bot), x["title"], "", snippet(x), tag=t("search.tag_bot"), action=lambda b=bot: g.router.go("/chat/" + (g.store.bot(b["id"]) or {}).get("chat", {}).get("id", "new")))) elif kind == "group": items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("bots.group"), action=lambda x=x: g.router.go("/chat/" + x["id"]))) elif kind == "message": items.append(GrrConvItem(blank, x["title"], ui.fmt.short_date(x.get("ts", 0)), snippet(x), action=lambda x=x: g.router.go("/chat/" + x["chat_id"]))) elif kind == "file": items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("search.tag_file"), action=lambda x=x: g.open_file({"id": x["id"], "name": x["title"], "mime": x.get("snippet", ""), "kind": x.get("kind")}))) else: items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("search.tag_routine"), action=lambda x=x: g.router.go("/automation/" + x["id"]))) self.page.set_items(items) # --------------------------------------------------------------------------- # Part E: settings and its sub screens, templates, shell helpers. # --------------------------------------------------------------------------- class GrrSettingsView(GrrView): fallback = "/" def open(self): t = self.g.t self.standard(t("home.settings"), close=True, back="/") self.batch([("GET", "/api/rules", None), ("GET", "/api/usage", None)], self.paint) def paint(self, res): g, t, ui = self.g, self.g.t, self.ui rules, usage = res s = g.settings me = g.me or {} go = g.router.go docs = lambda name: (lambda: g.open_url("%s/docs/%s.html" % (g.client.base, name))) items = grr_group([GrrRowItem(me.get("username", ""), sub=me.get("email", ""), lead=(lambda ui_, bg: [TuiSpan(" %s " % (me.get("username", "?")[:1].upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]), chevron=True, action=lambda: go("/settings/account")), GrrRowItem(t("settings.usage"), value="%s%%" % usage.get("pct", 0), chevron=True, action=lambda: go("/settings/usage"))]) items += [GrrSpacer(1)] items += grr_group([GrrRowItem(t("settings.connect"), sub=t("settings.connect_sub"), chevron=True, action=lambda: go("/settings/connectors")), GrrRowItem(t("settings.builder"), sub=t("settings.builder_sub"), chevron=True, action=lambda: go("/settings/builder")) if me.get("is_admin") else None]) items += [GrrLabel(t("settings.bots"))] items += grr_group([GrrRowItem(t("settings.auto_review"), sub=t("settings.auto_review_sub"), toggle=bool(s.get("auto_review")), on_toggle=lambda v: g.save_settings({"auto_review": v})), GrrRowItem(t("settings.rules"), value=str(len(rules.get("rules", []))), chevron=True, action=lambda: go("/settings/rules")), GrrRowItem(t("settings.auto_tz"), sub=t("settings.auto_tz_sub"), toggle=bool(s.get("auto_tz")), on_toggle=lambda v: g.save_settings({"auto_tz": v}, lambda _m: g.sync_timezone() if v else None)), GrrRowItem(t("settings.timezone"), value=s.get("timezone", ""), chevron=True, action=lambda: go("/settings/timezone")), GrrRowItem(t("settings.computer"), chevron=True, action=lambda: go("/settings/computer"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.notifications"), toggle=bool(s.get("notifications")), on_toggle=lambda v: g.save_settings({"notifications": v}))]) lang = s.get("language", "system") items += [GrrSpacer(1)] + grr_group([ GrrRowItem(t("settings.appearance"), value=t("appearance." + s.get("theme", "system")) + " · " + t("appearance.v_" + s.get("variant", "black")), chevron=True, action=lambda: go("/settings/appearance")), GrrRowItem(t("settings.language"), value=t("lang.system") if lang == "system" else GRR_LANG_NAMES.get(lang, lang), chevron=True, action=lambda: go("/settings/language")), GrrRowItem(t("settings.haptics"), value=t("ui.on") if s.get("haptics") else t("ui.off"), chevron=True, action=lambda: go("/settings/haptics")), GrrRowItem(t("settings.widget"), chevron=True, action=lambda: go("/settings/widget")), GrrRowItem(t("settings.advanced"), chevron=True, action=lambda: go("/settings/advanced"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.help"), chevron="ext", action=docs("help")), GrrRowItem(t("settings.privacy"), chevron="ext", action=docs("privacy")), GrrRowItem(t("settings.terms"), chevron="ext", action=docs("terms")), GrrRowItem(t("settings.bot_terms"), chevron="ext", action=docs("bots"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.feedback"), chevron=True, action=lambda: go("/settings/feedback"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.sign_out"), danger=True, action=lambda: sign_out_dialog(g))]) items.append(GrrNote("grr " + str((g.spec or {}).get("version", "")), center=True)) self.screen.items(items) def sign_out_dialog(g): t = g.t g.confirm(t("settings.sign_out"), t("settings.sign_out_text"), t("settings.sign_out"), g.sign_out, True) class GrrSubView(GrrView): """A settings sub screen: header, page, optional foot button.""" title_key = "" fallback = "/settings" def open(self): self.standard(self.g.t(self.title_key), back="/settings", foot=self.foot()) self.start() def foot(self): return None def start(self): self.paint(None) def paint(self, res): pass class GrrAccountView(GrrSubView): title_key = "account.title" def paint(self, res): g, t = self.g, self.g.t me = g.me or {} accounts = g.session.accounts items = grr_group([GrrRowItem(me.get("username", ""), sub=me.get("email", ""), lead=(lambda ui_, bg: [TuiSpan(" %s " % (me.get("username", "?")[:1].upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]))]) rows = [GrrRowItem(a.get("email") or a.get("username"), check=a["token"] == g.client.token, action=(lambda a=a: g.switch_account(a["token"]) if a["token"] != g.client.token else None)) for a in accounts] rows.append(GrrRowItem(t("account.add"), icon="plus", action=lambda: g.show_login())) items += [GrrLabel(t("account.switch"))] + grr_group(rows) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.sign_out"), danger=True, action=lambda: sign_out_dialog(g))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("account.delete"), icon="trash", danger=True, action=self.remove)]) + [GrrNote(t("account.delete_note"))] self.screen.items(items) def remove(self): g, t = self.g, self.g.t me = g.me or {} def done(value, texts): if value == "delete": g.call("DELETE", "/api/me", {"confirm": texts[0]}, then=lambda _r: g.after_sign_out()) g.dialog(title=t("account.delete"), text=t("account.delete_confirm", name=me.get("username", "")), fields=[{"value": "", "placeholder": me.get("username", "")}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.delete"), "value": "delete", "primary": True, "danger": True}], done=done) class GrrUsageView(GrrSubView): title_key = "settings.usage" def start(self): self.get("/api/usage", self.paint) def paint(self, u): g, t, ui = self.g, self.g.t, self.ui items = [GrrProgressItem(t("usage.weekly"), u.get("pct", 0), t("usage.resets", n=u.get("resets_in_days", 0)), "%s%% · %s / %s %s" % (u.get("pct", 0), GrrFormat.num(u.get("tokens", 0)), GrrFormat.num(u.get("limit", 0)), t("usage.tokens"))), GrrRowItem(t("usage.change_limit"), chevron=True, action=lambda: self.change(u)), GrrLabel(t("usage.days"))] days = u.get("days", []) items.append(GrrBarsItem(days) if days else GrrNote(t("usage.none"))) dp = u.get("devplace") items.append(GrrLabel(t("usage.gateway"))) if dp: items.append(GrrKvItem([(t("usage.gw_spent"), "$%.4f" % float(dp.get("spent_usd", 0))), (t("usage.gw_limit"), t("usage.unlimited") if dp.get("unlimited") else "$%s" % dp.get("limit_usd"))])) else: items.append(GrrKvItem([(t("usage.gw"), t("usage.gw_down"))])) models = u.get("models") or {} if models: items.append(GrrLabel(t("usage.models"))) items.append(GrrKvItem([(m, "%s/%s · %s ms%s" % (v.get("wins"), v.get("runs"), v.get("latency_ms"), " · " + t("usage.cooling") if v.get("cooled_until", 0) * 1000 > time.time() * 1000 else "")) for m, v in models.items()])) counters = u.get("counters") or {} if counters: items += [GrrLabel(t("usage.counters")), GrrKvItem([(k, str(v)) for k, v in counters.items()]), GrrNote(t("usage.counters_note"))] self.screen.items(items) def change(self, u): g, t = self.g, self.g.t def save(value): try: limit = int(float(value)) except ValueError: g.toast(t("apierr.bad_limit"), "bad") return g.call("PUT", "/api/usage/limit", {"limit": limit}, then=lambda _r: self.start()) g.edit(t("usage.change_limit"), str(u.get("limit", "")), save, text=t("usage.limit_text")) class GrrRulesView(GrrSubView): title_key = "settings.rules" def start(self): self.sub(self.g.events, "connector.update", lambda _d: self.start()) self.get("/api/rules", self.paint) def paint(self, res): g, t = self.g, self.g.t rules = res.get("rules", []) items = grr_group([GrrRowItem(r["text"], sub=t("rule." + r["action"]), wrap=True, chevron=True, action=(lambda r=r: g.router.go("/settings/rule/" + r["id"]))) for r in rules]) if rules else [] items.append(GrrRowItem(t("rules.add"), link=True, action=lambda: g.router.go("/settings/rule/new"))) items.append(GrrNote(t("rules.note"))) self.screen.items(items) class GrrRuleView(GrrView): fallback = "/settings/rules" def open(self): g, t = self.g, self.g.t self.new = self.params["id"] == "new" self.rule = {"text": "", "action": "allow", "bot_id": ""} self.standard(t("rules.edit"), back="/settings/rules", foot=[GrrButtonItem(t("rules.save"), self.save)]) if self.new: self.paint() else: self.get("/api/rules", self.loaded) def loaded(self, res): rule = next((r for r in res.get("rules", []) if r["id"] == self.params["id"]), None) if rule is None: self.fail_page(GrrError(404, "not_found", self.g.t("err.not_found")), "/settings/rules") return self.rule = dict(rule) self.paint() def paint(self): g, t = self.g, self.g.t rule = self.rule bots = [{"label": t("rules.all_bots"), "checked": not rule.get("bot_id"), "action": lambda: self.set("bot_id", "")}] bots += [{"label": b["name"], "checked": rule.get("bot_id") == b["id"], "action": (lambda b=b: self.set("bot_id", b["id"]))} for b in g.store.bots] current = g.store.bot(rule.get("bot_id")) if rule.get("bot_id") else None items = [GrrLabel(t("rules.when")), GrrChipsItem([{"label": current["name"] if current else t("rules.all_bots"), "icon": "user", "action": lambda: g.menu(bots)}]), GrrTextCard(rule.get("text", ""), placeholder=t("rules.placeholder"), action=lambda: g.edit(t("rules.text"), rule.get("text", ""), lambda v: self.set("text", v), True, 4, 300)), GrrLabel(t("rules.should"))] items += grr_group([GrrRowItem(t("rule." + a), check=rule.get("action") == a, action=(lambda a=a: self.set("action", a))) for a in (g.spec.get("rule_actions") or ["allow", "ask"])]) if not self.new: items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("rules.delete"), danger=True, action=self.delete)]) self.screen.items(items) def set(self, key, value): self.rule[key] = value self.paint() def save(self): g, rule = self.g, self.rule body = {"text": rule.get("text", ""), "action": rule.get("action", "allow"), "bot_id": rule.get("bot_id", "")} if self.new: self.call("POST", "/api/rules", body, lambda _r: g.router.back("/settings/rules")) else: self.call("PATCH", "/api/rules/" + rule["id"], body, lambda _r: g.router.back("/settings/rules")) def delete(self): self.call("DELETE", "/api/rules/" + self.rule["id"], None, lambda _r: self.g.router.back("/settings/rules")) class GrrAppearanceView(GrrSubView): title_key = "settings.appearance" def paint(self, res): g, t, ui = self.g, self.g.t, self.ui s = g.settings icons = {"system": "contrast", "day": "sun", "night": "moon"} themes = [{"label": t("appearance." + x), "icon": icons[x], "on": s.get("theme") == x, "action": (lambda x=x: g.save_settings({"theme": x}, lambda _m: self.paint(None)))} for x in (g.spec.get("themes") or ["system", "day", "night"])] variants = [] for v in (g.spec.get("variants") or ["black", "blue"]): accent = ((((g.spec.get("palette") or {}).get("variants") or {}).get(v) or {}).get("night") or {}).get("accent") or ui.palette.c("accent") variants.append({"label": t("appearance.v_" + v), "on": s.get("variant") == v, "art": (lambda ui_, bg, a=accent: [[TuiSpan("●", TuiStyle(fg=a, bg=ui_.palette.c(bg)))]]), "action": (lambda v=v: g.save_settings({"variant": v}, lambda _m: self.paint(None)))}) box = g.spec.get("ui_scale") or {"min": 70, "max": 150, "step": 5, "default": 100} size = int(s.get("ui_scale") or box["default"]) def step(delta): nxt = max(box["min"], min(box["max"], size + delta)) g.save_settings({"ui_scale": nxt}, lambda _m: self.paint(None)) items = [GrrTileGridItem(themes, cols=3, tile_h=4), GrrSpacer(1), GrrTileGridItem(variants, cols=2, tile_h=4), GrrSpacer(1)] items += grr_group([GrrRowItem(t("appearance.size"), value="%d%%" % size, sub=t("appearance.size_sub"), icon="tune", action=lambda: step(box["step"])), GrrRowItem(t("appearance.size_reset"), action=lambda: g.save_settings({"ui_scale": box["default"]}, lambda _m: self.paint(None)), tone="ink")]) self.screen.items(items) class GrrLanguageView(GrrSubView): title_key = "settings.language" def paint(self, res): g, t = self.g, self.g.t cur = g.settings.get("language", "system") rows = [GrrRowItem(t("lang.system") if l == "system" else GRR_LANG_NAMES.get(l, l), check=cur == l, action=(lambda l=l: g.save_settings({"language": l}, lambda _m: g.router.go("/settings/language", True)))) for l in (g.spec.get("languages") or ["system", "en"])] self.screen.items(grr_group(rows)) class GrrHapticsView(GrrSubView): title_key = "settings.haptics" def paint(self, res): g, t = self.g, self.g.t self.screen.items(grr_group([GrrRowItem(t("haptics.title"), toggle=bool(g.settings.get("haptics")), on_toggle=lambda v: g.save_settings({"haptics": v}))]) + [GrrNote(t("haptics.unsupported"))]) class GrrWidgetView(GrrSubView): title_key = "settings.widget" def paint(self, res): g, t = self.g, self.g.t url = g.client.base + "/widget" items = grr_group([GrrRowItem(t("widget.show"), toggle=bool(g.settings.get("widget")), on_toggle=lambda v: g.save_settings({"widget": v}))]) + [GrrNote(t("widget.note"))] items += grr_group([GrrRowItem(t("widget.open"), chevron="ext", action=lambda: g.open_url(url)), GrrRowItem(t("widget.copy"), sub=url, action=lambda: g.copy_text(url))]) self.screen.items(items) class GrrComputersView(GrrSubView): title_key = "settings.computer" def start(self): bots = self.g.store.bots self.batch([("GET", "/api/bots/%s/computer" % b["id"], None) for b in bots], self.paint) if bots else self.paint([]) def paint(self, res): g, t = self.g, self.g.t bots = g.store.bots rows = [GrrRowItem(b["name"], lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), sub=t("computer.disk") + ": " + t("computer.disk_" + c["disk"]["state"]) + (" · " + t("computer.busy") if c.get("busy") else ""), chevron=True, action=(lambda b=b, c=c: self.manage(b, c))) for b, c in zip(bots, res)] self.screen.items((grr_group(rows) if rows else [GrrEmptyItem("cpu", t("computer.none"))]) + [GrrNote(t("computer.each"))]) def manage(self, b, c): g, t = self.g, self.g.t def act(kind, force=False): def landed(_r): g.toast(t("computer.done_" + kind)) def failed(err): if isinstance(err, GrrError) and err.code == "busy" and not force: g.dialog(title=t("computer.working_title"), text=t("computer.working_text"), actions=[{"label": t("ui.cancel"), "value": False}, {"label": t("computer.anyway"), "value": True, "primary": True}], done=lambda v, _t: act(kind, True) if v else None) else: g.error(err) g.call("POST", "/api/bots/%s/computer/%s" % (b["id"], kind), {"force": True} if force else {}, landed, failed) rows = grr_group([GrrRowItem(t("computer.update"), sub=t("computer.update_sub"), link=True, wrap=True, action=lambda: act("update")), GrrRowItem(t("computer.reset"), sub=t("computer.reset_sub"), danger=True, wrap=True, action=lambda: act("reset"))]) g.pick(b["name"], rows) class GrrConnectorsView(GrrSubView): title_key = "settings.connect" def start(self): self.get("/api/connectors", self.paint) def paint(self, res): g, t = self.g, self.g.t rows = [GrrRowItem(t("conn." + c["name"]), sub=t("conn." + c["name"] + "_sub") + " · " + ", ".join(c.get("tools", [])), wrap=True, toggle=bool(c.get("enabled")), on_toggle=(lambda v, c=c: g.call("PUT", "/api/connectors/" + c["name"], {"enabled": v}))) for c in res.get("connectors", [])] self.screen.items(grr_group(rows) + [GrrNote(t("conn.note"))]) class GrrAdvancedView(GrrSubView): title_key = "settings.advanced" def start(self): self.get("/api/health", self.paint) def paint(self, h): g, t, ui = self.g, self.g.t, self.ui tiers = [{"label": t("tier." + x), "checked": g.settings.get("tier") == x, "action": (lambda x=x: g.save_settings({"tier": x}, lambda _m: self.start()))} for x in (g.spec.get("tiers") or ["auto", "fast", "expert"])] items = [GrrLabel(t("advanced.tier")), GrrChipsItem([{"label": t("tier." + g.settings.get("tier", "auto")), "icon": "sparkle", "action": lambda: g.menu(tiers)}]), GrrNote(t("advanced.tier_note"))] items += grr_group([GrrRowItem(t("advanced.replace_key"), icon="key", chevron=True, action=self.replace), GrrRowItem(t("advanced.export"), icon="download", sub=t("advanced.export_sub"), chevron=True, action=self.export)]) items += [GrrLabel(t("advanced.health")), GrrKvItem([(t("advanced.pressure"), t("pressure." + h.get("pressure", "calm"))), (t("advanced.turns"), "%s / %s" % (h.get("turns_active"), h.get("turns_cap"))), (t("advanced.db"), "%.1f MB" % (h.get("db_bytes", 0) / 1048576.0)), (t("advanced.uptime"), "%d min" % round(h.get("uptime", 0) / 60)), (t("advanced.version"), h.get("version", ""))])] self.screen.items(items) def replace(self): g, t = self.g, self.g.t def done(value, texts): if value != "save": return def work(): return g.client.request("POST", "/api/auth/login", {"api_key": texts[0]}, quiet=True) def landed(res, err): if err: g.error(err) else: g.session.add(res["token"], res["account"]) g.client.token = res["token"] g.stop_stream() g.signed_in = False g.enter() g.app.offload(work, landed) g.dialog(title=t("advanced.replace_key"), text=t("advanced.replace_text"), fields=[{"value": "", "placeholder": t("login.key_placeholder"), "secret": True}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.save"), "value": "save", "primary": True}], done=done) def export(self): g = self.g def work(): data, _cut, _type = g.client.fetch("/api/export", limit=GRR_BODY_MAX) path = os.path.join(os.getcwd(), "grr-export.json") with open(path, "wb") as fh: fh.write(data) return path g.app.offload(work, lambda path, err: g.error(err) if err else g.toast("Saved " + path)) class GrrBuilderView(GrrSubView): title_key = "builder.title" def start(self): self.get("/api/builder", self.paint) def paint(self, b): g, t = self.g, self.g.t if not b.get("admin"): self.screen.items([GrrEmptyItem("key", t("builder.not_admin"))]) return c = b["config"] builder = g.spec.get("builder") or {} def save(patch): self.call("PUT", "/api/builder/config", patch, lambda _r: self.start()) def chip(key, ids, label): return {"label": label(c[key]), "icon": "sparkle", "action": lambda: g.menu([{"label": label(i), "checked": c[key] == i, "action": (lambda i=i: save({key: i}))} for i in ids])} def number(key, label): return GrrRowItem(label, value=str(c[key]), chevron=True, action=lambda: g.edit(label, str(c[key]), lambda v: save({key: float(v)}) if re.match(r"^[0-9.]+$", v.strip()) else g.toast(t("apierr.invalid"), "bad"))) pct = lambda v: t("builder.unknown") if v is None else "%s%%" % v items = [GrrLabel(t("builder.state")), GrrKvItem([(t("builder.engine"), t("builder.engine_" + b["engine"])), (t("builder.weekly"), pct(b.get("weekly_pct"))), (t("builder.five_hour"), pct(b.get("five_hour_pct"))), (t("builder.ladder"), "%s / %s / %s %%" % (b["ladder"]["notice"], b["ladder"]["ask"], b["ladder"]["floor"]))])] items += grr_group([GrrRowItem(t("builder.enabled"), sub=t("builder.enabled_sub"), toggle=bool(c.get("enabled")), on_toggle=lambda v: save({"enabled": v}))]) items += [GrrLabel(t("builder.model")), GrrChipsItem([chip("model", builder.get("models", []), lambda x: x)]), GrrLabel(t("builder.effort")), GrrChipsItem([chip("effort", builder.get("efforts", []), lambda x: x)]), GrrLabel(t("builder.permission")), GrrChipsItem([chip("permission", builder.get("permissions", []), lambda x: t("builder.perm_" + x))]), GrrLabel(t("builder.tools")), GrrChipsItem([chip("tools", list((builder.get("tool_presets") or {}).keys()), lambda x: t("builder.tools_" + x))])] items += grr_group([number("budget_usd", t("builder.budget")), number("timeout", t("builder.timeout"))]) items += [GrrLabel(t("builder.notes")), GrrTextCard(c.get("notes", ""), placeholder=t("builder.notes_ph"), action=lambda: g.edit(t("builder.notes"), c.get("notes", ""), lambda v: save({"notes": v}), True, 6, g.ui.cap("BUILDER_NOTES_MAX_CHARS", 4000)))] items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("builder.sync"), sub=t("builder.sync_sub"), icon="refresh", chevron=True, action=lambda: self.call("POST", "/api/builder/sync", {}, lambda r: g.toast(t("builder.synced", mode=r.get("synced")))))]) items.append(GrrNote(t("builder.note"))) self.screen.items(items) class GrrFeedbackView(GrrSubView): title_key = "settings.feedback" def foot(self): return [GrrButtonItem(self.g.t("feedback.send"), self.send)] def start(self): self.text = "" self.bot_id = "" self.wants = False self.paint(None) def paint(self, res): g, t = self.g, self.g.t bots = [{"label": t("feedback.none"), "checked": not self.bot_id, "action": lambda: self.set("bot_id", "")}] bots += [{"label": b["name"], "checked": self.bot_id == b["id"], "action": (lambda b=b: self.set("bot_id", b["id"]))} for b in g.store.bots] bot = g.store.bot(self.bot_id) if self.bot_id else None items = [GrrTextCard(self.text, placeholder=t("feedback.placeholder"), action=lambda: g.edit(t("settings.feedback"), self.text, lambda v: self.set("text", v), True, 7, 4000)), GrrNote(t("feedback.note")), GrrLabel(t("feedback.which_bot")), GrrChipsItem([{"label": bot["name"] if bot else t("feedback.none"), "icon": "user", "action": lambda: g.menu(bots)}])] items += grr_group([GrrRowItem(t("feedback.want_response"), toggle=self.wants, on_toggle=lambda v: setattr(self, "wants", v))]) self.screen.items(items) def set(self, key, value): setattr(self, key, value) self.paint(None) def send(self): g, t = self.g, self.g.t if not self.text.strip(): g.toast(t("feedback.empty"), "bad") return self.call("POST", "/api/feedback", {"text": self.text, "bot_id": self.bot_id, "wants_response": self.wants}, lambda _r: (g.toast(t("feedback.thanks")), g.router.back("/settings"))) class GrrTimezoneView(GrrSubView): title_key = "settings.timezone" def start(self): try: import zoneinfo zones = sorted(zoneinfo.available_timezones()) except Exception: zones = ["UTC", "Europe/Amsterdam", "Europe/London", "America/New_York", "Asia/Tokyo"] if "UTC" not in zones: zones.insert(0, "UTC") self.zones = zones self.q = "" self.paint(None) def paint(self, res): g, t = self.g, self.g.t cur = g.settings.get("timezone") shown = [z for z in self.zones if self.q in z.lower()][:80] items = [GrrChipsItem([{"label": self.q or t("ui.search"), "icon": "search", "caret": False, "action": lambda: g.edit(t("ui.search"), self.q, lambda v: self.search(v))}])] items += grr_group([GrrRowItem(z, check=z == cur, action=(lambda z=z: g.save_settings({"timezone": z, "auto_tz": False}, lambda _m: g.router.back("/settings")))) for z in shown]) self.screen.items(items) def search(self, value): self.q = value.strip().lower() self.paint(None) # ---- templates class GrrTemplateView(GrrView): fallback = "/" def open(self): self.standard("", close=True, back="/") self.get("/api/templates/" + self.params["id"], self.loaded) def loaded(self, tpl): self.tpl = tpl self.paint() def paint(self): g, t, tpl = self.g, self.g.t, self.tpl snap = tpl["snapshot"] go = lambda part: (lambda: g.router.go("/template/%s/%s" % (tpl["id"], part))) items = grr_hero_items(self.ui, tpl["name"], tpl.get("description", ""), snap) items += [GrrLabel(t("template.details"))] items += grr_group([GrrRowItem(t("profile.instructions"), chevron=True, action=go("instructions")), GrrRowItem(t("profile.memories"), value=str(len(snap.get("memories", []))), chevron=True, action=go("memories")), GrrRowItem(t("profile.skills"), value=str(len(snap.get("skills", []))), chevron=True, action=go("skills"))]) items.append(GrrNote(t("template.scrub_note"))) items.append(GrrSpacer(1)) if tpl.get("state") == "published": items.append(GrrButtonItem(t("template.copy_link"), lambda: g.copy_text("%s/t/%s" % (g.client.base, tpl["slug"])))) items.append(GrrButtonItem(t("template.unpublish"), lambda: self.set_state("unpublish"), "secondary")) else: items.append(GrrButtonItem(t("template.publish"), lambda: self.set_state("publish"))) self.screen.items(items) def set_state(self, verb): self.call("POST", "/api/templates/%s/%s" % (self.tpl["id"], verb), {}, lambda res: (self.tpl.update(res), self.paint())) def grr_hero_items(ui, name, desc, snap): class Hero(GrrItem): def build(self, ui_, width, selected): out = [] for row in GrrAvatar.rows(ui_, {"shape": snap.get("shape"), "color": snap.get("color")}, "bg", hero=True): out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui_.st())] + row, width, ui_.st())) out.append([TuiSpan(grr_clip(name, width - 4).center(width), ui_.st("ink", "bg", bold=True))]) for line in grr_wrap(desc, width - 8)[:5]: out.append([TuiSpan(line.center(width), ui_.st("muted", "bg"))]) return out return [GrrSpacer(1), Hero()] class GrrTemplatePartView(GrrView): fallback = "/" def open(self): self.standard(self.g.t("profile." + self.params["part"]), back="/template/" + self.params["id"]) self.get("/api/templates/" + self.params["id"], self.loaded) def loaded(self, tpl): g, t = self.g, self.g.t snap, part = tpl["snapshot"], self.params["part"] show = lambda title, text: (lambda: g.dialog(title=title, text=text, w=72)) if part == "instructions": items = [GrrLabel(t("profile.instruction")), GrrTextCard(snap.get("instructions") or t("template.none"))] elif part == "memories": items = grr_group([GrrRowItem(m, wrap=True, chevron=True, action=show(t("profile.memory"), m)) for m in snap.get("memories", [])]) or [GrrEmptyItem("inbox", t("template.none"))] else: items = grr_group([GrrRowItem(s["name"], sub=s.get("description", ""), chevron=True, action=show(s["name"], s.get("content", ""))) for s in snap.get("skills", [])]) or [GrrEmptyItem("skills", t("template.none"))] self.screen.items(items) class GrrPublicTemplateView(GrrView): fallback = "/" def open(self): self.standard("grr", back=False) self.get("/api/t/" + urllib.parse.quote(self.params["slug"]), self.loaded, lambda err: self.fail_page(err)) def loaded(self, tpl): g, t = self.g, self.g.t snap = tpl["snapshot"] items = grr_hero_items(self.ui, tpl["name"], tpl.get("description", ""), snap) + [GrrLabel(t("template.details"))] items += grr_group([GrrRowItem(t("profile.instructions"), sub=(snap.get("instructions") or "")[:160]), GrrRowItem(t("profile.memories"), value=str(len(snap.get("memories", [])))), GrrRowItem(t("profile.skills"), value=str(len(snap.get("skills", [])))), GrrRowItem(t("profile.routines"), value=str(len(snap.get("routines") or [])))]) items += [GrrSpacer(1), GrrButtonItem(t("template.add"), lambda: self.call("POST", "/api/templates/import", {"slug": tpl["slug"]}, lambda bot: g.store.refresh(lambda _e: g.router.go("/chat/" + bot["chat"]["id"]))))] self.screen.items(items) GRR_TEXT_MIMES = ("text/", "application/json", "application/xml", "application/javascript", "application/x-sh", "application/yaml", "application/toml") class GrrShellExtras: """Helpers every view reaches through the shell: files, links, the clipboard (OSC 52).""" @staticmethod def copy_text(shell, text): data = base64.b64encode(text.encode("utf-8")).decode("ascii") try: os.write(shell.app._stdout_fd if shell.app._stdout_fd is not None else sys.stdout.fileno(), ("\x1b]52;c;%s\x07" % data).encode("ascii")) except OSError: pass shell.toast(shell.t("chat.copied")) @staticmethod def open_url(shell, url): opener = shutil.which("xdg-open") or shutil.which("open") if opener and re.match(r"^https?://", url, re.I): try: subprocess.Popen([opener, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return except OSError: pass shell.dialog(title=shell.t("ui.share"), text=url, w=76) @staticmethod def open_file(shell, f): view = GrrFileViewer(shell, f) view.open() class GrrFileViewer: """The file viewer: text in a scrolling dialog with line numbers, other files saved to a temporary path and opened by the desktop (or named).""" def __init__(self, shell, f): self.g = shell self.f = f def open(self): g, f = self.g, self.f mime = f.get("mime") or "" external = bool(f.get("url")) path = f["url"] if external else "/api/files/" + str(f["id"]) def work(): if external: return None return g.client.fetch(path, limit=GRR_VIEWER_MAX_BYTES) def landed(res, err): if err: g.error(err) return self.show(res) g.app.offload(work, landed) def show(self, res): g, t, f = self.g, self.g.t, self.f name = f.get("name") or str(f.get("id")) data, cut, ctype = res if res else (b"", False, "") mime = (f.get("mime") or ctype or "").split(";")[0] is_text = mime.startswith(GRR_TEXT_MIMES) or (not mime and b"\x00" not in data[:2048]) actions = [{"label": t("ui.close"), "value": "close", "primary": True}, {"label": t("ui.download"), "value": "save"}] if is_text and data: text = data.decode("utf-8", "replace") numbered = "\n".join("%4d %s" % (i + 1, line) for i, line in enumerate(text.split("\n")[:5000])) body = TuiLog(h=max(6, g.app.rows - 12), style=g.ui.st("ink", "code-bg"), focusable=True) for line in numbered.split("\n"): body.append(line, g.ui.st("ink", "code-bg")) g.dialog(title=name, text=(t("files.cut", shown="2 MB", total="") if cut else ""), w=min(g.app.cols - 2, 110), body=body, actions=actions, done=lambda v, _t: self.save(data) if v == "save" else None) else: g.dialog(title=name, text="%s · %s" % (mime or "file", GrrFormat.bytes(f.get("size", len(data)))), actions=[{"label": t("ui.close"), "value": "close"}, {"label": t("viewer.save"), "value": "save", "primary": True}], done=lambda v, _t: self.save(data, True) if v == "save" else None) def save(self, data, open_after=False): g, f = self.g, self.f name = re.sub(r"[^A-Za-z0-9._-]", "_", f.get("name") or str(f.get("id")))[:80] or "file" def work(): body = data if not body and f.get("id"): body = g.client.fetch("/api/files/%s?download=1" % f["id"], limit=GRR_BODY_MAX)[0] path = os.path.join(tempfile.gettempdir(), "grr-" + name) with open(path, "wb") as fh: fh.write(body) return path def landed(path, err): if err: g.error(err) return g.toast("Saved " + path) opener = shutil.which("xdg-open") or shutil.which("open") if open_after and opener: try: subprocess.Popen([opener, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) except OSError: pass g.app.offload(work, landed) GrrShell.copy_text = lambda self, text: GrrShellExtras.copy_text(self, text) GrrShell.open_url = lambda self, url: GrrShellExtras.open_url(self, url) GrrShell.open_file = lambda self, f: GrrShellExtras.open_file(self, f) # --------------------------------------------------------------------------- # Part F: chat. Message items, the composer, the working row, live process # cards and the remote terminal of a bot's computer. # --------------------------------------------------------------------------- def grr_bubble_styles(ui, side): bg = "bubble-user" if side == "out" else "bubble-bot" base = ui.st("ink", "bg") return {"base": base, "bubble": ui.st("ink", bg), "meta": ui.st("muted", bg), "tick": ui.st("muted", bg), "name": ui.st("muted", bg, bold=True), "b": ui.st("ink", bg, bold=True), "i": ui.st("soft", bg), "c": ui.st("soft", "code-bg"), "code": ui.st("accent", "code-bg"), "h": ui.st("accent", bg, bold=True), "bullet": ui.st("accent", bg, bold=True), "quote": ui.st("muted", bg), "bar": ui.st("accent", bg)} class GrrControlsItem(GrrItem): """An item with a row of small controls that Left and Right walk through. Subclasses fill `controls` as (label, callable) and draw.""" def __init__(self): super().__init__() self.controls = [] def nav(self, direction): if direction in ("left", "right") and len(self.controls) > 1: nxt = max(0, min(len(self.controls) - 1, self.stop + (1 if direction == "right" else -1))) if nxt != self.stop: self.stop = nxt self.touch() return True return False def activate(self): if self.controls: self.controls[min(self.stop, len(self.controls) - 1)][1]() class GrrMessageItem(GrrControlsItem): """A text message: your bubble on the right, a bot's on the left with its steps and an action bar (copy, good, bad, try again).""" selectable = True def __init__(self, view, m, last=False): super().__init__() self.view = view self.m = m self.last = last self.open_steps = False self.key = m["id"] self.rebuild_controls() def rebuild_controls(self): g, t = self.view.g, self.view.g.t m = self.m self.controls = [] if m.get("role") == "user": return steps = (m.get("meta") or {}).get("steps") or [] if steps: self.controls.append(("steps", self.toggle_steps)) self.controls.append(("copy", lambda: g.copy_text(grr_text(m.get("content"))))) self.controls.append(("good", lambda: self.view.rate(m, "up"))) self.controls.append(("bad", lambda: self.view.rate(m, "down"))) if self.last: self.controls.append(("again", self.view.regenerate)) self.stops = len(self.controls) def set_last(self, last): if last != self.last: self.last = last self.rebuild_controls() self.touch() def toggle_steps(self): self.open_steps = not self.open_steps self.touch() def build(self, ui, width, selected): v, m = self.view, self.m meta = m.get("meta") or {} g, t = v.g, v.g.t mar = grr_margin(width) inner = width - 2 * mar page = ui.st("ink", "bg") out = [] user = m.get("role") == "user" side = "out" if user else "in" styles = grr_bubble_styles(ui, side) sender = "" if not user and v.chat and v.chat.get("kind") == "group": bot = v.bot_of(m.get("sender")) sender = bot["name"] if bot else "" files = meta.get("files") or [] for fid in files if user else []: chip = " %s %s " % (ui.icon("image"), t("chat.attachment")) out.append(grr_line([TuiSpan(" " * (width - mar - len(chip)), page), TuiSpan(chip, ui.st("soft", "bubble-user"))], width, page)) self.hit(len(out) - 1, 0, width, (lambda fid=fid: g.open_file({"id": fid, "name": "", "mime": "image/*"}))) text = grr_text(m.get("content")) if user and meta.get("routine"): text = t("chat.routine") + "\n" + text if text: if m.get("kind") == "text" and not user and meta.get("error"): styles["bubble"] = ui.st("danger", "bubble-bot") rows = tui_chat_bubble_rows(text, inner, side, styles, sender=sender, markdown=not user, boxed=True) for row in rows: out.append(grr_line([TuiSpan(" " * mar, page)] + row + [TuiSpan(" " * mar, page)], width, page)) if not user: steps = meta.get("steps") or [] if steps: label = "%s %s" % ("▾" if self.open_steps else "▸", t("chat.steps", n=len(steps))) out.append(self.control_row(ui, width, [(label, "steps")], selected, len(out))) if self.open_steps: for s in steps: mark = ui.icon("check") if s.get("ok") else "!" line = " %s %s (%ss)" % (mark, grr_clip(s.get("summary", ""), inner - 12), s.get("secs", "")) out.append(grr_line([TuiSpan(" " * mar + line, ui.st("success" if s.get("ok") else "danger", "bg"))], width, page)) if meta.get("smile") if isinstance(meta.get("smile"), str) else None: out.append(grr_line([TuiSpan(" " * (mar + 1) + grr_clip(meta["smile"], inner - 2), ui.st("faint", "bg"))], width, page)) if (self.last or selected) and m.get("kind") == "text": glyph = {"copy": "copy", "good": "thumbup", "bad": "thumbdown", "again": "refresh"} bar = [(ui.icon(glyph[name]), name) for name, _fn in self.controls if name in glyph] out.append(self.control_row(ui, width, bar, selected, len(out))) return out def control_row(self, ui, width, parts, selected, row): mar = grr_margin(width) page = ui.st("ink", "bg") spans, x = [TuiSpan(" " * (mar + 1), page)], mar + 1 names = [n for n, _f in self.controls] for text, name in parts: idx = names.index(name) if name in names else -1 on = selected and idx == self.stop label = " %s " % text spans.append(TuiSpan(label, ui.st("ink" if on else "muted", "rule-strong" if on else "bg", bold=on))) spans.append(TuiSpan(" ", page)) fn = self.controls[idx][1] if idx >= 0 else None if fn: self.hit(row, x, x + len(label), fn) x += len(label) + 1 return grr_line(spans, width, page) def grr_card_rows(ui, width, lines, bg="card"): """Wrap card body lines (span lists) in the rounded card used by every message card.""" return [GrrCard.top(ui, width, bg)] + [GrrCard.line(ui, width, line, bg) for line in lines] + [GrrCard.bottom(ui, width, bg)] class GrrApprovalItem(GrrControlsItem): """A card that asks the owner to allow or deny what a bot wants to do.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] self.setup() def setup(self): g, t, m = self.view.g, self.view.g.t, self.m meta = m.get("meta") or {} self.controls = [] if meta.get("state") == "pending": def decide(d): g.call("POST", "/api/messages/%s/decision" % m["id"], {"decision": d}) self.controls = [(t("chat.allow_once"), lambda: decide("allow")), (t("chat.deny"), lambda: decide("deny"))] if meta.get("tool") == "browser": self.controls.append((t("chat.always_allow"), lambda: decide("always"))) self.stops = max(1, len(self.controls)) self.selectable = bool(self.controls) def build(self, ui, width, selected): v, m = self.view, self.m t = v.g.t meta = m.get("meta") or {} bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else {"name": "Bot"}) cw = GrrCard.inner(width) lines = [[TuiSpan(" " + grr_clip(t("chat.needs_approval", name=bot["name"]), cw), ui.st("ink", "card", bold=True))]] for line in grr_wrap(grr_text(m.get("content")), cw - 1)[:8]: lines.append([TuiSpan(" " + line, ui.st("soft", "card"))]) lines.append([TuiSpan(" ", ui.st("ink", "card"))]) if meta.get("state") == "pending": spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): on = selected and pos == self.stop text = " %s " % label primary = pos == 0 spans.append(TuiSpan(text, ui.st("accent-ink" if primary and not on else "ink", ("ink" if on and primary else ("rule-strong" if on else ("accent" if primary else "rule-strong"))), bold=True))) spans.append(TuiSpan(" ", ui.st("ink", "card"))) self.hit(len(lines) + 1, x, x + len(text), fn) x += len(text) + 1 lines.append(spans) else: state = "auto" if meta.get("auto") else grr_text(meta.get("state")) ok = meta.get("state") == "allowed" lines.append([TuiSpan(" %s %s" % (ui.icon("check" if ok else "close"), t("chat.state_" + state)), ui.st("success" if ok else "danger", "card"))]) return grr_card_rows(ui, width, lines) class GrrQuestionItem(GrrControlsItem): """A question card with lettered options; answering is a click or Enter.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] meta = m.get("meta") or {} self.answer = meta.get("answer") self.options = list(meta.get("options") or []) t = view.g.t if self.answer is None: self.controls = [(o, (lambda o=o: view.answer(m, o))) for o in self.options] + [(t("chat.skip"), lambda: view.answer(m, "(skipped)"))] self.stops = max(1, len(self.controls)) self.selectable = self.answer is None def nav(self, direction): if direction in ("up", "down") and len(self.controls) > 1: nxt = self.stop + (1 if direction == "down" else -1) if 0 <= nxt < len(self.controls): self.stop = nxt self.touch() return True return False def build(self, ui, width, selected): t = self.view.g.t cw = GrrCard.inner(width) lines = [[TuiSpan(" " + grr_clip(grr_text(self.m.get("content")), cw), ui.st("ink", "card", bold=True))]] if self.answer is None: for pos, option in enumerate(self.options): on = selected and pos == self.stop bg = "rule-strong" if on else "sunken" row = [TuiSpan(" ", ui.st("ink", "card")), TuiSpan(" %s " % chr(65 + pos), ui.st("muted", bg)), TuiSpan(" " + grr_clip(option, cw - 6), ui.st("ink", bg))] lines.append(grr_line(row, cw, ui.st("ink", bg))) self.hit(len(lines), 0, width, self.controls[pos][1]) skip = selected and self.stop == len(self.options) lines.append([TuiSpan(" %s %s" % (ui.icon("close"), t("chat.skip")), ui.st("ink" if skip else "muted", "rule-strong" if skip else "card"))]) self.hit(len(lines), 0, width, self.controls[-1][1]) lines.append([TuiSpan(" " + t("chat.answer_below"), ui.st("faint", "card"))]) else: lines.append([TuiSpan(" ", ui.st("ink", "card")), TuiSpan(" %s %s " % (grr_clip(self.answer, cw - 6), ui.icon("check")), ui.st("ink", "sunken"))]) return grr_card_rows(ui, width, lines) class GrrFileMsgItem(GrrItem): """A file a bot shares: name, size, caption. Enter opens the viewer.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] self.action = self.open def open(self): meta = self.m.get("meta") or {} self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": grr_text(self.m.get("content"))}) def build(self, ui, width, selected): meta = self.m.get("meta") or {} bg = "rule-strong" if selected else "card" cw = GrrCard.inner(width) mime = meta.get("mime") or "" icon = "image" if mime.startswith("image/") else ("mic" if mime.startswith("audio/") else "file") lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(grr_text(meta.get("name")), cw - 4)), ui.st("ink", bg, bold=True))], [TuiSpan(" %s · %s" % (mime, GrrFormat.bytes(meta.get("size", 0))), ui.st("muted", bg))]] if self.m.get("content"): lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 4), ui.st("soft", bg))]) return grr_card_rows(ui, width, [grr_line(l, cw, ui.st("ink", bg)) for l in lines], bg) class GrrFilesMsgItem(GrrControlsItem): """Several files in one message: a list the arrows walk through.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] files = (m.get("meta") or {}).get("files") or [] self.controls = [(f.get("name", ""), (lambda f=f: view.g.open_file({"id": f.get("file_id"), "name": f.get("name"), "mime": f.get("mime"), "kind": f.get("file_kind")}))) for f in files] self.stops = max(1, len(files)) self.files = files def nav(self, direction): if direction in ("up", "down") and len(self.controls) > 1: nxt = self.stop + (1 if direction == "down" else -1) if 0 <= nxt < len(self.controls): self.stop = nxt self.touch() return True return False def build(self, ui, width, selected): cw = GrrCard.inner(width) lines = [] for pos, f in enumerate(self.files): on = selected and pos == self.stop bg = "rule-strong" if on else "card" row = [TuiSpan(" %s %s" % (ui.icon("image" if (f.get("mime") or "").startswith("image/") else "file"), grr_clip(f.get("name", ""), cw - 14)), ui.st("ink", bg, bold=on))] row = grr_line(row, cw - 10, ui.st("ink", bg)) + [TuiSpan(GrrFormat.bytes(f.get("size", 0)).rjust(10), ui.st("muted", bg))] lines.append(row) self.hit(pos + 1, 0, width, self.controls[pos][1]) if self.m.get("content"): lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 2), ui.st("soft", "card"))]) return grr_card_rows(ui, width, lines) class GrrTemplateMsgItem(GrrControlsItem): selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] g, t = view.g, view.g.t meta = m.get("meta") or {} if meta.get("state") == "published": first = (t("template.copy_link"), lambda: g.copy_text("%s/t/%s" % (g.client.base, meta.get("slug")))) else: first = (t("template.publish"), lambda: g.call("POST", "/api/templates/%s/publish" % meta.get("template_id"), {})) self.controls = [first, (t("template.details"), lambda: g.router.go("/template/" + str(meta.get("template_id"))))] self.stops = 2 def build(self, ui, width, selected): v, m = self.view, self.m t = v.g.t meta = m.get("meta") or {} bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else None) live = meta.get("state") == "published" cw = GrrCard.inner(width) lines = [] for row in GrrAvatar.rows(ui, bot, "card"): lines.append([TuiSpan(" " * ((cw - tui_span_width(row)) // 2), ui.st("ink", "card"))] + row) badge = t("template.published") if live else t("template.unpublished") lines.append([TuiSpan(" " + grr_clip(grr_text(m.get("content")), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))]) for line in grr_wrap(grr_text(meta.get("description")), cw - 2)[:3]: lines.append([TuiSpan(" " + line, ui.st("muted", "card"))]) spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): on = selected and pos == self.stop text = " %s " % label spans.append(TuiSpan(text, ui.st("accent-ink" if pos == 0 else "ink", "ink" if on and pos == 0 else ("rule-strong" if on or pos else "accent"), bold=True))) spans.append(TuiSpan(" ", ui.st("ink", "card"))) self.hit(len(lines) + 1, x, x + len(text), fn) x += len(text) + 1 lines.append(spans) return grr_card_rows(ui, width, lines) class GrrRemoteTerminal(TuiTerminal): """A terminal panel for a pty that lives on the server: bytes arrive over server-sent events and keys go back over POST. The widget, its VT screen and its key mapping are the stock TuiTerminal ones.""" def __init__(self, shell, stream_path, input_path, **kwargs): super().__init__(cmd=["true"], interactive=True, **kwargs) self.shell = shell self.stream_path = stream_path self.input_path = input_path self.stream = None self.sent = bytearray() self.flush_timer = None self.size_timer = None self.running = True self.connecting = True self.on_exit = None self.on_leave = None def spawn(self): return False def attach(self, start_body=None): self.stream = GrrStream(self.shell.client, self.stream_path, self.on_event, self.shell.app.run_on_loop, reconnect=False) self.stream.start() def detach(self): if self.stream is not None: self.stream.stop() self.stream = None def on_event(self, name, data): if name == "out": if data.get("skipped"): self.screen.feed(("\r\n\x1b[2m[%s]\x1b[0m\r\n" % self.shell.t("proc.skipped", n=data["skipped"])).encode("utf-8")) if data.get("d"): try: self.screen.feed(base64.b64decode(data["d"])) except (ValueError, TypeError): pass self.connecting = False elif name == "exit": self.running = False if callable(self.on_exit): self.on_exit() self.touch() def _apply_winsize(self): if self.size_timer is not None: self.shell.app.cancel_timer(self.size_timer) self.size_timer = self.shell.app.after(0.15, self.send_size) def send_size(self): self.size_timer = None body = {"cols": self.screen.cols, "rows": self.screen.rows} self.shell.call("POST", self.input_path, body, fail=lambda _e: None) def fds(self): return [] def poll_exit(self): return None def terminate(self): self.detach() def drain_pending(self): return None def on_key(self, event): if event.key in ("ctrl+]", "ctrl+}") and callable(self.on_leave): event.stop() self.on_leave() return if not self.running: return data = TuiTerminal.key_bytes(event) if data: event.stop() self.queue(data) def on_paste(self, event): event.stop() self.queue((event.text or "").replace("\n", "\r").encode("utf-8", "replace")) def send_text(self, text): self.queue(text.encode("utf-8", "replace")) def queue(self, data): self.sent += data if self.flush_timer is None: self.flush_timer = self.shell.app.after(0.025, self.flush) def flush(self): self.flush_timer = None chunk = bytes(self.sent[:4096]) del self.sent[:4096] if chunk: self.shell.call("POST", self.input_path, {"data": chunk.decode("utf-8", "replace")}, fail=lambda _e: None) if self.sent: self.flush_timer = self.shell.app.after(0.025, self.flush) class GrrProcessItem(GrrControlsItem): """The live card of a command a bot runs: the command, its state, the last rows of its screen, and ^C, Stop, Type and Expand. A running card follows the server's byte stream into its own VT screen; a finished one shows the stored transcript.""" selectable = True ROWS = 8 ROWS_TALL = 24 def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] self.tall = False self.cols = 80 self.screen = TuiVtScreen(self.ROWS_TALL, 80) self.stream = None self.fed = False self.rebuild_controls() self.sync() @property def running(self): return (self.m.get("meta") or {}).get("state") == "running" def pid(self): return (self.m.get("meta") or {}).get("pid") def rebuild_controls(self): g, t = self.view.g, self.view.g.t pid = self.pid() self.controls = [] if self.running: self.controls.append(("^C", lambda: g.call("POST", "/api/processes/%s/input" % pid, {"data": "\x03"}, fail=lambda _e: None))) self.controls.append((t("proc.stop"), lambda: g.call("POST", "/api/processes/%s/input" % pid, {"kill": True}))) self.controls.append(("Type", self.type_in)) self.controls.append((t("proc.expand"), self.toggle_tall)) self.stops = len(self.controls) def update(self, m): was = self.running self.m = m self.rebuild_controls() if was and not self.running: self.finish() self.touch() def sync(self): if self.running: self.follow() else: self.replay() def follow(self): if self.stream is not None: return v = self.view self.stream = GrrStream(v.g.client, "/api/processes/%s/stream?from=0" % self.pid(), self.on_event, v.g.app.run_on_loop, reconnect=False) self.stream.start() def on_event(self, name, data): if name == "out": if data.get("skipped"): self.screen.feed(("\r\n[%s]\r\n" % self.view.g.t("proc.skipped", n=data["skipped"])).encode("utf-8")) if data.get("d"): try: self.screen.feed(base64.b64decode(data["d"])) except (ValueError, TypeError): pass self.fed = True self.touch() elif name == "exit": self.stop_stream() def stop_stream(self): if self.stream is not None: self.stream.stop() self.stream = None def finish(self): self.stop_stream() if not self.fed: self.replay() def replay(self): self.screen = TuiVtScreen(self.ROWS_TALL, self.cols) self.screen.feed(((self.m.get("meta") or {}).get("transcript") or "").encode("utf-8", "replace")) def destroy(self): self.stop_stream() def toggle_tall(self): self.tall = not self.tall self.touch() def type_in(self): v = self.view g = v.g pid = self.pid() term = GrrRemoteTerminal(g, "/api/processes/%s/stream?from=0" % pid, "/api/processes/%s/input" % pid, w=min(g.app.cols - 6, 100), h=min(g.app.rows - 8, 28), border=True, style=g.ui.st("ink", "sunken"), border_style=g.ui.st("accent", "bg")) dlg = g.dialog(title=grr_text(self.m.get("content")), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}], done=lambda _v, _t: term.detach()) term.on_exit = lambda: g.close_dialog(dlg, "done") term.attach() g.app.set_focus(term) def build(self, ui, width, selected): t = self.view.g.t meta = self.m.get("meta") or {} state = meta.get("state", "running") cw = GrrCard.inner(width) exit_code = meta.get("exit") status = t("proc.state_" + state, code="?" if exit_code is None else exit_code) tone = "accent" if state == "running" else ("danger" if state == "exited" and exit_code else "muted") head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(grr_text(self.m.get("content")), cw - len(status) - 6), ui.st("ink", "card", bold=True))] head = grr_line(head, cw - len(status) - 1, ui.st("ink", "card")) + [TuiSpan(status + " ", ui.st(tone, "card"))] lines = [head] rows = self.ROWS_TALL if self.tall else self.ROWS screen = self.screen base = ui.st("ink", "sunken") used = [r for r in range(screen.rows) if any(c is not None and c[0] != " " for c in screen.grid[r])] last = used[-1] if used else -1 first = max(0, last - rows + 1) for r in range(first, first + rows): if r >= screen.rows: lines.append([TuiSpan(" " * cw, base)]) continue cells = [] for col in range(min(cw - 1, screen.cols)): cell = screen.grid[r][col] cells.append((cell[0] if cell else " ", TuiVtScreen.style_for(cell, base))) lines.append([TuiSpan(" ", ui.st("ink", "card"))] + grr_line(tui_runs(cells), cw - 1, base)) spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): on = selected and pos == self.stop text = " %s " % label spans.append(TuiSpan(text, ui.st("danger" if label == t("proc.stop") else "ink", "rule-strong" if on else "sunken", bold=on))) spans.append(TuiSpan(" ", ui.st("ink", "card"))) self.hit(len(lines) + 1, x, x + len(text), fn) x += len(text) + 1 lines.append(spans) return grr_card_rows(ui, width, lines) class GrrComposerBox(TuiTextBox): """The chat input: PageUp and PageDown scroll the conversation when the message is a single line.""" def __init__(self, view, **kwargs): super().__init__(**kwargs) self.view = view def on_key(self, event): key = event.key or "" if key in ("pageup", "pagedown") and len(self.lines) <= 1: event.stop() self.view.log.scroll_by(-max(1, self.view.log._content_h() - 1) if key == "pageup" else max(1, self.view.log._content_h() - 1)) return if key in ("ctrl+up", "ctrl+down"): event.stop() self.view.log.scroll_by(-3 if key == "ctrl+up" else 3) return super().on_key(event) class GrrChatView(GrrView): """Bot chats, group chats and general chats (ask, imagine, build, private) with the live message stream, composer and side pane.""" fallback = "/" def open(self): g, ui, t = self.g, self.ui, self.g.t self.chat = None self.messages = [] self.items = {} self.attachments = [] self.mode = self.query.get("mode") or "ask" self.tier = g.settings.get("tier") or "auto" self.private = False self.has_more = False self.busy = False self.new_below = False self.spin = 0 self.pane = None self.root = TuiCols(w="1fr", h="100%", style=ui.st("ink", "bg")) self.col = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.modebar = GrrBar(ui, style=ui.st("ink", "bg")) self.log = GrrPage(ui, stick=True, auto_select=False, style=ui.st("ink", "bg"), h="1fr") self.log.on_bottom = self.on_bottom self.working = TuiRich([], h=1, style=ui.st("ink", "bg")) self.attach_row = TuiRich([], h=1, style=ui.st("ink", "bg")) self.attach_row.visible = False self.box = GrrComposerBox(self, h=1, border=True, rounded=True, padding=(0, 1), w="1fr", style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.on_send(), max_chars=ui.cap("MESSAGE_MAX_CHARS", 20000), mentions=lambda prefix: [(("@%s" % b["name"].replace(" ", "")), b["name"].replace(" ", "")) for b in g.store.bots if b["name"].replace(" ", "").lower().startswith((prefix or "").lower())]) self.plus = TuiButton(ui.icon("plus"), brackets=False, border=False, w=3, h=1, style=ui.st("ink", "card", bold=True), on_press=lambda _e: self.attach_menu()) self.send_btn = TuiButton("", brackets=False, border=False, w=5, h=1, style=ui.st("accent-ink", "accent", bold=True), on_press=lambda _e: self.send_action()) self.composer = TuiCols(h=3, gap=1, padding=(1, 1, 0, 1), style=ui.st("ink", "bg")) self.composer.add(self.plus, self.box, self.send_btn) self.col.add(self.bar, self.modebar, self.log, self.working, self.attach_row, self.composer) self.root.add(self.col) self.box.on_change = lambda e: self.fit_box() self.modebar.visible = False chat_id = self.params["id"] self.sub(g.events, "message.new", self.on_new) self.sub(g.events, "message.update", self.on_update) self.sub(g.events, "status", self.on_status) self.sub(g.events, "chat.update", self.on_chat_update) self.sub(g.events, "link", lambda up: self.catch_up() if up and self.chat else None) self.sub(g.store, "changed", lambda _d: self.refresh_head()) self.every(0.15, self.tick) self.screen = None if chat_id == "new": self.refresh_head() self.paint_all() self.show(self.root, self.box) self.fit_box() self.set_send_mode() else: self.show(self.root, self.box) self.batch([("GET", "/api/chats/" + chat_id, None), ("GET", "/api/chats/%s/messages" % chat_id, None)], self.loaded) # ---- data def loaded(self, res): chat, page = res g = self.g self.chat = chat self.messages = page.get("messages", []) self.has_more = page.get("has_more", False) self.mode = chat.get("mode") or "ask" self.tier = chat.get("tier") or "auto" self.pane = GrrPane(self) if self.chat_is_bot() else None self.refresh_head() self.paint_all() self.fit_box() self.set_send_mode() if chat.get("unread"): g.app.after(1.2, lambda: None if self.closed else g.call("POST", "/api/chats/%s/read" % chat["id"], {}, fail=lambda _e: None)) draft = g.drafts.get(chat["id"]) if draft: self.box.lines = draft.split("\n") self.box.cy = len(self.box.lines) - 1 self.box.cx = len(self.box.lines[-1]) self.log.scroll_to_end() self.restore_pane() def destroy(self): g = self.g if self.chat is not None: text = self.box.value.strip() if text: if len(g.drafts) >= GRR_DRAFT_KEEP: g.drafts.pop(next(iter(g.drafts))) g.drafts[self.chat["id"]] = self.box.value else: g.drafts.pop(self.chat["id"], None) if self.chat.get("kind") == "private": g.client_call_quiet("DELETE", "/api/chats/" + self.chat["id"]) else: g.client_call_quiet("POST", "/api/chats/%s/read" % self.chat["id"], {}) for item in self.items.values(): if hasattr(item, "destroy"): item.destroy() if self.pane is not None: self.pane.destroy() super().destroy() def chat_is_bot(self): return bool(self.chat) and self.chat.get("kind") in ("bot", "group") and bool(self.bots()) def is_general(self): return not self.chat or self.chat.get("kind") in ("general", "private") def is_private(self): return self.private or (self.chat and self.chat.get("kind") == "private") def bots(self): if not self.chat: return [] return [b for b in (self.g.store.bot(i) for i in self.chat.get("bot_ids", [])) if b] def bot_of(self, bot_id): return self.g.store.bot(bot_id) if bot_id else None def placeholder(self): t = self.g.t if not self.is_general(): b = self.bots() return t("chat.ask_bot", name=(b[0]["name"] if b else "") if self.chat.get("kind") == "bot" else self.chat.get("title", "")) if self.is_private(): return t("chat.private_placeholder") return t("chat.mode_%s_ph" % self.mode) # ---- header and mode bar def refresh_head(self): g, ui, t = self.g, self.ui, self.g.t c = self.chat parts = [{"text": " %s " % ui.icon("back"), "fn": lambda: g.router.back("/"), "bg": "card", "bold": True}] if c and c.get("kind") == "bot": b = (self.bots() or [None])[0] chip = (GrrAvatar.chip(ui, b) if b else []) + [TuiSpan(" " + (b["name"] if b else c.get("title", "")) + " ", ui.st("ink", "card", bold=True))] parts.append({"text": "", "fill": True}) parts.append({"spans": [TuiSpan(" ", ui.st("ink", "card"))] + chip, "fn": (lambda: g.router.go("/bot/" + b["id"])) if b else None, "fill": False}) parts.append({"text": "", "fill": True}) elif c and c.get("kind") == "group": chip = [TuiSpan(" ", ui.st("ink", "card"))] for b in self.bots()[:3]: chip += [TuiSpan("●", TuiStyle(fg=b.get("color") or ui.palette.c("accent"), bg=ui.palette.c("card")))] chip.append(TuiSpan(" %s " % c.get("title", ""), ui.st("ink", "card", bold=True))) parts.append({"text": "", "fill": True}) parts.append({"spans": chip, "fn": self.members}) parts.append({"text": "", "fill": True}) else: parts.append({"text": (c.get("title") if c and c.get("title") else (t("chat.private") if self.is_private() else "")), "fill": True, "align": "center", "bold": True}) if self.is_general() and not c: parts.append({"text": " %s " % ui.icon("incognito"), "fn": self.toggle_private, "bg": "accent" if self.private else "card", "fg": "accent-ink" if self.private else "ink"}) if self.pane is not None: parts.append({"text": " %s " % ui.icon("panel"), "fn": self.pane.toggle, "bg": "accent" if self.pane.is_open else "card", "fg": "accent-ink" if self.pane.is_open else "ink"}) if c: parts.append({"text": " %s " % ui.icon("more"), "fn": self.menu, "bg": "card"}) self.bar.set(parts) self.refresh_modebar() self.box.placeholder = self.placeholder() def refresh_modebar(self): g, ui, t = self.g, self.ui, self.g.t general = self.is_general() self.modebar.visible = general if not general: return modes = g.spec.get("modes") or ["ask", "imagine", "build"] tiers = g.spec.get("tiers") or ["auto", "fast", "expert"] width = max(10, self.col.aw or g.app.cols) need = sum(len(t("chat.mode_" + m)) + 3 for m in modes) + len(t("tier." + self.tier)) + 8 parts = [] if need > width: parts.append({"text": " %s %s " % (t("chat.mode_" + self.mode), ui.icon("down")), "fn": lambda: g.menu([{"label": t("chat.mode_" + m), "checked": m == self.mode, "action": (lambda m=m: self.set_mode(m))} for m in modes], align_left=True), "bg": "card", "bold": True}) else: for m in modes: on = m == self.mode parts.append({"text": " %s " % t("chat.mode_" + m), "fn": (lambda m=m: self.set_mode(m)), "bg": "accent" if on else "bg", "fg": "accent-ink" if on else "muted", "bold": on}) parts.append({"text": "", "fill": True}) parts.append({"text": " %s %s %s " % (ui.icon("sparkle"), t("tier." + self.tier), ui.icon("down")), "fn": lambda: g.menu([{"label": t("tier." + x), "checked": x == self.tier, "action": (lambda x=x: self.set_tier(x))} for x in tiers]), "bg": "card"}) self.modebar.set(parts) def set_mode(self, mode): self.mode = mode self.refresh_head() self.paint_all() if self.chat: self.call("PATCH", "/api/chats/" + self.chat["id"], {"mode": mode}) def set_tier(self, tier): self.tier = tier self.refresh_modebar() if self.chat: self.call("PATCH", "/api/chats/" + self.chat["id"], {"tier": tier}) def toggle_private(self): self.private = not self.private self.refresh_head() self.paint_all() def members(self): g, t = self.g, self.g.t rows = [GrrRowItem(b["name"], lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), chevron=True, action=(lambda b=b: g.router.go("/bot/" + b["id"]))) for b in self.bots()] g.pick(self.chat.get("title", ""), rows, text=t("chat.members_hint")) def menu(self): g, t, c = self.g, self.g.t, self.chat items = [] if c.get("kind") == "bot": b = (self.bots() or [None])[0] items += [{"label": t("chat.view_profile"), "icon": "user", "action": lambda: b and g.router.go("/bot/" + b["id"])}, {"label": t("profile.share_template"), "icon": "share", "action": self.share_template}, "-", {"label": t("chat.clear"), "icon": "refresh", "action": self.clear_chat}, {"label": t("profile.delete_bot"), "icon": "trash", "danger": True, "action": self.delete_bot}] else: if c.get("kind") == "group": items.append({"label": t("chat.members"), "icon": "group", "action": self.members}) items += [{"label": t("chat.unpin") if c.get("pinned") else t("chat.pin"), "icon": "pin", "action": lambda: self.call("PATCH", "/api/chats/" + c["id"], {"pinned": not c.get("pinned")})}, {"label": t("chat.rename"), "icon": "edit", "action": self.rename}, "-", {"label": t("ui.delete"), "icon": "trash", "danger": True, "action": self.delete_chat}] g.menu(items) def rename(self): g, t = self.g, self.g.t g.edit(t("chat.rename"), self.chat.get("title", ""), lambda v: self.call("PATCH", "/api/chats/" + self.chat["id"], {"title": v}, lambda c: (self.chat.update(c), self.refresh_head()))) def share_template(self): g, t = self.g, self.g.t g.confirm(t("profile.share_title"), t("profile.share_text"), t("ui.continue"), lambda: self.send_text(GRR_TEMPLATE_ASK)) def clear_chat(self): g, t = self.g, self.g.t def go(): self.call("DELETE", "/api/chats/" + self.chat["id"], None, lambda _r: (setattr(self, "messages", []), self.paint_all())) g.confirm(t("chat.clear"), t("chat.clear_text"), t("chat.clear"), go, True) def delete_bot(self): g, t = self.g, self.g.t b = (self.bots() or [None])[0] if b: g.confirm(t("profile.delete_bot"), t("profile.delete_text", name=b["name"]), t("ui.delete"), lambda: self.call("DELETE", "/api/bots/" + b["id"], None, lambda _r: g.store.refresh(lambda _e: g.router.go("/bots", True))), True) def delete_chat(self): g, t = self.g, self.g.t def go(): cid = self.chat["id"] self.chat["kind"] = "x" self.call("DELETE", "/api/chats/" + cid, None, lambda _r: g.router.go("/", True)) g.confirm(t("chat.delete_title"), t("chat.delete_text"), t("ui.delete"), go, True) # ---- messages def make_item(self, m): kind = m.get("kind") if m.get("role") == "user": return GrrMessageItem(self, m) if kind == "approval": return GrrApprovalItem(self, m) if kind == "question": return GrrQuestionItem(self, m) if kind == "process": return GrrProcessItem(self, m) if kind == "file": return GrrFileMsgItem(self, m) if kind == "files": return GrrFilesMsgItem(self, m) if kind == "template": return GrrTemplateMsgItem(self, m) return GrrMessageItem(self, m) def newest_boundary(self): n = (self.chat or {}).get("unread") or 0 if not n: return -1 count = 0 for i in range(len(self.messages) - 1, -1, -1): if self.messages[i].get("role") != "user": count += 1 if count == n: return i return -1 def paint_all(self): g, ui, t = self.g, self.ui, self.g.t for item in self.items.values(): if hasattr(item, "destroy"): item.destroy() self.items = {} entries = [] if not self.messages: entries.append(self.empty_item()) else: if self.has_more: entries.append(GrrButtonItem(t("chat.older"), self.older, "ghost", inline=True)) prev, new_at = None, self.newest_boundary() for i, m in enumerate(self.messages): if i == new_at: entries.append(GrrDividerItem(t("chat.new"), accent=True)) if prev is None or m.get("ts", 0) - prev.get("ts", 0) > 1800: entries.append(GrrDividerItem(ui.fmt.stamp(m.get("ts", 0)))) item = self.make_item(m) self.items[m["id"]] = item entries.append(item) prev = m self.mark_last() self.entries = entries self.log.set_items(entries) self.log.scroll_to_end() def empty_item(self): g, ui, t = self.g, self.ui, self.g.t if self.is_general(): priv = self.is_private() icon = "incognito" if priv else {"imagine": "image", "build": "terminal"}.get(self.mode, "sparkle") title = t("chat.private") if priv else t("chat.empty_" + self.mode) sub = t("chat.private_text") if priv else t("chat.empty_%s_sub" % self.mode) return GrrEmptyItem(icon, title, sub) bot = (self.bots() or [None])[0] class Intro(GrrItem): def build(inner, ui_, width, selected): out = [[TuiSpan(" " * width, ui_.st())] for _ in range(2)] if bot: for row in GrrAvatar.rows(ui_, bot, "bg", hero=True): out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui_.st())] + row, width, ui_.st())) out.append([TuiSpan(grr_clip(self.chat.get("title", ""), width - 4).center(width), ui_.st("ink", "bg", bold=True))]) for line in grr_wrap(bot["description"] if bot else t("chat.group_empty"), width - 8)[:4]: out.append([TuiSpan(line.center(width), ui_.st("muted", "bg"))]) return out return Intro() def mark_last(self): last_id = None for m in reversed(self.messages): if m.get("role") != "user" and m.get("kind") == "text": last_id = m["id"] break if m.get("role") == "user": break for mid, item in self.items.items(): if isinstance(item, GrrMessageItem): item.set_last(mid == last_id) def on_new(self, m): if not self.chat or m.get("chat_id") != self.chat["id"] or m["id"] in self.items: return if not self.messages: self.messages.append(m) self.paint_all() return prev = self.messages[-1] self.messages.append(m) if len(self.messages) > GRR_MESSAGE_KEEP: drop = self.messages.pop(0) gone = self.items.pop(drop["id"], None) if gone is not None and hasattr(gone, "destroy"): gone.destroy() self.has_more = True if m.get("ts", 0) - prev.get("ts", 0) > 1800: self.log.append(GrrDividerItem(self.ui.fmt.stamp(m.get("ts", 0)))) item = self.make_item(m) self.items[m["id"]] = item self.log.append(item) self.mark_last() if m.get("role") == "user" or self.log._follow: self.log.scroll_to_end() else: self.new_below = True if m.get("role") != "user": self.g.client_call_quiet("POST", "/api/chats/%s/read" % self.chat["id"], {}) def on_update(self, m): if not self.chat or m.get("chat_id") != self.chat["id"]: return for i, old in enumerate(self.messages): if old["id"] == m["id"]: self.messages[i] = m break else: return item = self.items.get(m["id"]) if isinstance(item, GrrProcessItem): item.update(m) return if item is None: return fresh = self.make_item(m) self.items[m["id"]] = fresh entries = [fresh if e is item else e for e in self.log.items] self.log.set_items(entries) self.mark_last() def on_status(self, s): if self.chat and s.get("chat_id") == self.chat["id"]: self.set_send_mode() def on_chat_update(self, d): if self.chat and d and d.get("id") == self.chat["id"]: self.get("/api/chats/" + self.chat["id"], lambda c: (self.chat.update(c), self.set_send_mode(), self.refresh_head())) def on_bottom(self, at_bottom): if at_bottom: self.new_below = False def catch_up(self): def landed(page): have = set(self.items) for m in page.get("messages", []): if m["id"] not in have: self.on_new(m) self.get("/api/chats/%s/messages" % self.chat["id"], landed) def older(self): if not self.messages: return def landed(page): self.messages = page.get("messages", []) + self.messages self.has_more = page.get("has_more", False) self.paint_all() self.get("/api/chats/%s/messages?before=%s" % (self.chat["id"], self.messages[0]["id"]), landed) # ---- working row def active(self): return self.g.store.active_in(self.chat["id"]) if self.chat else [] def is_busy(self): return bool(self.chat and (self.chat.get("busy") or self.active())) FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" def tick(self): self.spin += 1 self.paint_working() def paint_working(self): ui, t = self.ui, self.g.t act = self.active() if not act and not (self.chat and self.chat.get("busy")): hint = " ⌄ %s (End)" % t("chat.to_bottom") if self.new_below else "" self.working.set([TuiSpan(hint, ui.st("accent", "bg"))] if hint else []) return s = act[0] if act else None bot = self.bot_of(s.get("bot_id")) if s else (self.bots() or [None])[0] name = bot["name"] if bot else "grr" frame = self.FRAMES[self.spin % len(self.FRAMES)] label = (s or {}).get("label") or "" text = t("chat.is_working", name=name) + (" · " + label[:60] if label else "") if not label: text = "%s %s%s" % (t("chat.is_working", name=name), "." * (1 + self.spin // 3 % 3), "") self.working.set([TuiSpan(" %s " % frame, ui.st("accent", "bg", bold=True))] + (GrrAvatar.chip(ui, bot) if bot else []) + [TuiSpan(" " + text, ui.st("muted", "bg"))]) # ---- composer def fit_box(self): rows = max(1, min(GRR_COMPOSER_MAX_ROWS, len(self.box.lines))) if self.box.h != rows: self.box.h = rows self.plus.h = rows self.send_btn.h = rows self.composer.h = rows + 3 self.set_send_mode() def set_send_mode(self): busy = self.is_busy() has = bool(self.box.value.strip()) or bool(self.attachments) self.send_btn.label = " %s " % (self.ui.icon("stop") if busy else (self.ui.icon("send") if has else self.ui.icon("wave"))) self.send_mode = "stop" if busy else ("send" if has else "voice") self.paint_attach() def send_action(self): g, t = self.g, self.g.t if self.send_mode == "stop": if self.chat: self.call("POST", "/api/chats/%s/stop" % self.chat["id"], {}) elif self.send_mode == "voice": g.toast("Voice needs a microphone: use the web app for spoken conversations.") else: self.on_send() def on_send(self): if self.is_busy() and not self.box.value.strip(): return self.send_text(self.box.value.strip()) def paint_attach(self): ui = self.ui self.attach_row.visible = bool(self.attachments) if self.attachments: spans = [TuiSpan(" ", ui.st())] for a in self.attachments: spans.append(TuiSpan(" %s %s%s " % (ui.icon("file"), a["name"], "…" if a.get("uploading") else ""), ui.st("soft", "card"))) spans.append(TuiSpan(" ", ui.st())) self.attach_row.set(spans) def attach_menu(self): g, t = self.g, self.g.t g.menu([{"icon": "image", "label": t("chat.gallery"), "action": self.pick_file}, {"icon": "file", "label": t("chat.files"), "action": self.pick_file}, "-", {"icon": "skills", "label": t("chat.skills"), "action": self.skills_sheet}, {"icon": "plug", "label": t("chat.connectors"), "action": lambda: g.router.go("/settings/connectors")}], align_left=True) def pick_file(self): g, t = self.g, self.g.t g.edit(t("chat.files"), "", self.add_file, text="Path of a file on this machine") def add_file(self, path): g, t = self.g, self.g.t path = os.path.expanduser(path.strip()) if not path: return try: if os.path.getsize(path) > g.ui.cap("UPLOAD_MAX_BYTES", 20971520): g.toast(t("err.too_big", max=GrrFormat.bytes(g.ui.cap("UPLOAD_MAX_BYTES", 20971520))), "bad") return with open(path, "rb") as fh: data = fh.read() except OSError as exc: g.toast(str(exc), "bad") return chip = {"name": os.path.basename(path), "uploading": True} self.attachments.append(chip) self.set_send_mode() chat = self.chat def landed(item, err): if err or self.closed: if chip in self.attachments: self.attachments.remove(chip) if err: g.error(err) else: chip.update({"id": item["id"], "uploading": False}) self.set_send_mode() g.app.offload(lambda: g.client.upload(chip["name"], data, chat["id"] if chat else "", chat["bot_ids"][0] if chat and chat.get("kind") == "bot" else ""), landed) def skills_sheet(self): g, t = self.g, self.g.t bots = g.store.bots if not bots: return def landed(res): rows = [] for b, r in zip(bots, res): for s in r.get("skills", []): rows.append(GrrRowItem(s["name"], sub=s.get("description", ""), lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), action=(lambda b=b, s=s: self.insert_skill(b, s)))) if rows: g.pick(t("chat.skills"), rows) else: g.dialog(title=t("chat.no_skills"), text=t("chat.no_skills_sub")) self.batch([("GET", "/api/bots/%s/skills" % b["id"], None) for b in bots], landed) def insert_skill(self, bot, skill): text = (self.box.value + " @" + bot["name"].replace(" ", "") + " " + self.g.t("chat.use_skill", name=skill["name"])).strip() self.box.lines = text.split("\n") self.box.cy = len(self.box.lines) - 1 self.box.cx = len(self.box.lines[-1]) self.box.touch() self.g.app.set_focus(self.box) def ensure_chat(self, then): if self.chat: then(self.chat) return if getattr(self, "creating", False): return self.creating = True body = {"kind": "private" if self.private else "general", "mode": self.mode, "tier": self.tier} if self.query.get("project"): body["project_id"] = self.query["project"] def landed(chat): self.creating = False self.chat = chat self.params["id"] = chat["id"] self.g.router.rewrite("/chat/" + chat["id"]) self.g.store.soon() self.refresh_head() then(chat) self.call("POST", "/api/chats", body, landed, lambda e: (setattr(self, "creating", False), self.g.error(e))) def send_text(self, text): g, t = self.g, self.g.t if not text and not self.attachments: return if any(a.get("uploading") for a in self.attachments): g.toast(t("chat.uploading")) return files = [a["id"] for a in self.attachments if a.get("id")] kept = self.attachments def go(chat): body = {"text": text, "files": files} if self.is_general(): body.update({"mode": self.mode, "tier": self.tier}) self.box.lines = [""] self.box.cx = self.box.cy = 0 self.box.touch() self.attachments = [] def landed(res): self.on_new(dict({"chat_id": chat["id"]}, **res["message"])) self.chat["busy"] = True self.set_send_mode() self.log.scroll_to_end() def failed(err): self.attachments = kept if isinstance(err, GrrError) and err.code in ("busy", "chat_busy"): g.toast(err.text()) else: g.error(err) if not self.box.value: self.box.lines = text.split("\n") self.box.cy = len(self.box.lines) - 1 self.box.cx = len(self.box.lines[-1]) self.set_send_mode() self.call("POST", "/api/chats/%s/messages" % chat["id"], body, landed, failed) self.ensure_chat(go) def answer(self, m, text): self.call("POST", "/api/messages/%s/answer" % m["id"], {"text": text}) def rate(self, m, kind): g, t = self.g, self.g.t body = {"text": ("[good] " if kind == "up" else "[bad] ") + grr_text(m.get("content"))[:500], "bot_id": m.get("sender")} self.call("POST", "/api/feedback", body, lambda _r: g.toast(t("chat.thanks"))) def regenerate(self): for m in reversed(self.messages): if m.get("role") == "user" and m.get("content"): self.send_text(m["content"]) return def hotkey(self, key): if key == "ctrl+p" and self.pane is not None: self.pane.toggle() return True return False # ---- pane def restore_pane(self): if self.pane is not None: self.pane.restore() def on_escape(self): if self.pane is not None and self.pane.is_open and self.pane.layer_mode(): self.pane.hide() return True g = self.g g.router.back("/") return True def restyled(self): for node in self.root.walk(): if hasattr(node, "ui"): node.ui = self.g.ui class GrrComputerView(GrrView): """Take control of a bot's computer: its shell, live over the stream. Ctrl+] hands control back, like `computer take` does in the REPL.""" def open(self): g, ui, t = self.g, self.ui, self.g.t self.started = False self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.root.add(self.bar) self.term = None self.show(self.root) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): g, ui, t = self.g, self.ui, self.g.t self.bot = bot path = "/api/bots/%s/computer" % bot["id"] self.term = GrrRemoteTerminal(g, path + "/stream?from=0", path + "/input", w="100%", h="1fr", border=False, style=ui.st("ink", "sunken")) self.root.add(self.term) self.term.on_leave = self.done self.foot = GrrBar(ui, style=ui.st("ink", "bg")) self.root.add(self.foot) self.foot.set([{"text": " Ctrl+] %s " % t("computer.done"), "fn": self.done, "bg": "card"}, {"text": "", "fill": True}, {"text": " ^C ", "fn": lambda: self.term.queue(b"\x03"), "bg": "card"}, {"text": " ^L ", "fn": lambda: self.term.queue(b"\x0c"), "bg": "card"}, {"text": " ^D ", "fn": lambda: self.term.queue(b"\x04"), "bg": "card"}]) self.bar.set([{"text": " %s " % ui.icon("back"), "fn": self.done, "bg": "card", "bold": True}, {"spans": [TuiSpan(" ", ui.st("ink", "card"))] + GrrAvatar.chip(ui, bot) + [TuiSpan(" %s " % bot["name"], ui.st("ink", "card", bold=True))]}, {"text": "", "fill": True}, {"text": " %s " % ui.icon("help"), "fn": self.help, "bg": "card"}]) body = {"start": True, "cols": 80, "rows": 24} self.call("POST", path + "/input", body, lambda _r: self.attached(), lambda e: g.error(e)) g.app.set_focus(self.term) def attached(self): self.started = True self.term.attach() self.term.send_size() def on_key_root(self, event): return False def help(self): g, t = self.g, self.g.t lines = [t("computer.help_scroll_t"), "", t("computer.help_keyboard_t"), "", t("computer.help_done_t"), "", "Ctrl+] " + t("computer.done")] g.dialog(title=t("computer.help_title"), text="\n".join(lines), w=70) def done(self): g = self.g if self.started: g.client_call_quiet("POST", "/api/bots/%s/computer/input" % self.bot["id"], {"release": True}) if self.term is not None: self.term.detach() g.router.back("/bot/%s?tab=computer" % self.params["id"]) def destroy(self): if self.term is not None: self.term.detach() if self.started: self.g.client_call_quiet("POST", "/api/bots/%s/computer/input" % self.bot["id"], {"release": True}) super().destroy() def on_escape(self): return True class GrrScreenView(GrrView): """The graphical screen of a bot is a VNC session; a terminal cannot draw it. This view starts and stops it and says where to see it.""" def open(self): g, t = self.g, self.g.t self.standard(t("screen.title"), back="/bot/%s?tab=computer" % self.params["id"]) self.sub(g.events, "preview.ready", lambda _d: None) self.reload() def reload(self): self.get("/api/bots/%s/desktop" % self.params["id"], self.paint) def paint(self, d): g, t = self.g, self.g.t bid = self.params["id"] url = "%s/#/bot/%s/screen" % (g.client.base, bid) items = [GrrEmptyItem("monitor", t("screen.title"), "running" if d.get("running") else t("screen.starting").rstrip(".")), GrrKvItem([("Display", "%sx%s" % (d.get("width"), d.get("height"))), ("Screens", "%s / %s" % (d.get("clients", 0), d.get("max"))), ("Browser", "yes" if d.get("browser") else t("screen.no_browser"))])] if d.get("running"): items.append(GrrButtonItem(t("screen.stop"), lambda: g.confirm(t("screen.stop"), t("screen.stop_text"), t("screen.stop"), lambda: self.call("POST", "/api/bots/%s/desktop/stop" % bid, {}, lambda _r: self.reload()), True), "secondary", inline=True)) else: items.append(GrrButtonItem(t("profile.take_control"), lambda: self.call("POST", "/api/bots/%s/desktop/start" % bid, {}, lambda _r: self.reload()), inline=True)) items.append(GrrButtonItem("Open in browser", lambda: g.open_url(url), "secondary", inline=True, icon="external")) items.append(GrrNote("The graphical screen is a VNC view. It needs a graphical client: open it in a browser. The shell of the same computer is under Terminal.", center=True)) self.screen.items(items) # --------------------------------------------------------------------------- # Part G: the side pane of a bot chat (Preview, Files, Deploy), the route # table and the entry point behind `tai.py --tui`. # --------------------------------------------------------------------------- class GrrPane: """Preview and Files beside the chat, or over it on a narrow terminal. The tabs and what is enabled come from the server's spec.""" def __init__(self, view): self.v = view g = view.g ui = view.ui self.g = g self.cfg = g.spec.get("pane") or {} self.tabs = [t for t in (self.cfg.get("tabs") or [{"id": "preview"}, {"id": "files"}, {"id": "deploy", "enabled": False}])] self.tab = self.cfg.get("default_tab") or "preview" self.is_open = False bots = view.bots() self.bot = bots[0] self.root = TuiRows(w=self.width(), h="100%", style=ui.st("ink", "bg")) self.head = GrrBar(ui, style=ui.st("ink", "bg")) self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") self.root.add(self.head, self.page) self.state = g.pane_state.setdefault(view.chat["id"], {}) self.files = GrrPaneFiles(self) self.preview = GrrPanePreview(self) self.deploy = GrrPaneDeploy(self) self.watchers = [] self.watchers.append(g.events.on("files.changed", lambda d: self.on_event("files", d))) self.watchers.append(g.events.on("port.opened", lambda d: self.on_event("preview", d))) self.watchers.append(g.events.on("port.closed", lambda d: self.on_event("preview", d))) self.watchers.append(g.events.on("preview.ready", lambda d: self.on_event("preview", d))) self.watchers.append(g.events.on("preview.failed", lambda d: self.on_event("preview", d))) self.keeper = None def width(self): cols = self.g.app.cols if self.layer_mode(): return "1fr" return max(GRR_PANE_MIN_W, min(GRR_PANE_MAX_W, cols * 46 // 100)) def layer_mode(self): return self.g.app.cols < GRR_PANE_MIN_COLS def destroy(self): for off in self.watchers: off() if self.keeper is not None: self.g.app.cancel_timer(self.keeper) self.preview.hide() def restore(self): if self.state.get("open") and not self.layer_mode(): self.show(self.state.get("tab") or self.tab) def toggle(self): if self.is_open: self.hide() else: self.show(self.state.get("tab") or self.tab) def show(self, tab): v = self.v self.is_open = True self.state["open"] = True self.root.w = self.width() if self.root.parent is None: v.root.add(self.root) if self.layer_mode(): v.col.visible = False self.v.refresh_head() self.pick(tab if any(t["id"] == tab for t in self.tabs) else self.tab) self.g.app.set_focus(self.page) def hide(self): v = self.v if not self.is_open: return self.is_open = False self.state["open"] = False if self.root.parent is not None: v.root.remove(self.root) v.col.visible = True self.preview.hide() v.refresh_head() self.g.app.set_focus(v.box) def pick(self, tab): self.tab = tab self.state["tab"] = tab if tab != "preview": self.preview.hide() self.paint_head() self.current().show() def current(self): return {"preview": self.preview, "files": self.files, "deploy": self.deploy}.get(self.tab, self.preview) def paint_head(self): g, ui, t = self.g, self.v.ui, self.g.t parts = [] for tab in self.tabs: on = tab["id"] == self.tab parts.append({"text": " %s " % t("pane.tab_" + tab["id"]), "fn": (lambda i=tab["id"]: self.pick(i)), "bg": "accent" if on else "bg", "fg": "accent-ink" if on else "muted", "bold": on}) parts.append({"text": "", "fill": True}) bots = self.v.bots() if len(bots) > 1: parts.append({"text": " %s %s " % (ui.icon("user"), grr_clip(self.bot["name"], 12)), "bg": "card", "fn": lambda: g.menu([{"label": b["name"], "checked": b["id"] == self.bot["id"], "action": (lambda b=b: self.choose(b))} for b in bots])}) parts.append({"text": " %s " % ui.icon("close"), "fn": self.hide, "bg": "card"}) self.head.set(parts) def choose(self, bot): self.bot = bot self.state["bot"] = bot["id"] self.files.reset() self.preview.reset() self.paint_head() self.current().show() def on_event(self, kind, data): if not self.is_open or (data or {}).get("bot_id", self.bot["id"]) != self.bot["id"]: return if kind == "files" and self.tab == "files": self.files.changed() elif kind == "preview" and self.tab == "preview": self.preview.load(True) class GrrPaneTab: def __init__(self, pane): self.pane = pane @property def g(self): return self.pane.g @property def bot(self): return self.pane.bot def base(self, name): return "/api/bots/%s/%s" % (self.bot["id"], name) def items(self, items): self.pane.page.set_items(items) def reset(self): pass def hide(self): pass class GrrPaneFiles(GrrPaneTab): """The bot's workspace, read only: folders, a name filter, a viewer.""" def __init__(self, pane): super().__init__(pane) self.path = "" self.q = "" self.file = None self.entries = None self.total = 0 self.truncated = False self.gen = 0 def base(self, name): return "/api/bots/%s/workspace/%s" % (self.bot["id"], name) def reset(self): self.path, self.q, self.file, self.entries = "", "", None, None self.gen += 1 def show(self): if self.file is not None: self.view_file(self.file) else: self.load() def changed(self): if self.file is None: self.load(True) def load(self, quiet=False): g, t = self.g, self.g.t self.gen += 1 gen = self.gen if self.q: path = self.base("search") + "?q=" + urllib.parse.quote(self.q) else: path = self.base("tree") + "?path=" + urllib.parse.quote(self.path) def landed(res, err): if gen != self.gen: return if err: if not quiet: g.error(err) self.items([GrrEmptyItem("warn", t("files.load_failed"))]) return self.entries = res.get("entries") or res.get("results") or [] self.total = res.get("total", len(self.entries)) self.truncated = bool(res.get("truncated")) self.paint() g.app.offload(lambda: g.client.get(path), landed) def paint(self): g, t, ui = self.g, self.g.t, self.g.ui crumb = "/" + self.path if self.path else "/" chips = [{"label": crumb, "icon": "folder", "caret": False, "action": self.up}, {"label": self.q or t("files.filter"), "icon": "search", "caret": False, "action": self.ask}, {"label": t("files.refresh"), "icon": "refresh", "caret": False, "action": lambda: self.load()}] items = [GrrChipsItem(chips)] rows = [] for e in self.entries or []: name = e.get("path") or ((self.path + "/" if self.path else "") + e["name"]) is_dir = e.get("type") == "dir" sub = (t("files.cache") if e.get("cache") else "") if is_dir else GrrFormat.bytes(e.get("size", 0)) rows.append(GrrRowItem(e.get("path") or e["name"] if self.q else e["name"], icon="folder" if is_dir else "file", sub=sub, chevron=is_dir, action=(lambda n=name, d=is_dir: self.enter(n) if d else self.open_file(n)))) if self.path and not self.q: rows.insert(0, GrrRowItem("..", icon="back", action=self.up)) if rows: items += grr_group(rows) else: items.append(GrrEmptyItem("folder", t("files.none") if self.q else t("files.empty"))) if self.truncated: items.append(GrrNote(t("files.cut", shown=len(self.entries or []), total=self.total))) self.items(items) def enter(self, name): self.path = name self.q = "" self.load() def up(self): if self.q: self.q = "" else: self.path = self.path.rsplit("/", 1)[0] if "/" in self.path else "" self.load() def ask(self): g, t = self.g, self.g.t g.edit(t("files.filter"), self.q, lambda v: (setattr(self, "q", v.strip()), self.load())) def open_file(self, path): g, t = self.g, self.g.t self.gen += 1 gen = self.gen def landed(res, err): if gen != self.gen: return if err: g.error(err) return self.file = res self.view_file(res) g.app.offload(lambda: g.client.get(self.base("file") + "?path=" + urllib.parse.quote(path)), landed) def view_file(self, info): g, t, ui = self.g, self.g.t, self.g.ui items = [GrrChipsItem([{"label": t("files.back_list"), "icon": "back", "caret": False, "action": self.close_file}, {"label": t("files.copy_path"), "icon": "copy", "caret": False, "action": lambda: g.copy_text(info.get("path", ""))}, {"label": t("files.download"), "icon": "download", "caret": False, "action": lambda: self.download(info)}]), GrrNote(info.get("path", ""), tone="muted")] text = info.get("text") if isinstance(text, str): lines = text.split("\n") shown = lines[:2000] width = len(str(len(shown))) body = "\n".join("%*d %s" % (width, i + 1, line) for i, line in enumerate(shown)) items.append(GrrTextCard(body, mono=True)) if len(lines) > len(shown): items.append(GrrNote(t("files.cut", shown=len(shown), total=len(lines)))) else: kind = info.get("kind") items.append(GrrNote({"binary": t("files.binary"), "large": t("files.large", max=GrrFormat.bytes(g.ui.cap("VIEWER_MAX_BYTES", 2097152))), "gone": t("files.gone")}.get(kind, t("files.binary")), center=True)) self.items(items) def close_file(self): self.file = None self.load() def download(self, info): g = self.g path = self.base("raw") + "?path=" + urllib.parse.quote(info.get("path", "")) + "&download=1" def work(): data = g.client.fetch(path, limit=GRR_BODY_MAX)[0] dest = os.path.join(tempfile.gettempdir(), "grr-" + re.sub(r"[^A-Za-z0-9._-]", "_", info.get("name", "file"))) with open(dest, "wb") as fh: fh.write(data) return dest g.app.offload(work, lambda dest, err: g.error(err) if err else g.toast("Saved " + dest)) class GrrPanePreview(GrrPaneTab): """What the bot built: folders with an index.html and servers listening inside its computer. A terminal cannot frame a page, so it shows the address and opens it in the desktop browser.""" def __init__(self, pane): super().__init__(pane) self.info = None self.target = "" self.public = False self.shown = False def reset(self): self.info = None self.target = "" def show(self): self.shown = True self.load() if self.pane.keeper is None: ttl = max(5, (self.g.ui.cap("PORT_WATCH_TTL", 120)) / 2.0) self.pane.keeper = self.g.app.every(ttl, lambda: self.load(True) if self.shown else None) def hide(self): self.shown = False if self.pane.keeper is not None: self.g.app.cancel_timer(self.pane.keeper) self.pane.keeper = None def load(self, quick=False): g = self.g path = self.base("preview") + ("?ports=0" if quick else "") def landed(res, err): if err: self.info = {"folders": [], "ports": [], "active": None, "proxy": "local", "domain": "", "failed": True} g.error(err) else: self.info = res self.paint() active = (self.info or {}).get("active") if self.shown and active and active.get("state") == "starting": g.app.after(2.0, lambda: self.load(True) if self.shown else None) g.app.offload(lambda: g.client.get(path), landed) def options(self): t = self.g.t info = self.info or {} out = [{"id": "folder:" + f["path"], "label": "/" + f["path"] if f["path"] else t("preview.root"), "kind": "folder", "path": f["path"]} for f in info.get("folders", [])] out += [{"id": "port:%s" % p["port"], "label": t("preview.port", port=p["port"]) + (" (%s)" % p["process"] if p.get("process") else ""), "kind": "port", "port": p["port"]} for p in info.get("ports", [])] return out def paint(self): g, t, ui = self.g, self.g.t, self.g.ui info = self.info if info is None: self.items([GrrEmptyItem("globe", "...")]) return if info.get("proxy") == "none": self.items([GrrEmptyItem("warn", t("preview.no_proxy", domain=info.get("domain", "")))]) return active = info.get("active") items = [] if active: url = active.get("url") or active.get("address") or "" state = active.get("state", "") items += [GrrEmptyItem("globe", url or t("preview.starting", host=active.get("host", "")), state)] items.append(GrrKvItem([("State", state), ("Access", t("preview.public_badge") if active.get("access") == "public" or active.get("public") else "private")])) if url: items.append(GrrButtonItem(t("preview.external"), lambda: g.open_url(url), inline=True, icon="external")) items.append(GrrButtonItem(t("ui.share"), lambda: g.copy_text(url), "secondary", inline=True, icon="copy")) items.append(GrrButtonItem(t("preview.stop"), self.stop, "secondary", inline=True)) else: options = self.options() if not options: state = info.get("container") items.append(GrrEmptyItem("globe", t("preview.container_off", state=state) if state and state != "running" else t("preview.no_folders"), t("preview.pick_hint"))) else: rows = [GrrRowItem(o["label"], icon="folder" if o["kind"] == "folder" else "bolt", check=o["id"] == (self.target or options[0]["id"]), action=(lambda o=o: self.set_target(o["id"]))) for o in options] items += [GrrLabel(t("preview.pick_hint"))] + grr_group(rows) items += grr_group([GrrRowItem(t("preview.public"), sub=t("preview.public_hint"), toggle=self.public, on_toggle=lambda v: setattr(self, "public", v))]) items.append(GrrButtonItem(t("preview.open"), self.open, inline=True, icon="play")) items.append(GrrRowItem(t("preview.port_placeholder"), icon="plus", link=True, action=self.ask_port)) items.append(GrrNote(t("preview.port_opened", port="N", process="") if False else "")) self.items([i for i in items if not (isinstance(i, GrrNote) and not i.text)]) def set_target(self, target): self.target = target self.paint() def ask_port(self): g, t = self.g, self.g.t def go(value): if value.strip().isdigit(): self.post({"kind": "port", "port": int(value.strip())}) g.edit(t("preview.port_placeholder"), "", go) def open(self): options = self.options() if not options: return choice = next((o for o in options if o["id"] == self.target), options[0]) self.post({"kind": "port", "port": choice["port"]} if choice["kind"] == "port" else {"kind": "folder", "path": choice["path"]}) def post(self, target): g = self.g body = dict({"access": "public" if self.public else ((self.g.spec.get("pane") or {}).get("preview", {}).get("access_default") or "login")}, **target) def landed(res, err): if err: g.error(err) else: self.info = res self.paint() active = res.get("active") if self.shown and active and active.get("state") == "starting": g.app.after(2.0, lambda: self.load(True) if self.shown else None) g.app.offload(lambda: g.client.post(self.base("preview"), body), landed) def stop(self): g = self.g def landed(res, err): if err: g.error(err) else: self.info = res self.paint() g.app.offload(lambda: g.client.delete(self.base("preview")), landed) class GrrPaneDeploy(GrrPaneTab): """Shown with every control disabled, like the web app: deploy has no server behind it yet.""" def show(self): t = self.g.t self.items(grr_group([GrrRowItem(t("deploy.address"), value="-"), GrrRowItem(t("deploy.access"), value=t("deploy.access_me")), GrrRowItem(t("deploy.cover"), value="-")]) + [GrrButtonItem(t("deploy.button"), lambda: None, "secondary", inline=True, disabled=True), GrrNote(t("deploy.note"), center=True)]) GRR_CONTROL_JOBS = 64 GRR_CONTROL_LINE = 65536 GRR_CONTROL_WAIT = 10 GRR_CONTROL_WAIT_MAX = 60 GRR_CONTROL_KEYS_MAX = 200 def grr_control_path(): """The control socket of the running grr TUI: TAI_TUI_SOCK or /tui.sock.""" home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") return os.environ.get("TAI_TUI_SOCK") or os.path.join(home, "tui.sock") class GrrControl: """Lets agents drive the running grr TUI over a local unix socket (mode 0600), one JSON line in and one out. An operation is a job on the UI loop: `screen`, `key`, `type`, `click`, `goto` and `result`. Every one can be async: the call returns a job id at once and `result` collects it (with a wait). `screen` may wait for text to appear (`until`). At most GRR_CONTROL_JOBS finished jobs are kept.""" def __init__(self, shell, path=None): self.shell = shell self.path = path or grr_control_path() self.sock = None self.jobs = collections.OrderedDict() self.seq = 0 self.lock = threading.Lock() self.thread = None self.stopped = False def start(self): if os.path.exists(self.path): probe = socket.socket(socket.AF_UNIX) probe.settimeout(1) try: probe.connect(self.path) probe.close() return False except OSError: os.unlink(self.path) os.makedirs(os.path.dirname(self.path), exist_ok=True) self.sock = socket.socket(socket.AF_UNIX) self.sock.bind(self.path) os.chmod(self.path, 0o600) self.sock.listen(8) self.thread = threading.Thread(target=self.accept, name="grr-control", daemon=True) self.thread.start() return True def stop(self): self.stopped = True if self.sock is not None: try: self.sock.close() except OSError: pass try: os.unlink(self.path) except OSError: pass self.sock = None def accept(self): while not self.stopped: try: conn, _addr = self.sock.accept() except OSError: return threading.Thread(target=self.serve, args=(conn,), daemon=True).start() def serve(self, conn): try: conn.settimeout(GRR_CONTROL_WAIT_MAX + 5) data = b"" while b"\n" not in data and len(data) < GRR_CONTROL_LINE: chunk = conn.recv(4096) if not chunk: break data += chunk try: request = json.loads(data.decode("utf-8")) reply = self.handle(request) except (ValueError, TypeError) as exc: reply = {"state": "error", "error": "bad request: %s" % exc} conn.sendall((json.dumps(reply) + "\n").encode("utf-8")) except OSError: pass finally: conn.close() def handle(self, request): op = str(request.get("op") or "") wait = max(0.0, min(float(request.get("wait", GRR_CONTROL_WAIT)), GRR_CONTROL_WAIT_MAX)) if op == "result": with self.lock: job = self.jobs.get(int(request.get("id", 0))) if job is None: return {"state": "error", "error": "no such job"} else: if not hasattr(self, "op_" + op): return {"state": "error", "error": "unknown op %r" % op} job = self.submit(op, request) if request.get("async") and op != "result": return {"id": job["id"], "state": job["state"]} job["event"].wait(wait) return self.view(job) def view(self, job): out = {"id": job["id"], "state": job["state"]} if job["state"] == "done": out["result"] = job["result"] elif job["state"] == "error": out["error"] = job["error"] return out def submit(self, op, request): with self.lock: self.seq += 1 job = {"id": self.seq, "op": op, "state": "running", "event": threading.Event(), "result": None, "error": ""} self.jobs[job["id"]] = job while len(self.jobs) > GRR_CONTROL_JOBS: self.jobs.popitem(last=False) self.shell.app.run_on_loop(lambda: self.run(job, request)) return job def finish(self, job, result=None, error=""): job["result"], job["error"] = result, error job["state"] = "error" if error else "done" job["event"].set() def run(self, job, request): try: outcome = getattr(self, "op_" + job["op"])(job, request) except Exception as exc: self.finish(job, error="%s: %s" % (type(exc).__name__, exc)) return if outcome is not self.PENDING: self.finish(job, outcome) PENDING = object() def screen_info(self): app, shell = self.shell.app, self.shell app.layout() focus = app.focused return {"route": shell.router.current, "cols": app.cols, "rows": app.rows, "signed_in": shell.signed_in, "focus": type(focus).__name__ if focus is not None else "", "screen": app.render_text()} def op_screen(self, job, request): until = request.get("until") if not until: return self.screen_info() deadline = time.time() + max(0.0, min(float(request.get("timeout", GRR_CONTROL_WAIT)), GRR_CONTROL_WAIT_MAX)) def look(): info = self.screen_info() if until in info["screen"]: self.finish(job, dict(info, matched=True)) elif time.time() >= deadline: self.finish(job, dict(info, matched=False)) else: self.shell.app.after(0.2, look) look() return self.PENDING @staticmethod def key_event(name): parts = name.split("+") base = parts[-1] mods = parts[:-1] char = {"space": " ", "enter": "\n", "tab": "\t"}.get(base, base if len(base) == 1 else "") if mods and len(base) == 1 and "ctrl" in mods: char = chr(ord(base.lower()) & 0x1f) if base.isalpha() else "" return TuiKey(key=name.lower() if len(base) > 1 or mods else base, char=char, modifiers=mods) def op_key(self, job, request): keys = request.get("keys") or [] if not isinstance(keys, list) or len(keys) > GRR_CONTROL_KEYS_MAX: raise ValueError("keys must be a list of at most %d names" % GRR_CONTROL_KEYS_MAX) for name in keys: self.shell.app.dispatch(self.key_event(str(name))) self.shell.app.wake() return {"sent": len(keys)} def op_type(self, job, request): text = str(request.get("text") or "")[:GRR_CONTROL_KEYS_MAX * 20] for ch in text: name = "enter" if ch == "\n" else ("space" if ch == " " else ch) self.shell.app.dispatch(TuiKey(key=name, char="\n" if ch == "\n" else ch, modifiers=[])) self.shell.app.wake() return {"typed": len(text)} def op_click(self, job, request): x, y = int(request["x"]), int(request["y"]) app = self.shell.app app.layout() app.dispatch(TuiClick(x=x, y=y, button=0, modifiers=[], pressed=True)) app.dispatch(TuiClick(x=x, y=y, button=0, modifiers=[], pressed=False)) app.wake() return {"clicked": [x, y]} def op_goto(self, job, request): route = str(request.get("route") or "/") if not route.startswith("/"): raise ValueError("a route starts with /") self.shell.router.go(route) return {"route": self.shell.router.current} def grr_control_call(payload, path=None, timeout=GRR_CONTROL_WAIT_MAX + 10): """Client side: one request to the running TUI, its reply as a dict.""" path = path or grr_control_path() sock = socket.socket(socket.AF_UNIX) sock.settimeout(timeout) try: sock.connect(path) sock.sendall((json.dumps(payload) + "\n").encode("utf-8")) data = b"" while not data.endswith(b"\n"): chunk = sock.recv(65536) if not chunk: break data += chunk except OSError as exc: return {"state": "error", "error": "no running grr TUI answers on %s (%s)" % (path, exc)} finally: sock.close() try: return json.loads(data.decode("utf-8")) except ValueError: return {"state": "error", "error": "unreadable reply"} GRR_ROUTES = ( ("/bots", "GrrBotsView"), ("/bot/:id", "GrrProfileView"), ("/bot/:id/instructions", "GrrInstructionsView"), ("/bot/:id/memories", "GrrMemoriesView"), ("/memory/:id", "GrrMemoryView"), ("/bot/:id/skills", "GrrSkillsView"), ("/skill/:id", "GrrSkillView"), ("/bot/:id/computer", "GrrComputerView"), ("/bot/:id/screen", "GrrScreenView"), ("/chat/:id", "GrrChatView"), ("/automations", "GrrAutomationsView"), ("/automations/new", "GrrNewAutomationView"), ("/automation/:id", "GrrAutomationView"), ("/library", "GrrLibraryView"), ("/projects", "GrrProjectsView"), ("/project/:id", "GrrProjectView"), ("/search", "GrrSearchView"), ("/settings", "GrrSettingsView"), ("/settings/account", "GrrAccountView"), ("/settings/usage", "GrrUsageView"), ("/settings/rules", "GrrRulesView"), ("/settings/rule/:id", "GrrRuleView"), ("/settings/appearance", "GrrAppearanceView"), ("/settings/language", "GrrLanguageView"), ("/settings/haptics", "GrrHapticsView"), ("/settings/widget", "GrrWidgetView"), ("/settings/computer", "GrrComputersView"), ("/settings/connectors", "GrrConnectorsView"), ("/settings/advanced", "GrrAdvancedView"), ("/settings/builder", "GrrBuilderView"), ("/settings/feedback", "GrrFeedbackView"), ("/settings/timezone", "GrrTimezoneView"), ("/template/:id", "GrrTemplateView"), ("/template/:id/:part", "GrrTemplatePartView"), ("/public/:slug", "GrrPublicTemplateView"), ) def grr_shell_call_quiet(self, method, path, body=None): """Fire and forget a server call from the loop (read marks, releases).""" self.app.offload(lambda: self.client.request(method, path, body, quiet=True), lambda _r, _e: None) def grr_router_rewrite(self, raw): """Replace the current route in the stack without opening it again.""" if self.stack: self.stack[-1] = raw GrrShell.client_call_quiet = grr_shell_call_quiet GrrRouter.rewrite = grr_router_rewrite def grr_tui(): """Run the fullscreen grr client. The theme comes from the account's settings (system, day, night and the Black or Blue variant).""" app = GrrTuiApp() app.rows, app.cols = app._winsize() shell = GrrShell(app) control = GrrControl(shell) try: control.start() except OSError as exc: print("tui control socket unavailable: %s" % exc, file=sys.stderr) try: app.mount(shell.root) shell.boot() return app.run() except TuiError as exc: print("tui unavailable: %s" % exc, file=sys.stderr) return 2 finally: control.stop() shell.stop_stream() def build_parser(): parser = argparse.ArgumentParser(prog="tai", description="single-file autonomous agent, standard library only") parser.add_argument("--profile", default=None) parser.add_argument("--yes", action="store_true") parser.add_argument("--yolo", action="store_true") parser.add_argument("--auto", action="store_true") parser.add_argument("--continue", dest="resume", action="store_true") parser.add_argument("--env", default=None) parser.add_argument("--no-opencode", action="store_true", help="skip the opencode CLI backends for this run (same as TAI_OPENCODE=0)") parser.add_argument("--update-models", action="store_true", help="rediscover the free model lists of every provider, drop models that are gone, and exit") parser.add_argument("--version", action="store_true") parser.add_argument("--telegram", action="store_true") parser.add_argument("--scheduler", action="store_true") parser.add_argument("--install", action="store_true") parser.add_argument("--install-telegram", action="store_true") parser.add_argument("--uninstall-telegram", action="store_true") parser.add_argument("--plexer-wrap", action="store_true", help="internal: invoked by the bashrc plexer hook, not meant to be run by hand") parser.add_argument("--mplex", nargs=argparse.REMAINDER, default=None, metavar="COMMAND", help="terminal multiplexer: any mplex command (new-session, attach-session, list-keys ...); bare --mplex starts a session. Config: ~/.tai/mplex.toml") parser.add_argument("--demo", action="store_true", help="run the fullscreen TUI framework showcase") parser.add_argument("--demo-shot", action="store_true", help="print one composed demo frame as text and exit") parser.add_argument("--tui", action="store_true", help="run the fullscreen grr client (bots, chats, automations, library, projects, settings) against the grr server; address in GRR_URL") parser.add_argument("--tui-local", action="store_true", help="run the fullscreen Telegram-style chat UI on the local vault, no server needed") parser.add_argument("--theme", default=None, metavar="NAME", help="TUI theme: dark, light, nord, gruvbox, ansi (or TAI_THEME)") parser.add_argument("prompt", nargs="*") return parser def banner(agent): if Glow.enabled(): print("%s◆%s %stai%s %s%s%s %s· profile %s%s" % (Ansi.BOLD, Ansi.RESET, Ansi.BOLD, Ansi.RESET, FG_FILE, VERSION, Ansi.RESET, Ansi.DIM, agent.profile, Ansi.RESET)) else: print(paint("tai %s · profile %s" % (VERSION, agent.profile), Ansi.BOLD, Ansi.CYAN)) if agent.yolo: print(paint("YOLO MODE: all approvals off", Ansi.BOLD, Ansi.RED)) if agent.auto: print(paint("AUTO MODE: never asks, researches instead", Ansi.BOLD, Ansi.YELLOW)) seal_state = "off" if agent.store.seal.enabled: seal_state = "on (default key)" if agent.store.seal.default_key else "on (TAI_PASSPHRASE)" if Glow.enabled(): print(" %sseal:%s %s" % (Ansi.DIM, Ansi.RESET, seal_state)) else: print(" seal: %s" % seal_state) if getattr(agent, "ctx_id", ""): if Glow.enabled(): print(" %sctx:%s %s" % (Ansi.DIM, Ansi.RESET, agent.ctx_id)) else: print(" ctx: %s" % agent.ctx_id) for label, _chat_url, models_url, key, _model in agent.chat.backends(): if Glow.enabled(): print(" %s%s:%s %s" % (Ansi.DIM, label, Ansi.RESET, probe_backend(models_url, key))) else: print(" %s: %s" % (label, probe_backend(models_url, key))) if agent.store.load_turn_state(agent.profile, agent.bot, ctx=getattr(agent, "ctx_id", "")).get("open"): print(paint("interrupted turn available, restart with --continue to pick it up", Ansi.YELLOW)) print(paint("type /help for commands", Ansi.DIM)) def resume_turn(agent): state = agent.store.load_turn_state(agent.profile, agent.bot, ctx=getattr(agent, "ctx_id", "")) if not state.get("open"): print("nothing to resume: last turn for %s/%s completed normally" % (agent.profile, agent.bot)) return "" goal = state.get("goal") or "" print("resuming interrupted turn for %s/%s: %d steps, %d tool calls, goal: %s" % (agent.profile, agent.bot, state.get("steps", 0), state.get("tools", 0), goal[:200])) message = "Continue where you left off%s. Do not repeat steps already completed above; carry on with the next step." % (": the interrupted request was: %s" % goal if goal else "") return agent.run_turn(message) def repl(agent): if readline is not None: for entry in agent.store.history_load(agent.profile): readline.add_history(entry.replace("\n", " ")) readline.set_completer(lambda text, state: complete_input(text, state, agent)) readline.parse_and_bind("tab: complete") scheduler_stop = start_scheduler(agent.config, agent.store.seal) try: while True: try: busy = running_agents() base = agent.profile + ("!" if agent.yolo else "") tag = "%s+%d" % (base, busy) if busy else base if Glow.enabled(): print(Glow.statusbar(VERSION, agent.profile, dir_key(), agent.yolo, busy)) line = input(Glow.prompt_text()) else: line = input(paint("tai[%s]› " % tag, Ansi.BOLD, Ansi.GREEN)) except EOFError: print() break except KeyboardInterrupt: print() continue text = line.strip() if not text: continue agent.store.history_add(agent.profile, agent.bot, text) if text.startswith("/"): if not handle_command(agent, text): break continue try: agent.run_turn(text) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) agent.checkpoint() finally: scheduler_stop.set() agent.store.save_session(agent.profile, agent.messages, agent.bot, ctx=getattr(agent, "ctx_id", "")) def update_models_command(args): chat = ChatClient(Config(args)) failed = False for label, total, added, removed in chat.update_models(): if total is None: failed = True print("%s: skipped, source unreachable or empty, old list kept" % label) continue print("%s: %d models, +%d new, -%d removed" % (label, total, len(added), len(removed))) for item in added: print(" + %s" % item) for item in removed: print(" - %s" % item) return 1 if failed else 0 def main(argv=None): args = build_parser().parse_args(argv) if args.install: return install_self() if args.install_telegram: return install_telegram() if args.uninstall_telegram: return uninstall_telegram() if args.version: print("tai %s" % VERSION) return 0 if args.update_models: return update_models_command(args) if args.demo: return tui_demo() if args.demo_shot: return tui_demo_shot() try: theme_name = tui_resolve_theme_name(args.theme) except ValueError as exc: print(exc, file=sys.stderr) return 2 if args.mplex is not None: home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") return MplexCli.main(home, args.mplex) if args.plexer_wrap: home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") profile = args.profile or "default" backend = NativePlexerBackend(home, profile) return backend.run() if args.tui: return grr_tui() if args.profile is None: args.profile = "telegram" if args.telegram else "default" if not PROFILE_RE.match(args.profile): print("invalid profile name", file=sys.stderr) return 1 try: agent = boot(args) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) return 130 except SealError as exc: print("seal error: %s" % exc, file=sys.stderr) return 2 if args.env is not None: if args.env not in ("home", "sandbox"): print("use --env home or --env sandbox", file=sys.stderr) return 1 if args.env == "sandbox": try: ensure_box() except BackendError as exc: print("sandbox unavailable: %s" % exc, file=sys.stderr) return 2 agent.env = args.env if args.scheduler: print("scheduler online, press Ctrl-C to stop") try: run_scheduler_loop(agent.config, agent.store.seal, threading.Event()) except KeyboardInterrupt: print() finally: agent.store.close() return 0 if args.telegram: return run_telegram_bot(agent) if args.prompt: try: agent.run_turn(" ".join(args.prompt)) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) return 130 finally: try: agent.checkpoint() except Exception: pass try: agent.store.close() except Exception: pass return 0 if args.tui_local: try: return tui_chat(agent, theme_name) finally: agent.store.close() if args.resume and not args.telegram and not args.scheduler: resume_turn(agent) banner(agent) try: repl(agent) finally: agent.store.close() return 0 if __name__ == "__main__": sys.exit(main())