Tab completion added for usernames and /commands. This was hard, there are likely bugs.

This commit is contained in:
Storm Dragon
2025-07-29 01:00:28 -04:00
parent 4e7ceee476
commit e9c0d6f63d
+386 -12
View File
@@ -494,7 +494,7 @@ EOF
# try to init Term::ReadLine if it was requested
# (shakes fist at @br3nda, it's all her fault)
%readline_completion = ();
our %readline_completion = ();
$readline = 1 if (!defined $readline); # Enable readline by default
print STDOUT "-- DEBUG: readline=$readline, silent=$silent, script=$script\n" if ($verbose);
if ($readline && !$silent && !$script) {
@@ -522,8 +522,14 @@ EOF
$readline =~ s/^"//; # for optimizer
$readline =~ s/"$//;
#$termrl->Attribs()->{'autohistory'} = undef; # not yet
(%readline_completion) = map {$_ => 1} split(/\s+/, $readline);
%original_readline = %readline_completion;
# Merge readline config completions with existing completions (don't overwrite)
my %config_completions = map {$_ => 1} split(/\s+/, $readline);
%original_readline = %config_completions;
# Add config completions to our existing hash instead of replacing it
my $before_count = scalar(keys %readline_completion);
%readline_completion = (%readline_completion, %config_completions);
my $after_count = scalar(keys %readline_completion);
print $stdout "-- merged " . scalar(keys %config_completions) . " config completions (before: $before_count, after: $after_count)\n" if ($verbose);
# readline repaint can't be tested here. we cache our
# result later.
}
@@ -633,6 +639,295 @@ print $stdout "-- termios test: $termios\n" if ($verbose);
# Term::ReadLine::Gnu is well-maintained and compatible
if ($termrl && $termrl->ReadLine eq 'Term::ReadLine::Gnu') {
print $stdout "-- using Term::ReadLine::Gnu for enhanced readline support\n" if ($verbose);
# Set up comprehensive tab completion
my $attribs = $termrl->Attribs;
# Dynamically extract all commands from the source code
my @commands = ();
{
# Read our own source file to extract commands
# Try multiple possible filenames
my $source_file = $0;
$source_file = 'ttyverse.pl' if (!-f $source_file && -f 'ttyverse.pl');
$source_file = './ttyverse.pl' if (!-f $source_file && -f './ttyverse.pl');
my $source = '';
if (-f $source_file) {
open(my $fh, '<', $source_file) or warn "Cannot read source file ($source_file): $!";
if ($fh) {
$source = do { local $/; <$fh> };
close($fh);
}
}
# If we couldn't read the source file (packaged installation), fall back to manual list
my %seen_commands = ();
if (!$source || length($source) < 1000) {
print $stdout "-- using fallback command list (source file not accessible)\n" if ($verbose);
%seen_commands = map { $_ => 1 } qw(
/help /? /quit /q /bye /end /e /exit
/refresh /r /thump /again /a
/dm /dmr /dmrefresh /dms /dmsent /dmagain
/replies /re /reply /timeline /timelines
/media /visibility /search /se
/history /h /print /p /verbose /ve
/ruler /ru /cls /clear /url /open
/short /sh /rate /ratelimit
/track /tron /troff /trends /woeids
/notrack /set /unset /add /del
/push /pop /list /lists /listfollowers
/listfriends /dump /du /eval /ev
/version /update /versioncheck /updatecheck
/thread /th /entities /ent /delete
/deletelast /rtsof /vote /whois /w /me
);
} else {
# Dynamic extraction from source code
# Pattern 1: if ($_ eq '/command' || $_ eq '/alias')
while ($source =~ /if\s*\(\s*\$_\s+eq\s+['"]([\/][a-z?]+)['"](?:\s*\|\|\s*\$_\s+eq\s+['"]([\/][a-z?]+)['"])?/gi) {
$seen_commands{$1}++ if defined $1;
$seen_commands{$2}++ if defined $2;
}
# Pattern 2: if (m#^/command# or similar regex patterns
while ($source =~ /if\s*\(\s*[^)]*m#?\^([\/][a-z?]+)/gi) {
$seen_commands{$1}++;
}
# Pattern 3: return -1 if ($_ eq '/quit' patterns
while ($source =~ /return\s+-1\s+if\s*\(\s*\$_\s+eq\s+['"]([\/][a-z?]+)['"](?:\s*\|\|\s*\$_\s+eq\s+['"]([\/][a-z?]+)['"])?/gi) {
$seen_commands{$1}++ if defined $1;
$seen_commands{$2}++ if defined $2;
}
# Some manual additions for complex patterns we might miss
$seen_commands{'/reply'}++; # Often has complex parsing
$seen_commands{'/me'}++;
$seen_commands{'/url'}++;
$seen_commands{'/open'}++;
}
@commands = sort keys %seen_commands;
print $stdout "-- extracted " . scalar(@commands) . " commands for tab completion\n" if ($verbose);
print $stdout "-- commands: " . join(" ", @commands) . "\n" if ($verbose);
}
# Custom completion function
$attribs->{attempted_completion_function} = sub {
my ($text, $line, $start, $end) = @_;
# Debug output
print STDERR "COMPLETION DEBUG: text='$text' line='$line' start=$start end=$end\n" if ($verbose);
my $hash_size = scalar(keys %readline_completion);
my @hash_keys = keys %readline_completion;
print STDERR "COMPLETION DEBUG: Hash has $hash_size entries: " . join(", ", @hash_keys) . "\n" if ($verbose);
# Command completion at start of line or after whitespace
if ($line =~ /^\s*\/\w*$/ || ($start == 0 && $text =~ /^\//) ||
($line =~ /^\s+$/ && $text =~ /^\//)) {
print STDERR "COMPLETION: Trying command completion\n" if ($verbose);
my @matches = grep { index($_, $text) == 0 } @commands;
print STDERR "COMPLETION: Found " . scalar(@matches) . " command matches: " . join(", ", @matches) . "\n" if ($verbose);
if (@matches) {
return $termrl->completion_matches($text, sub {
my ($text, $state) = @_;
return $matches[$state] // undef;
});
}
}
# @mention completion - check if line has @ and we're completing after it
if ($line =~ /\@/ && $start >= 1 && substr($line, $start-1, 1) eq '@') {
print STDERR "COMPLETION: Trying \@mention completion for '$text' (line: '$line', start: $start)\n" if ($verbose);
print STDERR "COMPLETION: Hash reference check: " . \%readline_completion . "\n" if ($verbose);
my @mentions = keys %readline_completion;
print STDERR "COMPLETION: Total readline_completion keys: " . scalar(@mentions) . "\n" if ($verbose);
print STDERR "COMPLETION: All keys: " . join(", ", @mentions) . "\n" if ($verbose);
@mentions = grep { /^@/ } @mentions; # Only @mentions
# If no @mentions in completion hash, try to extract from timeline cache
if (!@mentions && %tl) {
print STDERR "COMPLETION: No mentions in cache, extracting from timeline\n" if ($verbose);
foreach my $post_id (keys %tl) {
my $post = $tl{$post_id};
if ($post && $post->{'user'}) {
my $user = $post->{'user'};
my $username = $user->{'acct'} || $user->{'username'} || $user;
if ($username) {
my $mention = '@' . $username;
push @mentions, $mention;
# Also add to completion hash for future use
$readline_completion{$mention}++;
}
# Also check for boost attribution
if ($post->{'boost_attribution'}) {
my $booster = '@' . $post->{'boost_attribution'};
push @mentions, $booster;
$readline_completion{$booster}++;
}
}
}
# Remove duplicates
my %seen = ();
@mentions = grep { !$seen{$_}++ } @mentions;
print STDERR "COMPLETION: Extracted " . scalar(@mentions) . " mentions from timeline\n" if ($verbose);
&save_completion_cache if (@mentions > 0);
}
print STDERR "COMPLETION: Available mentions: " . join(", ", @mentions) . "\n" if ($verbose);
# Filter matches based on what user has typed (text doesn't include @)
# We need to match against the part after @ in the mention
my @matches = ();
foreach my $mention (@mentions) {
my $username = $mention;
$username =~ s/^@//; # Remove @ prefix for matching
if ($text eq '' || index(lc($username), lc($text)) == 0) {
push @matches, $username; # Return without @ since completion will add it
}
}
print STDERR "COMPLETION: Filtered matches: " . join(", ", @matches) . "\n" if ($verbose);
if (@matches) {
return $termrl->completion_matches($text, sub {
my ($text, $state) = @_;
return $matches[$state] // undef;
});
}
}
# Try @ completion when user types @ anywhere
if ($text eq '@' || ($line =~ /\@$/ && $text eq '')) {
print STDERR "COMPLETION: Trying bare \@ completion\n" if ($verbose);
my @mentions = keys %readline_completion;
@mentions = grep { /^@/ } @mentions;
print STDERR "COMPLETION: Available mentions: " . join(", ", @mentions) . "\n" if ($verbose);
if (@mentions) {
return $termrl->completion_matches('@', sub {
my ($text, $state) = @_;
return $mentions[$state] // undef;
});
}
}
# File path completion for /media command
if ($line =~ /^\/media\s+/ && $start > 7) {
print STDERR "COMPLETION: Using file completion for /media\n" if ($verbose);
# Let default filename completion handle this
return ();
}
# Username completion for commands that take usernames
if ($line =~ /^\/(?:whois|w|again|a|list|lists|reply)\s+/ && $text !~ /^[\/@]/) {
print STDERR "COMPLETION: Trying username completion\n" if ($verbose);
my @mentions = keys %readline_completion;
@mentions = grep { /^@/ } @mentions;
# Remove @ prefix for username-only completion
my @usernames = map { substr($_, 1) } @mentions;
@usernames = grep { index(lc($_), lc($text)) == 0 } @usernames if $text;
print STDERR "COMPLETION: Username matches: " . join(", ", @usernames) . "\n" if ($verbose);
if (@usernames) {
return $termrl->completion_matches($text, sub {
my ($text, $state) = @_;
return $usernames[$state] // undef;
});
}
}
print STDERR "COMPLETION: No completion available\n" if ($verbose);
# No completion
return ();
};
# Load persistent completion cache
my $completion_cache_file = "$config/completion_cache";
if (-f $completion_cache_file) {
if (open(my $cache_fh, '<', $completion_cache_file)) {
my $read_count = 0;
my $validated_count = 0;
while (my $line = <$cache_fh>) {
chomp $line;
$read_count++;
print STDERR "CACHE_LOAD: Read line $read_count: '$line'\n" if ($verbose);
next unless $line;
# Validate username format before adding
if ($line =~ /^@[a-zA-Z0-9_.-]+(?:@[a-zA-Z0-9.-]+)?$/) {
print STDERR "CACHE_LOAD: Validated and adding: '$line'\n" if ($verbose);
$readline_completion{$line}++;
$validated_count++;
} else {
print STDERR "CACHE_LOAD: Failed validation: '$line'\n" if ($verbose);
}
}
close($cache_fh);
my $loaded_count = scalar(grep { /^@/ } keys %readline_completion);
print STDERR "CACHE_LOAD: Read $read_count lines, validated $validated_count, hash has $loaded_count entries\n" if ($verbose);
my @loaded_keys = sort(grep { /^@/ } keys %readline_completion);
print STDERR "CACHE_LOAD: Loaded keys: " . join(", ", @loaded_keys) . "\n" if ($verbose);
print $stdout "-- loaded $loaded_count cached usernames for tab completion\n" if ($verbose);
}
}
# Populate completion cache from existing timeline cache
if (%tl) {
print $stdout "-- populating completion cache from existing timeline data\n" if ($verbose);
my $added_count = 0;
foreach my $post_id (keys %tl) {
my $post = $tl{$post_id};
if ($post && $post->{'user'}) {
my $user = $post->{'user'};
# Extract username from user object
my $username = $user->{'acct'} || $user->{'username'} || $user;
if ($username) {
$readline_completion{'@'.$username}++;
$added_count++;
}
# Also check for boost attribution
if ($post->{'boost_attribution'}) {
$readline_completion{'@'.$post->{'boost_attribution'}}++;
$added_count++;
}
}
}
print $stdout "-- added $added_count usernames from cached timeline to completion\n" if ($verbose);
&save_completion_cache if ($added_count > 0);
}
print $stdout "-- tab completion enabled for commands, @mentions, and file paths\n" if ($verbose);
}
# Save completion cache to disk (with rate limiting)
our $last_cache_save = 0;
sub save_completion_cache {
return unless ($termrl && $termrl->ReadLine eq 'Term::ReadLine::Gnu');
# Rate limit saves to every 30 seconds to avoid excessive disk I/O
my $now = time();
return if ($now - $last_cache_save < 30);
$last_cache_save = $now;
my $completion_cache_file = "$config/completion_cache";
# Create config directory if it doesn't exist
unless (-d $config) {
mkdir($config, 0700) or return;
}
# Get all @mentions, validate them, and save
my @mentions = grep { /^@/ } keys %readline_completion;
@mentions = grep { /^@[a-zA-Z0-9_.-]+(?:@[a-zA-Z0-9.-]+)?$/ } @mentions; # Validate format
if (open(my $cache_fh, '>', $completion_cache_file)) {
print $cache_fh "$_\n" for sort @mentions;
close($cache_fh);
print $stdout "-- saved " . scalar(@mentions) . " usernames to completion cache: " . join(", ", sort @mentions) . "\n" if ($verbose);
}
}
# try to get signal numbers for SIG* from POSIX. use internals if failed.
@@ -2947,7 +3242,10 @@ EOF
$countmaybe += 0;
$uname =~ s/^\@//;
$readline_completion{'@'.$uname}++ if ($termrl);
if ($termrl) {
$readline_completion{'@'.$uname}++;
&save_completion_cache;
}
print $stdout
"-- synchronous /again command for $uname ($countmaybe)\n"
if ($verbose);
@@ -2971,7 +3269,10 @@ EOF
if ($_ =~ m#^/w(hois|a|again)?\s+(\+\d+\s+)?\@?([^\s]+)#) {
my $uname = lc($3);
$uname =~ s/^\@//;
$readline_completion{'@'.$uname}++ if ($termrl);
if ($termrl) {
$readline_completion{'@'.$uname}++;
&save_completion_cache;
}
print $stdout "-- synchronous /whois command for $uname\n"
if ($verbose);
@@ -3751,7 +4052,10 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) {
} else {
$_ = ".$_";
}
$readline_completion{'@'.lc($target)}++ if ($termrl);
if ($termrl) {
$readline_completion{'@'.lc($target)}++;
&save_completion_cache;
}
print $stdout &wwrap("(expanded to \"$_\")");
print $stdout "\n";
goto POSTPRINT; # fugly! FUGLY!
@@ -3771,7 +4075,10 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) {
}
# in the future, add DM in_reply_to here
my $target = &descape($dm->{'last_status'}->{'account'}->{'acct'} || $dm->{'last_status'}->{'account'}->{'username'});
$readline_completion{'@'.lc($target)}++ if ($termrl);
if ($termrl) {
$readline_completion{'@'.lc($target)}++;
&save_completion_cache;
}
$_ = "/dm $target $_";
print $stdout &wwrap("(expanded to \"$_\")");
print $stdout "\n";
@@ -3822,8 +4129,10 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) {
$_ .= " $text";
# add everyone in did_mentions to readline_completion
grep { $readline_completion{'@'.$_}++ } (keys %did_mentions)
if ($termrl);
if ($termrl) {
grep { $readline_completion{'@'.$_}++ } (keys %did_mentions);
&save_completion_cache;
}
# and fall through to post
print $stdout &wwrap("(expanded to \"$_\")");
@@ -5958,6 +6267,17 @@ sub tdisplay { # used by both synchronous /again and asynchronous refreshes
print $stdout "-- DEBUG: No valid posts for ID calculation, using last_id='$last_id'\n" if ($verbose);
}
print $stdout "-- DEBUG: tdisplay returning max_id='$new_max_id' (from " . scalar(@{ $my_json_ref }) . " posts)\n" if ($verbose);
# Save completion cache after timeline processing
if ($termrl && $termrl->ReadLine eq 'Term::ReadLine::Gnu') {
my $cache_count = scalar(grep { /^@/ } keys %readline_completion);
if ($cache_count > 0) {
$last_cache_save = 0; # Force save
&save_completion_cache;
print $stdout "-- saved completion cache after timeline processing ($cache_count entries)\n" if ($verbose);
}
}
return ($new_max_id, $j);
}
@@ -6171,6 +6491,14 @@ sub dmrefresh {
}
$dm_first_time = 0 if ($last_dm || !scalar(@{ $my_json_ref }));
print $stdout "-- dm bookmark is $last_dm.\n" if ($verbose);
# Save completion cache after processing DMs
my $dm_users_added = scalar(grep { /^@/ } keys %readline_completion) - 10; # original timeline users
if ($dm_users_added > 0) {
print $stdout "-- saving completion cache after DM processing ($dm_users_added DM users added)\n" if ($verbose);
&save_completion_cache;
}
&$dmconclude;
&send_repaint if ($termrl);
}
@@ -6547,9 +6875,28 @@ sub standardpost {
my $sn = &descape($ref->{'user'}->{'acct'} || $ref->{'user'}->{'username'});
my $post = &descape($ref->{'text'});
# Add usernames to completion cache
if ($termrl && $sn) {
$readline_completion{'@'.$sn}++;
my $total_keys = scalar(keys %readline_completion);
print STDERR "COMPLETION: Added \@$sn to completion cache (total: $total_keys)\n" if ($verbose);
# Don't save on every add - let the final save handle it
}
# Debug boost display
if (exists($ref->{'boost_attribution'}) && $ref->{'boost_attribution'}) {
print $stdout "-- DEBUG: standardpost - user: '$sn', text: '$post', boost_attribution: '" . $ref->{'boost_attribution'} . "'\n" if ($verbose);
# Also add boost attribution to completion cache
if ($termrl) {
my $booster = &descape($ref->{'boost_attribution'});
if ($booster) {
$readline_completion{'@'.$booster}++;
my $total_keys = scalar(keys %readline_completion);
print STDERR "COMPLETION: Added \@$booster (booster) to completion cache (total: $total_keys)\n" if ($verbose);
# Don't save on every add - let the final save handle it
}
}
}
my $colour;
@@ -7330,7 +7677,22 @@ sub defaultconclude {
sub defaultdmhandle {
(&flag_default_call, return) if ($multi_module_context);
my $dm_ref = shift;
my $sns = &descape($dm_ref->{'last_status'}->{'account'}->{'username'} || $dm_ref->{'last_status'}->{'account'}->{'acct'});
my $sns = &descape($dm_ref->{'last_status'}->{'account'}->{'acct'} || $dm_ref->{'last_status'}->{'account'}->{'username'});
# Add DM username to completion cache
print STDERR "DM_DEBUG: sns='$sns', whoami='$whoami'\n" if ($verbose && $sns);
if ($sns && $sns ne $whoami) {
my $username = $sns;
$username = '@' . $username unless $username =~ /^@/;
print STDERR "DM_DEBUG: Processing username '$username' for completion\n" if ($verbose);
if (!exists $readline_completion{$username}) {
$readline_completion{$username}++;
my $total = scalar(grep { /^@/ } keys %readline_completion);
print STDERR "COMPLETION: Added $username (DM user) to completion cache (total: $total)\n" if ($verbose);
} else {
print STDERR "DM_DEBUG: Username '$username' already in completion cache\n" if ($verbose);
}
}
my $dm_content = &standarddm($dm_ref);
print $streamout $dm_content;
@@ -7502,10 +7864,16 @@ sub defaultpostpost {
# populate %readline_completion if readline is on
while($line =~ s/^\@(\w+)\s+//) {
$readline_completion{'@'.lc($1)}++;
if ($termrl) {
$readline_completion{'@'.lc($1)}++;
&save_completion_cache;
}
}
if ($line =~ /^[dD]\s+(\w+)\s+/) {
$readline_completion{'@'.lc($1)}++;
if ($termrl) {
$readline_completion{'@'.lc($1)}++;
&save_completion_cache;
}
}
}
@@ -7823,6 +8191,12 @@ sub dmthump { print C "dmthump------------\n"; &sync_semaphore; }
sub dmthump_no_skip { print C "dmthump_no_skip----\n"; &sync_semaphore; }
sub sync_n_quit {
# Save completion cache before exiting
if ($termrl && $termrl->ReadLine eq 'Term::ReadLine::Gnu') {
$last_cache_save = 0; # Force save on exit
&save_completion_cache;
}
if ($child) {
print $stdout "waiting for child ...\n" unless ($silent);
print C "sync---------------\n";