rtfm-filemanager 8.2.6 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/bin/rtfm +169 -42
  4. metadata +3 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ba9bb8fbd17a51b1016eef29ceb5718dba8f6819bb24c6aee7b78aa7d8e7b83
4
- data.tar.gz: 3ce4361180ee23a2e446d1fb0d26f4d7d144517cef841bee9444e871f922c4e8
3
+ metadata.gz: f884960bd43345ca3368b1213be174efecf43943d20be674d722b70b848a7edf
4
+ data.tar.gz: 5c3b0d4c294ee89015df17b5b5adc4ba78b7080fee34fa5825d1a7a88f92531c
5
5
  SHA512:
6
- metadata.gz: 9303820f8f84adfb87b589925d16fb9b02102fd1c7df9a4a845b04b5854558e0910231b3d286488aa0d905252845794a5030ac7c72ff25b5ce3637af418db68f
7
- data.tar.gz: 82dbd79b2fc59c33001f3436a53bd9ad0f3ddcfafc112e8bc618788e47e6f88f2b40646d73189cedc3f7f0535302577b63cd959a4ed60731f8df5ab79dcf30e9
6
+ metadata.gz: ef00e7d08a4b579bb5a989cdbac6c30d02a77462401faeddaf30de42a464ccbbb547dcca7f369d050f0e64b83a8be8e4930eb38500d548bd010f4b77815f72c3
7
+ data.tar.gz: c063661025b9887def4c49f146042a78cc6dd3cf0dbc7fd5b42b929f3219fc64e5e79412bb2a6afa9cc2cfaa3188cf7f2f5380102573ecd4cd5ef0f07bd56544
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to RTFM will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.5.0] - 2026-05-19
9
+
10
+ ### Added
11
+ - **Crash log** - Unhandled exceptions now append to `~/.rtfm/crash.log` with timestamp, version, Ruby/platform info, cwd, exception class/message, and full backtrace. The `at_exit` handler skips `SystemExit`/`Interrupt` so normal quits stay silent. Makes bug reports actionable instead of guesswork from terminal scrollback
12
+ - **Persistent per-directory cursor** - The in-memory `@directory` hash that tracks selected index per directory is now saved to `~/.rtfm/conf` on quit (capped at 200 entries, prunes deleted dirs). Re-enter a directory in a later session and the cursor lands where you left it
13
+ - **`--fresh` CLI flag** - Launch with `rtfm --fresh` to ignore the saved cursor map for one session (useful when the saved positions are stale or you want a clean slate)
14
+ - **File-path argument** - `rtfm /etc/hosts` now cd's into `/etc` and places the cursor on `hosts`. Previously only directory arguments worked
15
+ - **Claude (Anthropic) support for AI features** - Set `@aimodel = "claude-sonnet-4-6"` (or any `claude-*` model) in `~/.rtfm/conf` and both `I` (file description) and `Ctrl-a` (chat) route to the Anthropic API via curl. OpenAI continues to work for non-`claude-*` model names. Same `@ai` config field holds either key
16
+ - **Non-blocking `:` commands** - Non-interactive shell commands now run in a background thread. Launching GUI apps (`xdg-open`, `firefox`, `evince`, etc.) no longer freezes RTFM until they exit. Output drops into the right pane when the command completes. The bottom pane shows running/done status
17
+
18
+ ### Fixed
19
+ - **Bootsnap optional** - Missing `bootsnap` gem no longer crashes RTFM on startup. It was always intended as a startup-speed optimization; now `require 'bootsnap/setup'` is wrapped in a `LoadError` rescue and RTFM continues without it
20
+
8
21
  ## [8.2.6] - 2026-04-21
9
22
 
10
23
  ### Added
data/bin/rtfm CHANGED
@@ -18,21 +18,46 @@
18
18
  # get a great understanding of the code itself by simply sending
19
19
  # or pasting this whole file into you favorite AI for coding with
20
20
  # a prompt like this: "Help me understand every part of this code".
21
- @version = '8.2.6' # Add `hyper` to the default @interactive TUI-app list
21
+ @version = '8.5.0' # Crash log, persistent per-dir cursor, Claude AI, non-blocking `:` commands
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
25
25
 
26
26
  at_exit do
