From 9e834cf3cd9201c02e0b0f43f1d5ec88e0b47b80 Mon Sep 17 00:00:00 2001
From: vjt-claude <vjt@openssl.it>
Date: Tue, 11 Aug 2026 22:30:00 +0000
Subject: [PATCH] Fix Tab cycling, case handling and completion suffix

Six defects, all found by reading the source; none of this was run inside
weechat, so verify the behaviour before trusting it.

1. Tab cycling was dead. With more than one match the callback always
   returned WEECHAT_RC_OK_EAT, so weechat's own completer never ran and a
   second Tab could not cycle through the candidates: it only reprinted the
   same list. The event is now eaten only when the input was actually
   extended.

2. The common prefix was computed case-sensitively over matches selected
   case-insensitively. os.path.commonprefix(["Sonic", "sonata"]) is "", so a
   single differently-cased nick in the match set silently disabled the
   extension - the normal situation on a real channel. The prefix is now
   computed on the lowercased nicks and the casing is sliced back from the
   first match.

3. The single-match branch inserted a bare nick, ignoring
   weechat.completion.nick_completer and weechat.completion.nick_add_space,
   so completion did not match what the user configured.

4. Only /input complete_next was hooked, so Shift-Tab behaved differently
   and never listed ambiguities. complete_previous is hooked too.

5. Nick comparison used str.lower(), which is not the IRC casemapping:
   under rfc1459 []\~ and {}|^ are the same letters. The authoritative value
   is the CASEMAPPING token in ISUPPORT; rfc1459 is assumed here.

6. The "Sort and list ambiguities" comment promised a sort that did not
   exist. The list is now sorted.

The word boundary is still plain space only (so "ciao,strk" is one word);
that one needs a decision about which separators count, so it is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---
 nick_ambiguity_lister.py | 60 ++++++++++++++++++++++++++++++----------
 1 file changed, 45 insertions(+), 15 deletions(-)

diff --git a/nick_ambiguity_lister.py b/nick_ambiguity_lister.py
index f952bd7..015800f 100644
--- a/nick_ambiguity_lister.py
+++ b/nick_ambiguity_lister.py
@@ -4,11 +4,29 @@ import os.path
 # Script registration
 weechat.register("ambiguity_lister", "MuaddibLLM", "1.1", "MIT", "List nicks on ambiguity and complete to common prefix", "", "")
 
+# RFC1459 casemapping: uppercase []\~ are the same letters as lowercase {}|^.
+# The authoritative value comes from the CASEMAPPING token in ISUPPORT (005);
+# rfc1459 is the common default. "ascii" servers want plain str.lower().
+_RFC1459_LOWER = str.maketrans(r"[]\~", r"{}|^")
+
+def irc_lower(s):
+    return s.lower().translate(_RFC1459_LOWER)
+
+def nick_suffix(at_line_start):
+    # Mirror what weechat's own nick completion appends, instead of inserting
+    # a bare nick: nick_completer (default ": ") at the start of the input,
+    # nick_add_space elsewhere.
+    if at_line_start:
+        return weechat.config_string(weechat.config_get("weechat.completion.nick_completer"))
+    if weechat.config_boolean(weechat.config_get("weechat.completion.nick_add_space")):
+        return " "
+    return ""
+
 def complete_cb(data, buffer, command):
     # Only intercept /input complete_next (usually Tab)
     line = weechat.buffer_get_string(buffer, "input")
     pos = weechat.buffer_get_integer(buffer, "input_pos")
-    
+
     if not line:
         return weechat.WEECHAT_RC_OK
 
@@ -16,7 +34,7 @@ def complete_cb(data, buffer, command):
     start = pos
     while start > 0 and line[start-1] != ' ':
         start -= 1
-    
+
     word = line[start:pos]
 
     # Skip if empty or looks like command
@@ -27,33 +45,43 @@ def complete_cb(data, buffer, command):
     infolist = weechat.infolist_get("nicklist", buffer, "")
     if not infolist:
         return weechat.WEECHAT_RC_OK
-    
+
     matches = []
     try:
         while weechat.infolist_next(infolist):
             if weechat.infolist_string(infolist, "type") != "nick": continue
             name = weechat.infolist_string(infolist, "name")
-            if name.lower().startswith(word.lower()):
+            if irc_lower(name).startswith(irc_lower(word)):
                 matches.append(name)
     finally:
         weechat.infolist_free(infolist)
-    
+
     if len(matches) > 1:
-        # Calculate common prefix (case-sensitive)
-        common = os.path.commonprefix(matches)
+        # Common prefix computed case-INSENSITIVELY, because that is how the
+        # matches were selected: os.path.commonprefix() on the raw nicks
+        # collapses to "" as soon as two matches differ in case (Sonic/sonata),
+        # which is the normal situation on a real channel. Slice the casing
+        # back from the first match so what we insert stays a real nick prefix.
+        common_len = len(os.path.commonprefix([irc_lower(m) for m in matches]))
+        common = matches[0][:common_len]
 
-        # If we found a longer common prefix, update input
+        # Sort and list ambiguities (the old comment claimed a sort that was
+        # never there; the nicklist order is arrival order, not useful).
+        match_list = ", ".join(sorted(matches, key=irc_lower))
+        weechat.prnt(buffer, f"{weechat.prefix('network')}Ambiguous: {match_list}")
+
+        # If we found a longer common prefix, update input and eat the event.
         # Note: weechat.buffer_set requires value as string even for integers
         if len(common) > len(word):
             new_line = line[:start] + common + line[pos:]
             weechat.buffer_set(buffer, "input", new_line)
             weechat.buffer_set(buffer, "input_pos", str(start + len(common)))
+            return weechat.WEECHAT_RC_OK_EAT
 
-        # Sort and list ambiguities
-        match_list = ", ".join(matches)
-        weechat.prnt(buffer, f"{weechat.prefix('network')}Ambiguous: {match_list}")
-
-        return weechat.WEECHAT_RC_OK_EAT
+        # Nothing left to extend: do NOT eat the event. Eating it here killed
+        # weechat's own Tab cycling through the candidates, so a second Tab
+        # only reprinted this same list forever.
+        return weechat.WEECHAT_RC_OK
 
     if len(matches) == 1 and len(matches[0]) > len(word):
         # Single match that extends the current word: complete to it.
@@ -61,7 +89,7 @@ def complete_cb(data, buffer, command):
         # weechat's own completion does not fire here (e.g. after a
         # previous Tab press already ate the event at the common prefix),
         # so the input would not be extended to the full nickname.
-        name = matches[0]
+        name = matches[0] + nick_suffix(start == 0)
         new_line = line[:start] + name + line[pos:]
         weechat.buffer_set(buffer, "input", new_line)
         weechat.buffer_set(buffer, "input_pos", str(start + len(name)))
@@ -69,5 +97,7 @@ def complete_cb(data, buffer, command):
 
     return weechat.WEECHAT_RC_OK
 
-# Hook the completion command
+# Hook the completion commands. complete_previous (Shift-Tab) went straight to
+# weechat's completer before, so the two directions behaved differently.
 weechat.hook_command_run("/input complete_next", "complete_cb", "")
+weechat.hook_command_run("/input complete_previous", "complete_cb", "")
-- 
2.47.3

