diff --git a/ttyverse.pl b/ttyverse.pl index 0159395..9544330 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -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";