27
+ # Log unhandled exceptions to ~/.rtfm/crash.log so users can file bug reports
28
+ # without losing the backtrace to terminal scrollback.
29
+ if $! && !$!.is_a?(SystemExit) && !$!.is_a?(Interrupt)
30
+ begin
31
+ log = File.join(Dir.home, '.rtfm', 'crash.log')
32
+ require 'fileutils'
33
+ FileUtils.mkdir_p(File.dirname(log))
34
+ File.open(log, 'a') do |f|
35
+ f.puts '=' * 60
36
+ f.puts "RTFM v#{@version} crash at #{Time.now}"
37
+ f.puts "Ruby #{RUBY_VERSION} on #{RUBY_PLATFORM}"
38
+ f.puts "PWD: #{Dir.pwd rescue 'unknown'}"
39
+ f.puts "#{$!.class}: #{$!.message}"
40
+ f.puts($!.backtrace.join("\n")) if $!.backtrace
41
+ end
42
+ rescue StandardError
43
+ # never crash inside the crash handler
44
+ end
45
+ end
27
46
  system("stty #{ORIG_STTY} < /dev/tty") rescue nil
28
47
  end
29
48
 
30
49
  # BOOTSNAP {{{1
50
+ # Optional startup speedup. If the gem isn't installed, RTFM just starts
51
+ # a bit slower instead of refusing to run.
31
52
  cache_dir = ENV.fetch('BOOTSNAP_CACHE_DIR', File.join(Dir.home, '.rtfm', 'bootsnap-cache'))
32
53
  ENV['BOOTSNAP_CACHE_DIR'] = cache_dir
33
54
  require 'fileutils'
34
55
  FileUtils.mkdir_p(cache_dir)
35
- require 'bootsnap/setup' # Speed up subsequent requires
56
+ begin
57
+ require 'bootsnap/setup'
58
+ rescue LoadError
59
+ # bootsnap not installed — proceed without compile cache
60
+ end
36
61
 
37
62
  # ENCODING {{{1
38
63
  # encoding: utf-8
@@ -616,6 +641,13 @@ $stdin.set_encoding(Encoding::UTF_8)
616
641
  @file_op_complete = false # Flag when operation finishes
617
642
  @file_op_result = nil # Result message when operation finishes
618
643
 
644
+ ## Async shell command (`:` mode, non-interactive branch)
645
+ @shell_cmd_thread = nil # Current background shell-command thread
646
+ @shell_cmd_running = false # True while a background `:` cmd is running
647
+ @shell_cmd_label = nil # Command string for status display
648
+ @shell_cmd_complete = false # Set by worker when output is ready
649
+ @shell_cmd_output = nil # Captured stdout+stderr
650
+
619
651
  ## Recently accessed files/directories
620
652
  @recent_files = [] # Last 50 accessed files
621
653
  @recent_dirs = [] # Last 20 accessed directories
@@ -860,11 +892,33 @@ if (pick_arg = ARGV.find { |a| a.start_with?('--pick=') })
860
892
  ARGV.delete(pick_arg)
861
893
  end
862
894
 
895
+ # --fresh: ignore any saved per-directory cursor positions for this session.
896
+ # @directory is still tracked in-memory, but startup restoration is skipped.
897
+ @fresh = false
898
+ if ARGV.delete('--fresh')
899
+ @fresh = true
900
+ @directory = {}
901
+ end
902
+
863
903
  # Handle start dir {{{2
864
- Dir.chdir(ARGV.shift) if ARGV[0] && File.directory?(ARGV[0])
904
+ # Accept either a directory or a file path. With a file path, cd into its
905
+ # parent and queue the basename for cursor restoration after load_dir runs.
906
+ @startup_select = nil
907
+ if (arg = ARGV[0]) && !arg.start_with?('-')
908
+ if File.directory?(arg)
909
+ Dir.chdir(ARGV.shift)
910
+ elsif File.exist?(arg)
911
+ @startup_select = File.basename(arg)
912
+ Dir.chdir(File.dirname(File.expand_path(arg)))
913
+ ARGV.shift
914
+ end
915
+ end
865
916
 
866
917
  # Initialize first tab {{{2
867
918
  create_tab(Dir.pwd, "Main")
