Files
redmine/redmine_ticket_tui_modern.py
T
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

277 lines
9.1 KiB
Python

#!/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)