diff --git a/slack_cli/__main__.py b/slack_cli/__main__.py index 59678b8..ef29482 100644 --- a/slack_cli/__main__.py +++ b/slack_cli/__main__.py @@ -81,8 +81,12 @@ def resolve_user(state: dict, user_id: str) -> str: if not isinstance(member, dict): return user_id - # Slack Redux State Stores Name Fields at Top Level - name = member.get("display_name") or member.get("real_name") or member.get("name") + # Slack Handle (e.g. "evan") > Display Name > Real Name > fallback to ID + name = member.get("name") # Slack handle / username + if name and name != user_id: + return name + + name = member.get("display_name") or member.get("real_name") if name: return name @@ -96,6 +100,23 @@ def resolve_user(state: dict, user_id: str) -> str: return user_id +def resolve_mentions(state: dict, text: str) -> str: + """Replace Slack User Mentions (<@USERID>) With @DisplayNames""" + if not isinstance(text, str): + return text + + members = state.get("members", {}) + if not isinstance(members, dict): + return text + + def _replace(match): + user_id = match.group(1) + resolved = resolve_user(state, user_id) + return f"@{resolved}" + + return re.sub(r"<@([A-Z0-9]+)>", _replace, text) + + def parse_since(value: str) -> datetime: """Parse a --since Value Into a Datetime @@ -243,6 +264,7 @@ def cmd_messages( user_id = msg.get("user", msg.get("bot_id", "?")) user_name = resolve_user(state, user_id) + text = resolve_mentions(state, text) subtype = msg.get("subtype", "") # dfindexeddb Represents JS undefined as an Undefined object @@ -280,39 +302,92 @@ def cmd_messages( top_level.append(msg) # Build Display List — Each Top-Level Entry Followed by Its Replies - # item = (msg | None, depth) — None msg means orphan thread header - # depth 0 = root (top-level message or orphan thread header) - # depth 1 = thread reply - display: list[tuple[dict | None, int]] = [] + # item = (msg, depth) + # depth 0 = root (top-level message OR thread parent) + # depth 1 = thread reply (or ellipsis row) + # Every root carries `has_replies` so the renderer can pick glyphs. + display: list[tuple[dict, int]] = [] seen_parents: set[str] = {m["ts"] for m in top_level} for msg in top_level: + replies = threads.get(msg["ts"], []) + msg["has_replies"] = bool(replies) + if replies: + msg["is_thread_parent"] = True display.append((msg, 0)) - for reply in threads.get(msg["ts"], []): + for reply in replies: display.append((reply, 1)) - # Collect Orphan Thread Groups — Replies Whose Parent Isn't Shown + # Collect Orphan Thread Groups — Replies Whose Parent Isn't a Top-Level Match orphan_groups: list[tuple[str, list[dict]]] = [] for thread_ts, replies in threads.items(): if thread_ts not in seen_parents: orphan_groups.append((thread_ts, replies)) - - # Sort Orphan Groups by Earliest Reply Timestamp orphan_groups.sort(key=lambda g: g[1][0]["dt"]) - # Append Each Orphan Group With a Header Placeholder + # Append Each Orphan Group — Resolve Parent From Cache When Possible. + # If parent isn't cached, emit a synthetic header with is_orphan=True so + # the renderer shows a consistent row + "parent not cached" body. for thread_ts, replies in orphan_groups: - # Use First Reply's Channel and ID for the Header - header = { - "channel": replies[0]["channel"], - "channel_id": replies[0]["channel_id"], - "thread_ts": thread_ts, - } - display.append((None, 0)) # Placeholder for header + cid = replies[0]["channel_id"] + ch_name = replies[0]["channel"] + channel_msgs_raw = messages_store.get(cid) + parent_raw = ( + channel_msgs_raw.get(thread_ts) + if isinstance(channel_msgs_raw, dict) else None + ) + + if isinstance(parent_raw, dict) and isinstance(parent_raw.get("text"), str): + user_id = parent_raw.get("user", parent_raw.get("bot_id", "?")) + subtype = parent_raw.get("subtype", "") + if not isinstance(subtype, str): + subtype = "" + parent_msg = { + "channel": ch_name, + "channel_id": cid, + "ts": thread_ts, + "thread_ts": thread_ts, + "dt": ts_to_datetime(thread_ts), + "user": resolve_user(state, user_id), + "user_id": user_id, + "text": resolve_mentions(state, parent_raw.get("text", "")), + "subtype": subtype, + "is_thread_parent": True, + "has_replies": True, + } + reply_count = parent_raw.get("reply_count") + shown = len(replies) + if isinstance(reply_count, int): + # Known: show ellipsis only if replies were actually skipped + skipped = reply_count - shown if reply_count > shown else 0 + else: + # Unknown reply_count — signal ambiguous gap + skipped = None + else: + # Orphan — Parent Not in Local Cache + parent_msg = { + "channel": ch_name, + "channel_id": cid, + "ts": thread_ts, + "thread_ts": thread_ts, + "dt": ts_to_datetime(thread_ts), + "user": "???", + "user_id": "", + "text": "", + "subtype": "", + "is_thread_parent": True, + "is_orphan": True, + "has_replies": True, + } + # No reply_count available for orphans → generic ellipsis + skipped = None + + display.append((parent_msg, 0)) + # Suppress Ellipsis When We Know No Replies Were Skipped + if skipped != 0: + display.append(({"ellipsis": True, "skipped": skipped}, 1)) for reply in replies: display.append((reply, 1)) - # Patch the Placeholder With Header Info - display[-len(replies) - 1] = (header, 0) # Print Last N Messages (Count depth=0 Entries Only) if len(display) > 0: @@ -330,52 +405,117 @@ def cmd_messages( else: visible = [] + # ANSI Palette + dim = "\033[90m" ts_color = "\033[90m" ch_color = "\033[36m" user_color = "\033[33m" + bullet_top = "\033[36m●\033[0m" # Top-level message (cyan dot) + bullet_thread = "\033[33m◆\033[0m" # Thread parent (yellow diamond) reset = "\033[0m" - bar_str = f"\033[90m│\033[0m" + bar = f"{dim}│{reset}" # Thread continuation gutter + branch_mid = f"{dim}├{reset}" # Ellipsis branch + branch_end = f"{dim}└{reset}" # Final reply branch + # Horizontal Rule Between Roots — Sized to Terminal Width (Clamped) + try: + term_width = os.get_terminal_size().columns + except OSError: + term_width = 78 + rule_width = max(40, min(term_width, 100)) + rule = f"{dim}{'─' * rule_width}{reset}" + + def wrap_text(text: str, prefix: str, limit: int = 500) -> None: + """Print Message Body, Prefixing Every Line""" + for line in text[:limit].split("\n"): + print(f"{prefix}{line}") + + prev_depth = None for idx, (msg, depth) in enumerate(visible): - # Peek Ahead to See if Next Item Is Still a Thread Reply - next_is_reply = (idx + 1 < len(visible) and visible[idx + 1][1] > 0) + # Peek Ahead — Needed to Decide Between Mid vs End Reply Branch + next_is_reply = ( + idx + 1 < len(visible) and visible[idx + 1][1] > 0 + ) - # Orphan Thread Header - if msg is not None and "dt" not in msg: - header_line = f"\033[90m↳ thread in {ch_color}#{msg['channel']}{reset}" - if domain and msg.get("channel_id") and msg.get("thread_ts"): - link = slack_url(domain, msg["channel_id"], msg["thread_ts"]) - header_line += f" \033[90m{link}{reset}" - print(header_line) + # Horizontal Rule Before Every New Root + if depth == 0: + print(rule) + + # Ellipsis Row — Between Thread Parent and Shown Replies + if msg.get("ellipsis"): + skipped = msg.get("skipped") + if skipped: + inner = f"[… {skipped} earlier repl{'y' if skipped == 1 else 'ies'}]" + else: + inner = "[… older replies]" + print(f"{branch_mid} {dim}{inner}{reset}") + # Continuation Bar Below Ellipsis if More Replies Follow + if next_is_reply: + print(bar) continue dt_str = msg["dt"].strftime("%Y-%m-%d %H:%M:%S") - subtype_tag = f" [{msg['subtype']}]" if msg["subtype"] else "" - bar = f"{bar_str} " if depth > 0 else "" + subtype_tag = f" [{msg['subtype']}]" if msg.get("subtype") else "" - # Build Slack Link for Top-Level Messages - link_suffix = "" - if domain and depth == 0: - link = slack_url(domain, msg["channel_id"], msg["ts"]) - link_suffix = f" {ts_color}{link}{reset}" + if depth == 0: + # Root Row — Choose Bullet Based on Thread State + is_thread = msg.get("is_thread_parent", False) + bullet = bullet_thread if is_thread else bullet_top + link_suffix = "" + if domain and msg.get("channel_id") and msg.get("ts"): + link = slack_url(domain, msg["channel_id"], msg["ts"]) + link_suffix = f" {ts_color}{link}{reset}" - print( - f"{bar}{ts_color}{dt_str}{reset} " - f"{ch_color}#{msg['channel']}{reset} " - f"{user_color}{msg['user']}{reset}{subtype_tag}{link_suffix}" - ) + print( + f"{bullet} {ts_color}{dt_str}{reset} " + f"{ch_color}#{msg['channel']}{reset} " + f"{user_color}{msg['user']}{reset}{subtype_tag}{link_suffix}" + ) - # Indent Message Text (Prefix Every Line for Multi-Line Messages) - text_prefix = f"{bar_str} " if depth > 0 else " " - for line in msg["text"][:500].split("\n"): - print(f"{text_prefix}{line}") + # Orphan Subtitle (Kept Below Header for Layout Consistency) + if msg.get("is_orphan"): + print(f"{bar} {dim}(orphan — parent not cached){reset}") - # Connecting Bar Between Thread Messages - if next_is_reply: - print(bar_str) + # Body: Either the Orphan Placeholder Box or the Real Text + if msg.get("is_orphan"): + # ⚠ Renders as Double-Width in Most Terminals, So the + # top/bottom rules need to be 2 chars shorter than the + # printable width of the middle row. + box_top = f"{dim}╔{'═' * 39}╗{reset}" + box_mid = f"{dim}║{reset} \033[33m⚠ ORPHANED THREAD — PARENT UNKNOWN\033[0m {dim}║{reset}" + box_bot = f"{dim}╚{'═' * 39}╝{reset}" + print(f"{bar}") + print(f"{bar} {box_top}") + print(f"{bar} {box_mid}") + print(f"{bar} {box_bot}") + else: + # Top-Level and Resolved-Parent Bodies Both Indent 2 Spaces; + # thread parents use a gutter to signal replies follow. + body_prefix = f"{bar} " if msg.get("has_replies") else " " + wrap_text(msg["text"], body_prefix) + + # Trailing Gutter Only When Replies Follow + if next_is_reply: + print(bar) + else: + print() else: - print() + # Reply Row — Use └ (Final) or ├ (Mid) Depending on Followups + branch = branch_mid if next_is_reply else branch_end + print( + f"{branch} {ts_color}{dt_str}{reset} " + f"{user_color}{msg['user']}{reset}{subtype_tag}" + ) + # Mid Replies Keep the │ Gutter; Final Reply Indents Flat + text_prefix = f"{bar} " if next_is_reply else " " + wrap_text(msg["text"], text_prefix) + if next_is_reply: + print(bar) + else: + print() + + prev_depth = depth root_count = sum(1 for _, d in visible if d == 0) total_roots = sum(1 for _, d in display if d == 0)