#!/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") env_file = os.environ.get("NMI_ENV_FILE") 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")