919
+ # Seed the first tab's per-tab directory_memory with the persisted @directory
920
+ # so switching tabs and back doesn't lose the saved cursor map.
921
+ @tabs[0][:directory_memory] = @directory.dup if @tabs[0]
868
922
 
869
923
  # OPENAI SETUP {{{1
870
924
  def chat_history # {{{2
@@ -880,6 +934,33 @@ def openai_client # {{{2
880
934
  @openai_client ||= OpenAI::Client.new(access_token: @ai)
881
935
  end
882
936
 
937
+ # AI dispatch: routes to Anthropic when @aimodel starts with 'claude-',
938
+ # otherwise OpenAI. Returns the assistant message text, or nil on failure.
939
+ # Anthropic uses curl (no extra gem dependency); OpenAI uses ruby-openai.
940
+ def ai_request(messages, max_tokens = 600) # {{{2
941
+ if @aimodel.to_s.start_with?('claude-')
942
+ require 'json'
943
+ # Anthropic expects "system" as a top-level field, not a message role.
944
+ sys = messages.find { |m| (m[:role] || m['role']).to_s == 'system' }
945
+ msgs = messages.reject { |m| (m[:role] || m['role']).to_s == 'system' }
946
+ body = { model: @aimodel, max_tokens: max_tokens, messages: msgs }
947
+ body[:system] = sys[:content] || sys['content'] if sys
948
+ out = `curl -sS -X POST https://api.anthropic.com/v1/messages \
949
+ -H 'content-type: application/json' \
950
+ -H 'anthropic-version: 2023-06-01' \
951
+ -H #{Shellwords.escape("x-api-key: #{@ai}")} \
952
+ -d #{Shellwords.escape(body.to_json)} 2>/dev/null`
953
+ return nil if out.empty?
954
+ json = JSON.parse(out) rescue nil
955
+ json && json.dig('content', 0, 'text')
956
+ else
957
+ resp = openai_client.chat(
958
+ parameters: { model: @aimodel, messages: messages, max_tokens: max_tokens }
959
+ ) rescue nil
960
+ resp&.dig('choices', 0, 'message', 'content')
961
+ end
962
+ end
963
+
883
964
  # SET UP VIEWER SYSTEM {{{1
884
965
  # rubocop:disable Style/StringLiterals
