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

1191 lines
46 KiB
Python

#!/usr/bin/env python3
"""
Textual Redmine power TUI — list-first design per user spec.
Default view: big dense list (like curses + screenshot).
Enter / o / v on a row → switches to two-pane split:
Left = scrollable full ticket + journals
Right = reply composer (choose Private note OR Send customer email)
Escape → back to the list.
"""
from __future__ import annotations
import sys
import os
import argparse
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from rich.text import Text
from textual.app import App, ComposeResult
from textual.events import Key
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, ScrollableContainer
from textual.widgets import DataTable, Footer, Header, Static, TextArea, Select, Button, Markdown
_here = Path(__file__).resolve().parent
if str(_here) not in sys.path:
sys.path.insert(0, str(_here))
def _get_core():
try:
from redmine_tickets import (
TicketCache as _TC,
select_cached_issues_for_cli as _sel,
refresh_cached_issue as _ref,
client_from_args as _cf,
)
return _TC, _sel, _ref, _cf
except Exception as e:
raise RuntimeError(f"Core import failed: {e}") from e
try:
from redmine_ticket_tui import PendingChanges, Template
except Exception:
from dataclasses import dataclass, field
from typing import Any, Optional
@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
@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
contact_tags: list[str] = field(default_factory=list)
private_note: Optional[str] = None
entity_extract: bool = False
nmi_brief: bool = False
# Painting parity helpers (entity preview + NMI brief + drafting)
try:
from redmine_ticket_entities import extract_entities_from_issue, format_entities_line
except Exception:
extract_entities_from_issue = None
format_entities_line = None
try:
from redmine_ticket_nmi import brief_for_issue, format_private_note
except Exception:
brief_for_issue = None
format_private_note = None
try:
from redmine_ticket_drafts import (
add_draft,
KIND_CONTACT_FIELDS,
KIND_INTERNAL_NOTE,
KIND_FIELDS,
assert_write_target_allowed,
load_draft,
mark_draft_applied,
mark_draft_error,
)
except Exception:
add_draft = None
KIND_INTERNAL_NOTE = "internal_note"
KIND_CONTACT_FIELDS = "contact_fields"
KIND_FIELDS = "fields"
assert_write_target_allowed = None
load_draft = None
mark_draft_applied = None
mark_draft_error = None
try:
from redmine_ticket_control_tags import add_tag as control_tag_add
except Exception:
control_tag_add = None
try:
from redmine_tickets import apply_draft_to_redmine, TicketCache, refresh_cached_issue, load_cached_issue
except Exception:
apply_draft_to_redmine = None
TicketCache = None
refresh_cached_issue = None
load_cached_issue = None
def extract_contact_id(issue: dict[str, Any]) -> Optional[int]:
"""Best-effort contact id extraction, matching the curses client."""
if not issue:
return None
ht = issue.get("helpdesk_ticket") or {}
if isinstance(ht, dict):
c = ht.get("contact") or {}
if isinstance(c, dict):
cid = c.get("id")
if cid:
try:
return int(cid)
except Exception:
pass
for k in ("contact_id", "helpdesk_contact_id"):
if k in issue and issue[k]:
try:
return int(issue[k])
except Exception:
pass
return None
def icon_for_contact_tag(t: str) -> str:
t = (t or "").lower()
if "spam" in t: return "󰏮"
if "lead" in t: return "󰈸"
return "󰀀"
def icon_for_control_tag(t: str) -> str:
return "󰓒"
def status_icon(s: str) -> str:
s = (s or "").lower()
if "new" in s: return "󰐊"
if "progress" in s: return "󰦕"
if "feedback" in s: return "󰍉"
if "closed" in s: return "󰄬"
return "󰇘"
COLUMNS = [
("#", "id", 7), ("Status", "status", 12), ("Pri", "priority", 4),
("Assignee", "assignee", 12), ("Category", "category", 11),
("Subject", "subject", 38), ("Updated", "updated", 14),
("Contact", "contact", 16), ("Tags", "tags", 14),
]
class RedmineTextualTUI(App):
"""List is the main view. Enter opens a temporary two-pane detail view."""
CSS = """
Screen { background: #0a0a0a; }
DataTable { background: #0a0a0a; color: #d0d0d0; }
DataTable > .datatable--header { background: #1e3a5f; color: #7eb8ff; text-style: bold; }
DataTable > .datatable--cursor { background: #1a3a5f; color: #fff; }
#detail-left { width: 1fr; border-right: thick #222; }
#detail-right { width: 1fr; padding: 0 1; }
#status-bar { background: #111; color: #888; padding: 0 1; }
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("enter", "open_selected", "Open (read+reply)"),
Binding("o", "open_selected", "Open"),
Binding("v", "open_selected", "View"),
Binding("space", "paint", "Paint template"),
Binding("s", "pick_status", "Status"),
Binding("a", "pick_assignee", "Assignee"),
Binding("c", "pick_category", "Category"),
Binding("p", "set_priority", "Priority"),
Binding("1", "quick_priority_1", "Pri 1"),
Binding("2", "quick_priority_2", "Pri 2"),
Binding("3", "quick_priority_3", "Pri 3"),
Binding("n", "set_note", "Note"),
Binding("e", "toggle_entity", "Entity"),
Binding("m", "toggle_nmi", "NMI"),
Binding("t", "set_control_tag", "Tag"),
Binding("C", "add_contact_tag", "CTag"),
Binding("x", "clear_pending", "Clear"),
Binding("r", "refresh_local", "Redraw"),
Binding("R", "refresh_live", "Live refresh"),
Binding("/", "filter", "Filter"),
Binding("A", "apply_batch", "Apply batch"),
Binding("?", "help", "Help"),
Binding("escape", "back_to_list", "Back to list"),
]
def __init__(self, *, cache_dir: Path, project: str, base_url: str, allow_prod_writes=False, open_only=False, max_tickets=600, **_):
super().__init__()
self.cache_dir = cache_dir
self.project = project
self.base_url = base_url
self.allow_prod_writes = allow_prod_writes
self.open_only = open_only
self.max_tickets = max_tickets
self.issues = []
self.pending = {}
self.template = Template()
self.filter_text = ""
self.current_issue_id = None
self._client = None
self.in_detail_mode = False
self.list_table = None
self.detail_split = None
def _get_client(self):
if self._client: return self._client
if not self.allow_prod_writes: return None
try:
_,_,_,cf = _get_core()
a = argparse.Namespace()
a.base_url = self.base_url; a.project = self.project
a.cache_dir = str(self.cache_dir); a.allow_prod_writes = True
a.config = a.impersonate_user = None
self._client = cf(a)
return self._client
except Exception:
return None
# === DEFAULT VIEW: the list ===
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static(f"Redmine Power TUI | {self.project} | {self.base_url}", id="header-line")
self.list_table = DataTable(id="main-list", zebra_stripes=True, show_cursor=True)
yield self.list_table
yield Static("", id="status-bar")
yield Footer()
def on_mount(self):
t = self.list_table
t.cursor_type = "row"
for title, key, w in COLUMNS:
t.add_column(title, key=key, width=w)
self.load_issues()
self.refresh_table()
if self.issues:
t.move_cursor(row=0)
t.focus()
self._update_status()
def on_key(self, event: Key) -> None:
"""Force Enter to open the detail view even if DataTable tries to swallow it."""
if event.key == "enter" and not getattr(self, "in_detail_mode", False):
event.prevent_default()
self.action_open_selected()
return
# === DETAIL MODE ===
def enter_detail_mode(self):
if self.in_detail_mode or not self.issues: return
t = self.list_table
if t.cursor_row is None: return
try:
iid = int(t.get_row_at(t.cursor_row)[0])
except Exception:
return
self.current_issue_id = iid
self.in_detail_mode = True
t.display = False
left = Vertical(
Static(f"#{iid} (cached)", id="detail-header"),
ScrollableContainer(Markdown("", id="detail-viewer"), id="viewer-scroll"),
id="detail-left"
)
right = Vertical(
Static("Reply", id="editor-title"),
Select([("Private note (internal)", "private"), ("Send email to customer", "customer")],
prompt="Note type", id="note-type", value="private"),
Select([("New","1"),("In Progress","2"),("Feedback","3"),("Closed","5")],
prompt="Status", id="status-select", allow_blank=True),
TextArea("", id="reply-area"),
Button("Paint", id="btn-paint"),
Button("Apply (A)", id="btn-apply"),
Button("Back (Esc)", id="btn-back"),
Static("", id="pending-summary"),
id="detail-right"
)
self.detail_split = Horizontal(left, right, id="detail-split")
self.mount(self.detail_split)
# Focus the composer so user can immediately type or tab to status
try:
self.query_one("#reply-area", TextArea).focus()
except Exception:
pass
# Always render from cache immediately — we already have the data
self._render_cached_detail(iid)
base_issue = next((x for x in self.issues if int(x.get("id",0)) == iid), None)
if base_issue:
try:
self._seed_detail_painters(base_issue)
except Exception:
pass
# Optional live refresh only on explicit R (see action_refresh_live)
def exit_detail_mode(self):
if not self.in_detail_mode: return
if self.detail_split:
self.detail_split.remove()
self.detail_split = None
self.in_detail_mode = False
self.list_table.display = True
self.list_table.focus()
self._update_status()
def _render_cached_detail(self, iid: int):
"""Instant render from whatever we already have in the list cache.
No network call here — that's what was causing the "loading forever" problem.
"""
base = next((x for x in self.issues if int(x.get("id",0)) == iid), None)
if not base:
return
# Use whatever is in the cached entry (description + journals are often present)
try:
self.query_one("#detail-header", Static).update(f"#{iid} {base.get('subject','')}")
self.query_one("#detail-viewer", Markdown).update(self._build_markdown(base))
except Exception:
pass
self._refresh_pending_display(iid)
# ------------------------------------------------------------------
# Painting system (ported from curses client for parity)
# ------------------------------------------------------------------
def _seed_detail_painters(self, issue: dict):
"""Populate the right-pane selects from current template + meta."""
try:
meta = self._get_meta_lists()
# Status
try:
st_sel = self.query_one("#status-select", Select)
statuses = meta.get("statuses", [])
if statuses:
st_sel.set_options([(st.get("name", ""), str(st.get("id", ""))) for st in statuses])
except Exception:
pass
# Assignee
try:
asg_sel = self.query_one("#assignee-select", Select)
users = meta.get("users", [])
if users:
asg_sel.set_options([(u.get("name", "") or u.get("login", ""), str(u.get("id", ""))) for u in users])
except Exception:
pass
# Category
try:
cat_sel = self.query_one("#category-select", Select)
cats = meta.get("categories", [])
if cats:
cat_sel.set_options([(c.get("name", ""), str(c.get("id", ""))) for c in cats])
except Exception:
pass
# Seed from current template
pend = self.pending.get(self.current_issue_id) if self.current_issue_id else None
if self.template.status_id:
# try to set select value
pass # Select value setting is a bit fiddly; user can pick
if pend and pend.private_note:
try:
self.query_one("#reply-area", TextArea).text = pend.private_note
except Exception:
pass
except Exception:
pass
def _get_meta_lists(self):
"""Return meta lists (categories, statuses, users, etc.)."""
if hasattr(self, "_meta_cache") and self._meta_cache:
return self._meta_cache
try:
from redmine_ticket_meta import load_meta_lists
self._meta_cache = load_meta_lists(self.cache_dir, self.project) or {}
return self._meta_cache
except Exception:
return {}
def apply_template_to_current(self) -> None:
"""Paint the global template onto the current ticket (creates/merges PendingChanges)."""
if not self.current_issue_id:
# Try to use highlighted in list
if self.list_table and self.list_table.cursor_row is not None:
try:
row = self.list_table.get_row_at(self.list_table.cursor_row)
self.current_issue_id = int(row[0])
except Exception:
return
else:
return
iid = self.current_issue_id
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
if not issue:
return
contact_id = extract_contact_id(issue)
fields: dict[str, Any] = {}
if self.template.status_id is not None:
fields["status_id"] = self.template.status_id
if self.template.assigned_to_id is not None:
fields["assigned_to_id"] = None if self.template.assigned_to_id == 0 else self.template.assigned_to_id
if self.template.priority_id is not None:
fields["priority_id"] = self.template.priority_id
if self.template.category_id is not None:
fields["category_id"] = None if self.template.category_id == 0 else self.template.category_id
if self.template.project_id is not None:
fields["project_id"] = self.template.project_id
ch = self.pending.get(iid) or PendingChanges(issue_id=iid, contact_id=contact_id)
if fields:
ch.fields = {** (ch.fields or {}), **fields}
if self.template.private_note:
ch.private_note = self.template.private_note
ch.private = True
if self.template.local_control_tag:
if self.template.local_control_tag not in (ch.control_tags or []):
ch.control_tags = (ch.control_tags or []) + [self.template.local_control_tag]
if self.template.contact_tags:
for t in self.template.contact_tags:
if t not in (ch.contact_tags or []):
ch.contact_tags = (ch.contact_tags or []) + [t]
ch.entity_extract = ch.entity_extract or self.template.entity_extract
ch.nmi_brief = ch.nmi_brief or self.template.nmi_brief
ch.contact_id = contact_id or ch.contact_id
self.pending[iid] = ch
if self.in_detail_mode:
self._refresh_pending_display(iid)
self.notify(f"Painted template to #{iid}")
def set_template_status(self, status_id: Optional[int], name: Optional[str]):
self.template.status_id = status_id
self.template.status_name = name
self.notify(f"Template Status → {name or 'cleared'}")
def set_template_assignee(self, user_id: Optional[int], name: Optional[str]):
self.template.assigned_to_id = user_id
self.template.assigned_to_name = name
self.notify(f"Template Assignee → {name or 'unassigned'}")
def set_template_category(self, cat_id: Optional[int], name: Optional[str]):
self.template.category_id = cat_id
self.template.category_name = name
self.notify(f"Template Category → {name or 'cleared'}")
def set_priority_numeric(self, value: int):
self.template.priority_id = value
self.template.priority_name = str(value)
self.notify(f"Template Priority → {value}")
def set_local_control_tag(self, tag: str):
self.template.local_control_tag = tag
self.notify(f"Template local tag → {tag}")
def add_contact_tag_to_template(self, tag: str):
if tag and tag not in (self.template.contact_tags or []):
self.template.contact_tags = (self.template.contact_tags or []) + [tag]
self.notify(f"Template contact tags: {self.template.contact_tags}")
def set_template_private_note(self, note: Optional[str]):
self.template.private_note = note or None
self.notify("Template note updated")
def toggle_entity_for_current(self):
if not self.current_issue_id:
return
iid = self.current_issue_id
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
ch.entity_extract = not ch.entity_extract
self.pending[iid] = ch
if self.in_detail_mode:
self._refresh_pending_display(iid)
self.notify(f"Entity extract toggled for #{iid}")
def toggle_nmi_for_current(self):
if not self.current_issue_id:
return
iid = self.current_issue_id
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
ch.nmi_brief = not ch.nmi_brief
self.pending[iid] = ch
if self.in_detail_mode:
self._refresh_pending_display(iid)
self.notify(f"NMI brief toggled for #{iid}")
def _refresh_pending_display(self, iid: int):
try:
box = self.query_one("#pending-box", Static)
pend = self.pending.get(iid)
if pend:
box.update("Pending: " + (getattr(pend, "private_note", "") or "")[:40] + " " + str(pend.fields or {}))
else:
box.update("")
except Exception:
pass
def _build_markdown(self, issue: dict) -> str:
lines = []
lines.append(f"# {issue.get('subject','')}")
lines.append("")
lines.append(f"**Status:** {_safe_get(issue,'status','name','')}")
lines.append(f"**Assigned:** {_safe_get(issue,'assigned_to','name','')}")
lines.append("")
lines.append("## Description")
lines.append("")
desc = (issue.get("description") or "(no description)").replace("\r\n", "\n")
lines.append(desc)
lines.append("")
for j in (issue.get("journals") or [])[-12:]:
user = _safe_get(j, "user", "name", _safe_get(j, "user", "login", ""))
created = str(j.get("created_on",""))[:16]
notes = (j.get("notes") or "").strip()
if notes:
priv = " (private)" if j.get("private_notes") else ""
lines.append(f"**{created} — {user}{priv}**")
lines.append(notes)
lines.append("---")
return "\n".join(lines)
# === LIST LOGIC ===
def load_issues(self):
"""Load using the same logic as the curses client for consistent columns + ordering.
Falls back to raw cache + explicit sort if selector unavailable.
"""
TC, select_cached_issues_for_cli, _, _ = _get_core()
cache = TC(self.cache_dir, self.project)
raw = []
try:
raw = list(cache.iter_cached_issues())
except Exception:
raw = []
issues = raw
# Prefer the shared selector (gives normalized fields + ordering)
if select_cached_issues_for_cli is not None:
try:
class _A: pass
a = _A()
a.where = []
a.control_tag = None
a.status = None
a.max_tickets = self.max_tickets
a.order = "newest-updated-first"
a.open_only = self.open_only
a.has_attachments = False
a.cache_dir = str(self.cache_dir)
a.project = self.project
selected = select_cached_issues_for_cli(cache, a)
if selected:
issues = selected
except Exception:
pass # fall back to raw + manual sort
# Ensure we have the newest-updated-first order as a hard guarantee
def _updated_key(x):
return x.get("updated_on") or x.get("created_on") or ""
try:
issues = sorted(issues, key=_updated_key, reverse=True)
except Exception:
pass
if self.open_only:
issues = [i for i in issues if "closed" not in str(_safe_get(i, "status", "name", "")).lower()]
self.issues = issues[:self.max_tickets]
def refresh_table(self):
t = self.list_table
t.clear()
for iss in self._apply_filter(self.issues):
t.add_row(*self._make_row(iss), key=str(iss.get("id")))
self._update_status()
def _apply_filter(self, issues):
if not self.filter_text: return issues
q = self.filter_text.lower()
return [i for i in issues if q in str(i.get("id","")) + str(i.get("subject","")).lower()]
def _make_row(self, issue):
iid = issue.get("id")
pend = self.pending.get(int(iid)) if iid else None
# Pending painting markers (parity with curses)
markers = ""
if pend:
bits = []
if pend.fields: bits.append("F")
if pend.private_note: bits.append("N")
if pend.control_tags or pend.contact_tags: bits.append("T")
if pend.entity_extract: bits.append("E")
if pend.nmi_brief: bits.append("M")
if bits:
markers = "[" + "".join(bits) + "] "
# Robust extraction: status/priority can be dict or string
st_obj = issue.get("status") or {}
if isinstance(st_obj, dict):
st = st_obj.get("name", "") or ""
else:
st = str(st_obj)
pri_obj = issue.get("priority") or {}
if isinstance(pri_obj, dict):
pri = pri_obj.get("name", "") or ""
else:
pri = str(pri_obj)
asg = _safe_get(issue, "assigned_to", "name", "") or _safe_get(issue, "assigned_to", "login", "") or ""
cat = _safe_get(issue, "category", "name", "") or ""
subj = markers + str(issue.get("subject",""))[:36]
upd = str(issue.get("updated_on",""))[:14]
contact = ""
ht = issue.get("helpdesk_ticket") or {}
if isinstance(ht, dict):
c = ht.get("contact") or {}
if isinstance(c, dict):
contact = c.get("company") or c.get("email") or ""
tags = self._render_tags(issue)
st_d = f"{status_icon(st)} {st}" if st else st
if not tags.plain and contact:
tags = Text(f"[{contact[:7]}]", style="dim cyan")
return [str(iid), st_d, pri, asg, cat, subj, upd, contact, tags]
def _render_tags(self, issue):
icons, br = [], []
ht = issue.get("helpdesk_ticket") or {}
if isinstance(ht, dict):
c = ht.get("contact") or {}
if isinstance(c, dict):
for t in (c.get("tags") or []):
nm = (t.get("name") if isinstance(t,dict) else str(t)).strip()
if nm:
icons.append(icon_for_contact_tag(nm))
br.append(f"[{nm[:5]}]")
iid = int(issue.get("id",0))
pend = self.pending.get(iid)
if pend:
for t in (pend.control_tags or []):
icons.append(icon_for_control_tag(t))
br.append(f"[{t[:5]}]")
disp = " ".join(icons + br).strip()
return Text(disp, style="cyan") if disp else Text("")
# === ACTIONS ===
def action_paint(self):
"""SPACE: paint the global template onto the highlighted ticket from the list (primary like curses)."""
t = getattr(self, "list_table", None)
iid = None
if t is not None and getattr(t, "cursor_row", None) is not None:
try:
iid = int(t.get_row_at(t.cursor_row)[0])
except Exception:
pass
if not iid:
iid = getattr(self, "current_issue_id", None)
if not iid:
self.notify("Highlight a row in the list, then press SPACE to paint")
return
self.current_issue_id = iid
self.apply_template_to_current()
self.refresh_table()
self.notify(f"Painted #{iid} (pending now has changes)")
self._update_status()
def action_open_selected(self):
self.enter_detail_mode()
def action_back_to_list(self):
self.exit_detail_mode()
def action_refresh_local(self):
if self.in_detail_mode and self.current_issue_id:
self._render_cached_detail(self.current_issue_id)
else:
self.refresh_table()
def action_refresh_live(self):
"""R key: do the (slow) live fetch and update the view.
This is now explicit — opening a ticket no longer blocks on the network.
"""
if not self.current_issue_id:
return
cli = self._get_client()
if not cli:
self.notify("Live refresh requires --allow-prod-writes")
return
self.notify("Refreshing from Redmine (can be slow)...")
TC, _, ref, _ = _get_core()
try:
c = TC(self.cache_dir, self.project)
fresh = ref(c, cli, self.current_issue_id)
if fresh:
for i, x in enumerate(self.issues):
if int(x.get("id", 0)) == self.current_issue_id:
self.issues[i] = fresh
break
if self.in_detail_mode:
# Re-render from the now-updated cache entry
self._render_cached_detail(self.current_issue_id)
else:
self.refresh_table()
self.notify(f"#{self.current_issue_id} refreshed from live")
except Exception as e:
self.notify(f"Live refresh failed: {e}")
# === Painter actions (ported systematically) ===
def action_pick_category(self):
self.template.category_name = "Customer Service"
self.notify("Template Category → Customer Service (use SPACE to paint)")
def action_pick_assignee(self):
self.template.assigned_to_name = "Jason"
self.template.assigned_to_id = 1
self.notify("Template Assignee → Jason (paint with SPACE)")
def action_toggle_entity(self) -> None:
"""e key - real-time entity preview + mark (list-first, curses parity)."""
t = getattr(self, "list_table", None)
iid = None
issue = None
if t and getattr(t, "cursor_row", None) is not None:
try:
row = t.get_row_at(t.cursor_row)
iid = int(row[0])
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
except Exception:
pass
if not iid:
iid = getattr(self, "current_issue_id", None)
if iid:
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
if not iid or not issue:
self.notify("Highlight a row then press e")
return
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
ch.entity_extract = not ch.entity_extract
self.pending[iid] = ch
if ch.entity_extract and extract_entities_from_issue:
try:
ents = extract_entities_from_issue(issue)
line = format_entities_line(ents) if format_entities_line and ents else str(ents or "(none)")
self.notify(f"Entity ON #{iid}: {line[:85]}")
except Exception as ee:
self.notify(f"Entity ON #{iid} (err: {ee})")
else:
self.notify(f"Entity {'ON' if ch.entity_extract else 'OFF'} for #{iid}")
self.refresh_table()
self._update_status()
def action_toggle_nmi(self) -> None:
"""m key - mark for NMI brief (real generation on A)."""
t = getattr(self, "list_table", None)
iid = None
if t and getattr(t, "cursor_row", None) is not None:
try:
iid = int(t.get_row_at(t.cursor_row)[0])
except Exception:
pass
if not iid:
iid = getattr(self, "current_issue_id", None)
if not iid:
self.notify("Highlight a row then press m")
return
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
ch.nmi_brief = not ch.nmi_brief
self.pending[iid] = ch
env = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
self.notify(f"NMI {'ON' if ch.nmi_brief else 'OFF'} for #{iid} (real on A using {env})")
self.refresh_table()
self._update_status()
def action_set_control_tag(self) -> None:
"""t key: prompt for local control tag (simple for now)."""
from textual.widgets import Input
self.notify("Type tag then press Enter in the input (or use detail pane). For quick: set via template in practice.")
# Minimal: user can use SPACE after setting via other means; for full parity we accept direct
# For now just remind
self.notify("Use t + type in future modal. For now set via curses if needed or use SPACE after manual set.")
def action_add_contact_tag(self) -> None:
"""C key: prompt for contact tag."""
self.notify("C pressed: contact tag painting supported in template. Use SPACE after setting or in detail.")
def action_apply_batch(self):
"""A key: commit + auto-apply all painted changes (full parity with curses)."""
if not self.pending:
self.notify("No pending changes.")
return
if not self.allow_prod_writes:
self.notify("Use --allow-prod-writes to actually apply.")
return
results = self._commit_batch()
for r in results[:8]:
self.notify(str(r)[:90])
self.refresh_table()
self._update_status()
def action_commit_apply(self):
"""Apply button in detail (or A key): paint the current detail pane then run full batch commit."""
if getattr(self, "in_detail_mode", False) and getattr(self, "current_issue_id", None):
try:
self._paint_current_pane()
except Exception:
pass
self.action_apply_batch()
def action_help(self):
self.notify("List primary (curses parity): SPACE=paint | e/m entity/nmi | s/c/a/p fields | SPACE paint | A commit\n")
def action_pick_status(self):
# Simple working version for painting parity
self.template.status_name = "In Progress"
self.template.status_id = 2
self.notify("Template: status=In Progress (press SPACE to paint)")
def action_pick_assignee(self):
self.template.assigned_to_name = "Jason"
self.template.assigned_to_id = None
self.notify("Template: assignee (paint with SPACE)")
def action_pick_category(self):
self.template.category_name = "Customer Service"
self.notify("Template: category (paint with SPACE)")
def action_set_priority(self):
self.set_priority_numeric(2)
self.notify("Template priority=2 (paint with SPACE)")
def action_quick_priority_1(self): self.set_priority_numeric(1)
def action_quick_priority_2(self): self.set_priority_numeric(2)
def action_quick_priority_3(self): self.set_priority_numeric(3)
def action_set_note(self):
self.template.private_note = "[note]"
self.notify("Template note set (edit in detail or paint)")
def action_clear_pending(self):
self.pending.clear()
self.refresh_table()
self._update_status()
self.notify("Cleared all pending")
def action_filter(self):
self.notify("Filter: type / then text (basic support)")
# Minimal: could open input, for now just remind
"""Apply button in detail view (or A): capture pane changes then run the batch commit (painting parity)."""
if getattr(self, "in_detail_mode", False) and getattr(self, "current_issue_id", None):
try:
self._paint_current_pane()
except Exception:
pass
# Delegate to the real batch logic (which does early entity/nmi + one draft per ticket)
self.action_apply_batch()
self.notify("List primary (curses parity): SPACE=paint template | s=status a=assignee c=cat p/1/2/3=pri | n=note e=entity m=nmi | t=local C=contact | x=clear | A=batch | R=live | o=optional open read+reply | r/redraw /=filter ? =this")
def action_paint_status(self):
"""'s' key: quick status paint (opens picker or sets from current select)."""
if not self.current_issue_id:
# If we're in the list, open the ticket first
self.enter_detail_mode()
if not self.current_issue_id:
return
iid = self.current_issue_id
pend = self.pending.get(iid)
if pend is None:
pend = PendingChanges(issue_id=iid)
self.pending[iid] = pend
# Try to read the current select value in the right pane
try:
sel = self.query_one("#status-select", Select)
val = sel.value
if val:
pend.fields["status_name"] = val
self.notify(f"Status painted: {val} for #{iid}")
self._refresh_pending_display(iid)
return
except Exception:
pass
self.notify("Press Enter on a ticket, choose a status in the right pane, then press s or SPACE")
# Make SPACE call paint from both views
def _update_status(self):
try:
bar = self.query_one("#status-bar", Static)
m = "DETAIL" if self.in_detail_mode else "LIST"
sort_info = "newest-updated-first"
t = self.template
tmpl = []
if t.status_name: tmpl.append(f"st:{t.status_name}")
if t.assigned_to_name: tmpl.append(f"asg:{t.assigned_to_name[:6]}")
if t.priority_id: tmpl.append(f"pri:{t.priority_id}")
tmpl_str = " ".join(tmpl) if tmpl else "template-empty"
bar.update(f"[{m}] {sort_info} | {tmpl_str} | Filter:{self.filter_text or '-'} | {len(self.issues)} shown | Pending:{len(self.pending)}")
except Exception:
pass
def _commit_batch(self) -> list[str]:
"""Real commit path with early entity/nmi special handling + actual apply (parity with curses)."""
results: list[str] = []
if add_draft is None:
results.append("Draft system unavailable")
return results
# Safety
if assert_write_target_allowed:
try:
assert_write_target_allowed(self.base_url, allow_prod_writes=self.allow_prod_writes)
except RuntimeError as exc:
results.append(f"SAFETY: {exc}")
return results
client = self._get_client()
drafts_to_apply = [] # (iid, Path)
for iid, ch in list(self.pending.items()):
try:
# --- Early special: entity + NMI (exactly like fixed curses) ---
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
if issue is None and load_cached_issue and TicketCache:
try:
c = TicketCache(self.cache_dir, self.project)
issue = load_cached_issue(c, iid)
except Exception:
pass
mode = os.environ.get("NMI_MODE", "dev")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
if ch.entity_extract and issue and extract_entities_from_issue:
try:
ents = extract_entities_from_issue(issue)
line = format_entities_line(ents) if format_entities_line and ents else str(ents or "")
ent_note = f"Entities extracted: {line}"
if ch.private_note:
if "Entities extracted" not in (ch.private_note or ""):
ch.private_note = ((ch.private_note or "") + "\n" + ent_note).strip()
else:
ch.private_note = ent_note
ch.private = True
if control_tag_add:
try:
control_tag_add(self.cache_dir, tag="entity-extract", issue_id=iid, properties=["textual"], annotation="via textual tui")
except Exception:
pass
results.append(f"#{iid} entity: {line[:70]}")
except Exception as ee:
results.append(f"#{iid} entity err: {ee}")
if ch.nmi_brief and issue and brief_for_issue and format_private_note:
try:
ents = extract_entities_from_issue(issue) if extract_entities_from_issue else []
payload = brief_for_issue(issue, ents, mode=mode, env_file=env_file)
real_note = format_private_note(payload)
ch.private_note = real_note
ch.private = True
results.append(f"#{iid} NMI brief (ok={payload.get('ok')})")
except Exception as ne:
results.append(f"#{iid} NMI err (env={env_file}): {ne}")
if not ch.private_note:
ch.private_note = f"[NMI attempted; failed (env={env_file}). See rtl entities nmi --issue-id {iid}]"
ch.private = True
# Normal queuing (one draft per ticket)
if ch.fields or ch.private_note:
fields = dict(ch.fields or {})
if ch.private_note:
fields["notes"] = ch.private_note
if ch.private:
fields["private_notes"] = True
try:
kind = KIND_FIELDS if ch.fields else KIND_INTERNAL_NOTE
p = add_draft(
self.cache_dir,
issue_id=iid,
kind=kind,
body=ch.private_note or "",
fields=fields if ch.fields else None,
private=ch.private,
project=self.project,
base_url=self.base_url,
)
drafts_to_apply.append((iid, p))
results.append(f"#{iid} draft queued: {p.stem}")
except Exception as qe:
results.append(f"#{iid} queue err: {qe}")
# Local control / contact tags (as before)
if ch.control_tags and control_tag_add:
for tag in ch.control_tags:
try:
control_tag_add(self.cache_dir, tag=tag, issue_id=iid, properties=["textual"], annotation="painted")
results.append(f"#{iid} local tag {tag}")
except Exception as te:
results.append(f"#{iid} tag err: {te}")
if ch.contact_tags and ch.contact_id and add_draft:
for t in ch.contact_tags:
try:
p = add_draft(self.cache_dir, contact_id=ch.contact_id, kind=KIND_CONTACT_FIELDS, fields={"add_tag": t}, project=self.project, base_url=self.base_url)
results.append(f"#{iid} contact tag draft {p.stem}: {t}")
except Exception as ce:
results.append(f"#{iid} contact tag err: {ce}")
except Exception as exc:
results.append(f"#{iid} ERROR: {exc}")
# === THE IMPORTANT PART: actually apply the drafts we just created ===
if self.allow_prod_writes and client and apply_draft_to_redmine and load_draft:
for iid, p in drafts_to_apply:
try:
_, draft_dict = load_draft(self.cache_dir, p.stem)
res = apply_draft_to_redmine(
client, draft_dict,
impersonate_user=None,
allow_customer_email=False,
)
results.append(f"#{iid} APPLIED: {res}")
# refresh from live
if refresh_cached_issue and TicketCache:
try:
c = TicketCache(self.cache_dir, self.project)
fresh = refresh_cached_issue(c, client, iid)
if fresh:
for i, x in enumerate(self.issues):
if int(x.get("id", 0)) == iid:
self.issues[i] = fresh
break
except Exception:
pass
except Exception as ae:
results.append(f"#{iid} apply error: {ae}")
self.pending.clear()
results.append("Batch complete.")
return results
def on_button_pressed(self, e: Button.Pressed):
if e.button.id == "btn-back":
self.exit_detail_mode()
elif e.button.id == "btn-paint":
if self.in_detail_mode:
self._paint_current_pane()
else:
self.action_paint()
elif e.button.id == "btn-apply":
self.action_commit_apply()
def on_select_changed(self, event: Select.Changed) -> None:
if not self.in_detail_mode or not self.current_issue_id:
return
iid = self.current_issue_id
pend = self.pending.get(iid)
if pend is None:
pend = PendingChanges(issue_id=iid)
self.pending[iid] = pend
if event.select.id == "status-select" and event.value:
try:
pend.fields["status_id"] = int(event.value)
except (ValueError, TypeError):
pend.fields["status_name"] = event.value # fallback
self._refresh_pending_display(iid)
def _paint_current_pane(self):
"""Actual paint implementation used from the detail view."""
if not self.current_issue_id:
return
iid = self.current_issue_id
pend = self.pending.get(iid)
if pend is None:
pend = PendingChanges(issue_id=iid)
self.pending[iid] = pend
try:
note_type = self.query_one("#note-type", Select).value
reply = self.query_one("#reply-area", TextArea).text or ""
status_val = self.query_one("#status-select", Select).value
except Exception:
note_type, reply, status_val = "private", "", None
if reply.strip():
pend.private_note = reply
pend.private = (note_type == "private")
if status_val:
try:
pend.fields["status_id"] = int(status_val)
except (ValueError, TypeError):
pend.fields["status_name"] = status_val
self._refresh_pending_display(iid)
self.notify(f"Painted for #{iid}")
def _safe_get(d, *ks, default=""):
for k in ks:
if isinstance(d, dict) and k in d: d = d[k]
else: return default
return d or default
# CLI glue (kept minimal)
def add_textual_tui_parser(subparsers):
p = subparsers.add_parser("textual-tui", help="Modern list-first TUI (Enter opens split view)")
p.add_argument("--allow-prod-writes", action="store_true")
p.add_argument("--apply", action="store_true")
p.add_argument("--open-only", action="store_true", help="Only show open tickets (default behavior in list-first mode)")
p.add_argument("--max-tickets", type=int, default=600, help="Limit number of tickets loaded")
def run_textual_tui_cli(args, cache):
RedmineTextualTUI(
cache_dir=Path(getattr(args,"cache_dir","cache/redmine_tickets")),
project=getattr(args,"project","customer-service"),
base_url=getattr(args,"base_url","https://rm.ldrprep.com"),
allow_prod_writes=getattr(args,"allow_prod_writes",False),
open_only=getattr(args, "open_only", False),
max_tickets=getattr(args, "max_tickets", 600),
).run()
return 0
if __name__ == "__main__":
print("Run: ./bin/rtl textual-tui --allow-prod-writes")