Learn cvar and command names from the server for autocomplete

Send cvarlist and cmdlist on connect and merge the answer into the
autocomplete candidates. Number the server menu from 0, keep suggesting
sibling commands while the command name is typed, mask the password in
the startup line, ignore venv/, fix README claims.
This commit is contained in:
pfl
2026-09-16 18:25:48 +02:00
parent db123993c0
commit 3d1dc19d9b
6 changed files with 227 additions and 60 deletions
+35 -4
View File
@@ -40,6 +40,14 @@ GAME_CVARS = [
'g_quadHog',
'g_training',
'g_instagib',
'g_password',
'g_needpass',
'g_overtime',
'g_startingHealth',
'g_startingArmor',
'g_infiniteAmmo',
'g_loadout',
'sv_privatePassword',
]
# Map and rotation
@@ -92,6 +100,8 @@ QLX_CVARS = [
'qlx_serverBrandName',
'qlx_owner',
'qlx_redditAuth',
'qlx_plugins',
'qlx_pluginsPath',
]
# Bot cvars
@@ -151,9 +161,21 @@ COMMANDS = [
'scores',
# Info commands
'serverinfo',
'systeminfo',
'players',
'maplist',
'configstrings',
'cvarlist',
'cmdlist',
'dumpuser',
# Console
'exec',
'set',
'seta',
'sets',
'echo',
'clientkick',
'cp',
]
# Bot names for addbot command (complete list)
@@ -271,6 +293,9 @@ COMMAND_SIGNATURES = {
'sv_maxclients': '<1-64>',
'sv_hostname': '<name>',
'sv_fps': '<20|30|40|60|125>',
'g_password': '<password>',
'g_needpass': '<0|1>',
'sv_privatePassword': '<password>',
# Network
'net_port': '<port number>',
@@ -527,12 +552,18 @@ def fuzzy_match(query, candidates, max_results=5):
return [match for match, score in matches[:max_results]]
def autocomplete(partial, max_results=5):
def autocomplete(partial, learned_names=(), max_results=5):
"""
Autocomplete a partial cvar/command
Returns list of suggestions
Autocomplete a partial cvar/command name.
Candidates are the built-in lists plus the names learned from the server
(see lib/namelist.py); a learned name that is already built in is skipped.
"""
return fuzzy_match(partial, ALL_CVARS, max_results)
candidates = list(ALL_CVARS)
builtin_lower = {name.lower() for name in ALL_CVARS}
for name in sorted(learned_names):
if name.lower() not in builtin_lower:
candidates.append(name)
return fuzzy_match(partial, candidates, max_results)
def parse_signature(signature):
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
Cvar and command names learned from the server, for autocomplete.
After connecting, main.py sends `cvarlist` and `cmdlist`. The server answers
with one line per name and a count line at the end:
cvarlist: S A sv_hostname "My Server" ... 1234 total cvars
cmdlist: addbot ... 321 commands
NameCapture collects the names from those lines and hides the listing from
the output window while it is running.
"""
import logging
import re
import time
logger = logging.getLogger('namelist')
# Capture stops when no listing output arrived for this many seconds.
# Guards against the count line looking different than expected, which would
# otherwise hide all later output forever. Measured from the last message, not
# from the request: a large cvarlist (5000+ messages) takes seconds to process.
CAPTURE_TIMEOUT = 5.0
# Cvar line: the name is the word directly before the quoted value.
# Example: 'S A sv_hostname "My Server"' -> sv_hostname
CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
# Command line: one bare word.
# Example: 'addbot' -> addbot
CMDLIST_LINE = re.compile(r'^\s*([A-Za-z_]\w*)\s*$')
# Count line that ends a listing.
# Examples: '1234 total cvars', '321 commands'
END_LINE = re.compile(r'^\s*\d+\s+(total cvars|commands)\s*$')
def unwrap(message):
"""
Turn one RCON message into plain text with real newlines.
Example: 'print "addbot\\n"' -> 'addbot\n'
"""
text = message
if text.startswith('print "'):
text = text[7:]
if text.endswith('"'):
text = text[:-1]
return text.replace('\\n', '\n')
class NameCapture:
"""Collects cvar and command names from the server's cvarlist and cmdlist output"""
def __init__(self):
self.names = set()
self.listings_pending = 0 # count lines still expected; 0 = not capturing
self.deadline = 0
self.partial_line = ''
def request(self, rcon):
"""Send cvarlist and cmdlist; their output is captured until both count lines arrived"""
self.listings_pending = 2
self.deadline = time.time() + CAPTURE_TIMEOUT
self.partial_line = ''
rcon.send_command(b'cvarlist')
rcon.send_command(b'cmdlist')
logger.info('Requested cvarlist and cmdlist for autocomplete')
def handle(self, message):
"""
Feed one RCON message.
Returns True when the message belongs to a running listing (hide it from the output).
"""
if self.listings_pending == 0:
return False
if time.time() > self.deadline:
logger.warning(f'No listing output for {CAPTURE_TIMEOUT:.0f}s, '
f'stopping capture with {len(self.names)} names')
self.listings_pending = 0
return False
self.deadline = time.time() + CAPTURE_TIMEOUT
# A line can arrive in several messages (the server sends each flag column of
# a cvarlist row as its own 1-byte message), so keep the unfinished tail
text = self.partial_line + unwrap(message)
lines = text.split('\n')
self.partial_line = lines.pop()
for line in lines:
self._learn_line(line)
# The last count line may come without a trailing newline
if END_LINE.match(self.partial_line):
self._learn_line(self.partial_line)
self.partial_line = ''
return True
def _learn_line(self, line):
if END_LINE.match(line):
self.listings_pending -= 1
if self.listings_pending == 0:
logger.info(f'Learned {len(self.names)} cvar and command names from the server')
return
match = CVARLIST_LINE.search(line) or CMDLIST_LINE.match(line)
if match:
self.names.add(match.group(1))
+28 -21
View File
@@ -167,21 +167,20 @@ def suggestion_list_hint(suggestions, arg_type=None):
return f'{shown}{more}'
def compute_autocomplete(words, ends_with_space, player_names_provider):
def compute_autocomplete(words, ends_with_space, player_names_provider, learned_names_provider=None):
"""
Work out what to suggest for the current input.
player_names_provider / learned_names_provider: callables returning the
current player names and the cvar/command names learned from the server.
Returns (suggestions, original_word, hint_parts):
- suggestions: values cycled with Tab
- original_word: the word Tab replaces ('' means Tab appends a new word)
- hint_parts: [(text, attributes)] shown below the input line
"""
first_word = words[0].lower()
typing_command_name = len(words) == 1 and not ends_with_space
if first_word in COMMAND_ARGUMENTS:
if len(words) == 1 and not ends_with_space:
# Just the command: show its signature with the first argument highlighted
return [], '', signature_hint(first_word, 0)
if first_word in COMMAND_ARGUMENTS and not typing_command_name:
if ends_with_space:
arg_position = len(words) - 1
current_value = ''
@@ -199,26 +198,34 @@ def compute_autocomplete(words, ends_with_space, player_names_provider):
# No value suggestions (free text, no players known): show the signature
return [], '', signature_hint(first_word, arg_position)
if COMMAND_SIGNATURES.get(first_word):
if COMMAND_SIGNATURES.get(first_word) and not typing_command_name:
# Command with a signature but no argument definitions
return [], '', signature_hint(first_word, 0)
# Not a recognized command: suggest commands matching the current word
# Still typing the command name, or an unknown command: suggest names matching
# the current word. A complete name like `map` stays here on purpose because it
# can be the prefix of others (map_restart, maplist); its signature is shown too.
current_word = words[-1]
if len(current_word) < 2:
return [], '', []
suggestions = autocomplete(current_word, max_results=5)
if not suggestions:
return [], current_word, []
return suggestions, current_word, [(' '.join(suggestions), curses.A_DIM)]
learned_names = learned_names_provider() if learned_names_provider else ()
suggestions = autocomplete(current_word, learned_names, max_results=5)
hint_parts = []
if suggestions:
hint_parts.append((' '.join(suggestions), curses.A_DIM))
if typing_command_name and COMMAND_SIGNATURES.get(first_word):
hint_parts.append((' ', 0))
hint_parts.extend(signature_hint(first_word, 0))
return suggestions, current_word, hint_parts
class InputLine:
"""The command input line: text, cursor, history and autocomplete state"""
def __init__(self, max_history, player_names_provider=None):
def __init__(self, max_history, player_names_provider=None, learned_names_provider=None):
self.max_history = max_history
self.player_names_provider = player_names_provider
self.learned_names_provider = learned_names_provider
self.text = ''
self.cursor = 0
self.history = []
@@ -310,7 +317,7 @@ class InputLine:
self._clear_suggestions()
return
self.suggestions, self.original_word, self.hint_parts = compute_autocomplete(
words, self.text.endswith(' '), self.player_names_provider)
words, self.text.endswith(' '), self.player_names_provider, self.learned_names_provider)
self.suggestion_index = -1
def _history_previous(self):
@@ -384,7 +391,7 @@ def select_server(screen, servers):
"""
Full-screen menu listing the configured servers.
servers: dict name -> host. Returns the chosen name, or None when the user quits.
Keys: Up/Down or j/k move, Enter connects, 1-9 connect directly, q or Esc quit.
Keys: Up/Down or j/k move, Enter connects, 0-9 connect directly, q or Esc quit.
"""
names = list(servers)
selected = 0
@@ -405,8 +412,8 @@ def select_server(screen, servers):
selected = (selected + 1) % len(names)
elif key in (curses.KEY_ENTER, 10, 13):
return names[selected]
elif ord('1') <= key <= ord('9') and key - ord('1') < len(names):
return names[key - ord('1')]
elif ord('0') <= key <= ord('9') and key - ord('0') < len(names):
return names[key - ord('0')]
elif key in (ord('q'), 27): # 27 = Escape
return None
# Any other key (including KEY_RESIZE) just redraws
@@ -416,8 +423,7 @@ def _draw_server_menu(screen, servers, names, selected):
screen.erase()
safe_addstr(screen, 0, 2, 'Quake Live PyCon - select a server', curses.A_BOLD)
for index, name in enumerate(names):
number = index + 1
line = f'{number:>2}. {name:<12} {servers[name]}'
line = f'{index:>2}. {name:<12} {servers[name]}'
attributes = curses.A_REVERSE if index == selected else 0
safe_addstr(screen, 2 + index, 2, line, attributes)
safe_addstr(screen, 3 + len(names), 2,
@@ -428,7 +434,8 @@ def _draw_server_menu(screen, servers, names, selected):
class UIManager:
"""Manages curses windows and display"""
def __init__(self, screen, title, max_history=MAX_COMMAND_HISTORY, player_names_provider=None):
def __init__(self, screen, title, max_history=MAX_COMMAND_HISTORY,
player_names_provider=None, learned_names_provider=None):
self.screen = screen
self.title = title
self.info_window = None
@@ -445,7 +452,7 @@ class UIManager:
# Visual rows scrolled up from the newest output; 0 = follow new output
self.scroll_offset = 0
self.input_line = InputLine(max_history, player_names_provider)
self.input_line = InputLine(max_history, player_names_provider, learned_names_provider)
self.game_state = None # last state shown in the info window
self._init_curses()