885
966
  preview_specs = {
@@ -4804,16 +4885,19 @@ end
4804
4885
 
4805
4886
  def openai_description # {{{3
4806
4887
  clear_image
4807
- begin
4808
- require 'ruby/openai'
4809
- rescue LoadError
4810
- @pB.say('To enable AI-descriptions: `gem install ruby-openai` and set @ai in ~/.rtfm/conf')
4811
- return
4812
- end
4813
4888
  unless @ai && !@ai.empty?
4814
- @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-secret-openai-key'")
4889
+ @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-api-key'")
4815
4890
  return
4816
4891
  end
4892
+ # ruby-openai is only needed for OpenAI models; Anthropic uses curl.
4893
+ unless @aimodel.to_s.start_with?('claude-')
4894
+ begin
4895
+ require 'ruby/openai'
4896
+ rescue LoadError
4897
+ @pB.say('For OpenAI models: `gem install ruby-openai`. For Claude, set @aimodel to a claude-* model.')
4898
+ return
4899
+ end
4900
+ end
4817
4901
  # Context
4818
4902
  path = File.join(Dir.pwd, @selected.to_s)
4819
4903
  is_dir = File.directory?(path)
@@ -4827,27 +4911,26 @@ def openai_description # {{{3
4827
4911
  parts << "Git-Aware Diff Explanation: summarize the most recent `git diff` touching #{path}, explaining what changed." if Dir.exist?(File.join(Dir.pwd, '.git'))
4828
4912
  parts << "Existing preview text: #{preview}" unless preview.empty?
4829
4913
  prompt = parts.join(' ')
4830
- # Send to OpenAI
4831
- client = OpenAI::Client.new(access_token: @ai)
4832
4914
  @pR.say('Thinking...'.fg(244))
4833
- response = client.chat(
4834
- parameters: {
4835
- model: @aimodel,
4836
- messages: [{ role: 'user', content: prompt }],
4837
- max_tokens: 600
4838
- }
4839
- ) rescue nil
4840
- answer = response&.dig('choices', 0, 'message', 'content') ||
4841
- '⚠️ Error or empty response from OpenAI.'
4915
+ answer = ai_request([{ role: 'user', content: prompt }], 600) ||
4916
+ '⚠️ Error or empty response from AI provider.'
4842
4917
  @pR.say(answer.fg(230))
4843
4918
  end
4844
4919
 
4845
4920
  def chat_mode # {{{3
4846
4921
  clear_image
4847
- unless defined?(OpenAI) && @ai && !@ai.empty?
4848
- @pB.say("To make OpenAI work in RTFM, run `gem install ruby-openai` and add to ~/.rtfm/conf:\n @ai = 'your-secret-openai-key'")
4922
+ unless @ai && !@ai.empty?
4923
+ @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-api-key'")
4849
4924
  return
4850
4925
  end
4926
+ unless @aimodel.to_s.start_with?('claude-')
4927
+ begin
4928
+ require 'ruby/openai'
4929
+ rescue LoadError
4930
+ @pB.say('For OpenAI models: `gem install ruby-openai`. For Claude, set @aimodel to a claude-* model.')
4931
+ return
4932
+ end
4933
+ end
4851
4934
 
4852
4935
  @pB.clear; @pB.update = true
4853
4936
  question = @pAI.ask('Chat> ', '').strip
@@ -4855,15 +4938,7 @@ def chat_mode # {{{3
4855
4938
 
4856
4939
  chat_history << { role: 'user', content: question }
4857
4940
  @pR.say('Thinking...'.fg(230))
4858
- reply = openai_client.chat(
4859
- parameters: {
4860
- model: @aimodel,
4861
- messages: chat_history,
4862
- max_tokens: 400
4863
- }
4864
- ) rescue nil
4865
- answer = reply&.dig('choices', 0, 'message', 'content') ||
4866
- '⚠️ API error or empty response'
4941
+ answer = ai_request(chat_history, 400) || '⚠️ API error or empty response'
4867
4942
  chat_history << { role: 'assistant', content: answer }
4868
4943
  @pR.say(answer.fg(230))
4869
4944
  @pB.clear; @pB.update = true
@@ -5385,15 +5460,33 @@ def command_mode # {{{3
5385
5460
  refresh
5386
5461
  render
5387
5462
  else
5388
- # Check if it's a GUI application that should run without timeout
5389
- gui_apps = ['evince', 'xdg-open', 'firefox', 'chrome', 'chromium', 'eog', 'nautilus', 'thunar', 'gedit', 'code', 'subl']
5390
- prog_base = File.basename(prog.to_s)
5391
- is_gui = gui_apps.include?(prog_base)
5392
-
5393
- # Use nil timeout (wait forever) for GUI apps, 10 seconds for others
5394
- shellexec(cmd, timeout: is_gui ? nil : 10)
5395
- @pR.refresh
5396
- @pR.update = false
5463
+ # Run non-interactive commands in a background thread so GUI apps
5464
+ # (xdg-open, firefox, evince, …) and long-running tools don't freeze
5465
+ # the TUI. The main loop polls @shell_cmd_complete and drops output
5466
+ # into the right pane when ready.
5467
+ if @shell_cmd_running
5468
+ @pB.say("A background command is still running: #{@shell_cmd_label}".fg(214))
5469
+ return
5470
+ end
5471
+ @shell_cmd_running = true
5472
+ @shell_cmd_complete = false
5473
+ @shell_cmd_output = nil
5474
+ @shell_cmd_label = cmd
5475
+ cmd_str = cmd.dup
5476
+ @shell_cmd_thread = Thread.new do
5477
+ begin
5478
+ out, err, _status = Open3.capture3('sh', '-c', cmd_str)
5479
+ combined = out.dup
5480
+ combined << "\n" << err.fg(196) unless err.empty?
5481
+ @shell_cmd_output = combined
5482
+ rescue StandardError => e
5483
+ @shell_cmd_output = "Error: #{e.class}: #{e.message}".fg(196)
5484
+ ensure
5485
+ @shell_cmd_complete = true
5486
+ end
5487
+ end
5488
+ @pB.say(" Running: #{cmd}".fg(244))
5489
+ @pB.update = true
5397
5490
  end
5398
5491
  end
5399
5492
 
@@ -6574,7 +6667,8 @@ def conf_write(all: false) # WRITE TO ~/.rtfm/conf {{{2
6574
6667
  'history' => "@history = #{@pCmd.history.reverse.uniq.reverse.last(40)}",
6575
6668
  'rubyhistory' => "@rubyhistory = #{@pRuby.history.reverse.uniq.reverse.last(40)}",
6576
6669
  'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}",
6577
- 'sshhistory' => "@sshhistory = #{@pSsh.history.reverse.uniq.reverse.last(40)}"
6670
+ 'sshhistory' => "@sshhistory = #{@pSsh.history.reverse.uniq.reverse.last(40)}",
6671
+ 'directory' => "@directory = #{@directory.select { |d, _| Dir.exist?(d) }.to_a.last(200).to_h}"
6578
6672
  }
