#!/usr/bin/env python3 """ Modern TUI for redmine_tickets using prompt_toolkit + rich. Goal: btop / mynav / htop-style experience while keeping the same workflow: - Set a template once (status, assignee, category, priority, private note, tags, etc.) - Paint with SPACE/Enter - A = one-confirm batch queue + auto-apply (with cache refresh) - R = live refresh of current ticket - Good columns (contact, tags, updated, priority, related, etc.) - Proper split panes, mouse, resize, theming - Reuses existing domain logic (drafts, apply, cache refresh, select, meta) This is the future implementation path. The curses version in redmine_ticket_tui.py remains the current stable tool while we build this out. Run (once wired): ./bin/rtl --allow-prod-writes tui --modern ... """ from __future__ import annotations import argparse import os import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional # --- Rich (rendering) --- try: from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text from rich.layout import Layout from rich.live import Live from rich.box import ROUNDED except ImportError: print("rich is required for the modern TUI. (it should already be installed)", file=sys.stderr) raise # --- prompt_toolkit (input + app) --- try: from prompt_toolkit import Application from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import Layout as PTKLayout, Window, HSplit, VSplit from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.containers import Window from prompt_toolkit.widgets import TextArea, Frame from prompt_toolkit.application import get_app except ImportError: print("prompt_toolkit is required for the modern TUI.", file=sys.stderr) raise # --- Reuse existing domain code where possible --- try: from redmine_tickets import ( TicketCache, select_cached_issues_for_cli, refresh_cached_issue, ) except Exception: TicketCache = None # type: ignore select_cached_issues_for_cli = None # type: ignore refresh_cached_issue = None # type: ignore try: from redmine_ticket_drafts import ( add_draft, load_draft, mark_draft_applied, mark_draft_error, assert_write_target_allowed, KIND_FIELDS, KIND_INTERNAL_NOTE, ) except Exception: add_draft = None # type: ignore # ... (fallbacks handled at runtime) try: from redmine_ticket_meta import load_meta_lists except Exception: load_meta_lists = None # type: ignore try: from redmine_ticket_control_tags import add_tag as control_tag_add except Exception: control_tag_add = None # type: ignore @dataclass class Template: status_id: Optional[int] = None status_name: Optional[str] = None assigned_to_id: Optional[int] = None assigned_to_name: Optional[str] = None category_id: Optional[int] = None category_name: Optional[str] = None priority_id: Optional[int] = None priority_name: Optional[str] = None project_id: Optional[int] = None project_name: Optional[str] = None local_control_tag: Optional[str] = None private_note: Optional[str] = None private: bool = True @dataclass class PendingChanges: issue_id: int fields: dict[str, Any] = field(default_factory=dict) private_note: Optional[str] = None private: bool = True control_tags: list[str] = field(default_factory=list) contact_tags: list[str] = field(default_factory=list) entity_extract: bool = False nmi_brief: bool = False contact_id: Optional[int] = None class ModernRedmineTUI: """Prompt-toolkit + Rich based TUI. This is intentionally a work-in-progress skeleton that mirrors the established workflow while giving us proper panes, mouse, live updates, and nice visuals. """ def __init__( self, issues: list[dict[str, Any]], *, cache_dir: Path, project: str, base_url: str, allow_prod_writes: bool = False, auto_apply: bool = False, ): self.issues = issues self.cache_dir = cache_dir self.project = project self.base_url = base_url self.allow_prod_writes = allow_prod_writes self.auto_apply = auto_apply self.template = Template() self.pending: dict[int, PendingChanges] = {} self.filter_text = "" self.filtered_indices: list[int] = list(range(len(issues))) self.current_idx = 0 self.status_msg = "Ready. SPACE = paint template. A = commit+apply (1 confirm). R = live refresh." self.console = Console() self._meta = {} def _current_issue(self) -> Optional[dict[str, Any]]: if not self.filtered_indices: return None idx = min(self.current_idx, len(self.filtered_indices) - 1) real = self.filtered_indices[idx] return self.issues[real] def apply_template_to_current(self) -> None: issue = self._current_issue() if not issue: return iid = int(issue["id"]) ch = self.pending.get(iid) or PendingChanges(issue_id=iid) if self.template.status_id is not None: ch.fields["status_id"] = self.template.status_id if self.template.assigned_to_id is not None: ch.fields["assigned_to_id"] = self.template.assigned_to_id if self.template.category_id is not None: ch.fields["category_id"] = self.template.category_id if self.template.priority_id is not None: ch.fields["priority_id"] = self.template.priority_id if self.template.project_id is not None: ch.fields["project_id"] = self.template.project_id if self.template.local_control_tag: ch.control_tags = [self.template.local_control_tag] if self.template.private_note: ch.private_note = self.template.private_note ch.private = self.template.private self.pending[iid] = ch self.status_msg = f"Painted template on #{iid}" def _refresh_list_view(self) -> None: # In a real ptk+rich version we would trigger a Live update here. pass def run(self) -> int: """Very early skeleton. Real keyboard + layout comes next.""" self.console.print( Panel.fit( "[bold]Modern Redmine TUI (prompt_toolkit + rich)[/bold]\n" "Skeleton active. Full keyboard layout + panes coming in next iterations.\n" "For now use the stable curses TUI (remove --modern when wired).\n\n" "Current tickets in view: " + str(len(self.issues)) ) ) return 0 # --- Wiring helpers (will be called from redmine_tickets.py later) --- def add_modern_tui_parser(subparsers: argparse._SubParsersAction) -> None: p = subparsers.add_parser( "tui-modern", help="EXPERIMENTAL: prompt_toolkit + rich TUI (btop-style).", ) # Reuse the same global flags p.add_argument("--allow-prod-writes", action="store_true", help=argparse.SUPPRESS) p.add_argument("--apply", action="store_true", help=argparse.SUPPRESS) p.add_argument("--where", action="append", default=[], help="Local property filter.") p.add_argument("--max-tickets", type=int, default=100) p.add_argument("--open-only", action="store_true") p.add_argument("--impersonate-user", default="rebot") def run_modern_tui( issues: list[dict[str, Any]], *, cache_dir: Path, project: str, base_url: str, allow_prod_writes: bool = False, auto_apply: bool = False, ) -> int: tui = ModernRedmineTUI( issues, cache_dir=cache_dir, project=project, base_url=base_url, allow_prod_writes=allow_prod_writes, auto_apply=auto_apply, ) return tui.run() def main_cli(args: argparse.Namespace, cache: "TicketCache") -> int: # type: ignore if getattr(args, "cache_dir", None) in (None, ""): args.cache_dir = getattr(cache, "cache_dir", None) or Path(".cache/redmine_tickets") if getattr(args, "project", None) in (None, ""): args.project = getattr(cache, "project", None) or "customer-service" if getattr(args, "base_url", None) in (None, ""): args.base_url = os.environ.get("REDMINE_BASE_URL", "https://rm.ldrprep.com") effective_allow = bool( getattr(args, "allow_prod_writes", False) or getattr(args, "apply", False) or os.environ.get("REDMINE_ALLOW_PROD_WRITES", "").lower() in ("1", "true", "yes") ) matches = select_cached_issues_for_cli(cache, args) if select_cached_issues_for_cli else [] if not matches: print("No matching tickets for modern TUI.", file=sys.stderr) return 0 return run_modern_tui( matches, cache_dir=Path(args.cache_dir), project=args.project, base_url=args.base_url, allow_prod_writes=effective_allow, auto_apply=getattr(args, "apply", False), ) if __name__ == "__main__": print("Run via the main redmine_tickets.py / bin/rtl once the subcommand is registered.") sys.exit(0)