6579
6673
  if all
6580
6674
  assignments.merge!(
@@ -7063,6 +7157,13 @@ end
7063
7157
  @pSsh.record = true
7064
7158
  @pSsh.history = @sshhistory
7065
7159
 
7160
+ # Restore startup cursor from saved per-directory positions (unless --fresh).
7161
+ # A file-path argument is applied after the first render in the main loop,
7162
+ # since @files isn't populated until render() runs.
7163
+ if !@fresh && @directory[Dir.pwd]
7164
+ @index = @directory[Dir.pwd]
7165
+ end
7166
+
7066
7167
  # Report plugin errors {{{2
7067
7168
  @pR.say("Plugin load errors:\n" + @plugin_errors.join("\n").fg(196)) if @plugin_errors.any?
7068
7169
 
@@ -7096,6 +7197,23 @@ loop do
7096
7197
  @pB.update = false
7097
7198
  end
7098
7199
 
7200
+ # Check async `:` shell command (non-interactive branch in command_mode)
7201
+ if @shell_cmd_complete
7202
+ @shell_cmd_complete = false
7203
+ @shell_cmd_running = false
7204
+ @shell_cmd_thread = nil
7205
+ if @shell_cmd_output && !@shell_cmd_output.empty?
7206
+ clear_image
7207
+ @pR.say(@shell_cmd_output)
7208
+ end
7209
+ @pB.say(" Done: #{@shell_cmd_label}".fg(156))
7210
+ @shell_cmd_label = nil
7211
+ @shell_cmd_output = nil
7212
+ # Directory may have changed (file created/removed); invalidate cache
7213
+ @dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
7214
+ @pL.update = @pR.update = @pB.update = true
7215
+ end
7216
+
7099
7217
  # Handle pending terminal resize (debounced from SIGWINCH)
7100
7218
  if @winch_pending && !@external_program_running
7101
7219
  @winch_pending = false
@@ -7115,6 +7233,15 @@ loop do
7115
7233
  rescue Errno::EIO
7116
7234
  # Note: rcurses 4.9.0+ has enhanced error handling, reducing need for this
7117
7235
  end
7236
+ # Apply file-argument cursor selection on first render (when @files is populated)
7237
+ if @startup_select && @files && !@files.empty?
7238
+ ix = @files.index(@startup_select)
7239
+ if ix
7240
+ @index = ix
7241
+ @pL.update = @pR.update = true
7242
+ end
7243
+ @startup_select = nil
7244
+ end
7118
7245
  # read key, but ignore TTY-focus errors
7119
7246
  begin
7120
7247
  getkey
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rtfm-filemanager
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.2.6
4
+ version: 8.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rcurses
@@ -104,7 +103,6 @@ licenses:
104
103
  - Unlicense
105
104
  metadata:
106
105
  source_code_uri: https://github.com/isene/RTFM
107
- post_install_message:
108
106
  rdoc_options: []
109
107
  require_paths:
110
108
  - lib
@@ -119,8 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
119
117
  - !ruby/object:Gem::Version
120
118
  version: '0'
121
119
  requirements: []
122
- rubygems_version: 3.4.20
123
- signing_key:
120
+ rubygems_version: 3.6.7
124
121
  specification_version: 4
125
122
  summary: RTFM - Ruby Terminal File Manager
126
123
  test_files: []