tempest-rb 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 828dcb07d7565737bbe9b37ef7ca876fe6136dd7aaf308df43ec8c8fac9be825
4
- data.tar.gz: 3a32fde6e7693ba99acea0482875cc9919e8e4969dd201404edeeb9e01d89193
3
+ metadata.gz: 2312e8eb343bf518ce61de53a97608a42ece28af9777501910d864e9e2f79f1f
4
+ data.tar.gz: 05063b25a45e82fbcb7051f93f6af1715e0c2e25f7cfeefdbf343b3d5f4877ed
5
5
  SHA512:
6
- metadata.gz: 8eb5b81c5d05843c461fcf2352b8be5351e95dbb4eebd1359ba0c2ee2380400de36bd627fab9d96db685f44bb80cf39c3c914c1183ee895481f33d7d9b3a4b84
7
- data.tar.gz: 1b16bd98ef48212760133e3b87bb877a9f3a7c0e1044a86f541219cf32871fad921d88074ac7ca52e1c42ed69b5f8d1fedbd5f7e762d4d3c21d22c45be88ad3c
6
+ metadata.gz: 8eb2f34c4c3e7dcb851976b4f7ad502edfb57784661dd06ed0792eaef9f1344ef65dee5a1b05f63c51859aa84d9412f23b5714cde1d65a08360dac549245e98f
7
+ data.tar.gz: 23d4f50ec5dab93b6d792009103f78a56df3fa7019bd1266866ff582a55db1e5831f72376ef2949c3bb4cf029a68c1faf1b3f68ea60cb4ea627507924ece146d
data/README.md CHANGED
@@ -113,6 +113,7 @@ If you have been using a single-account installation of an earlier `tempest` rel
113
113
  |------------------|--------------------------------------------------|
114
114
  | `:timeline` | Fetch and print the home timeline |
115
115
  | `:stream on/off` | Toggle the Jetstream live feed |
116
+ | `:compose` | Open your editor to compose a multi-line post |
116
117
  | `:open $LX` | Open the URL with id `$LX` in the browser |
117
118
  | `:help` | Show in-app help |
118
119
  | `:quit` | Exit (`Ctrl-D` works too) |
@@ -120,6 +121,8 @@ If you have been using a single-account installation of an earlier `tempest` rel
120
121
 
121
122
  Anything else you type is sent as a new post.
122
123
 
124
+ `:compose` hands the terminal over to your editor so you can write a longer post without fighting the single-line prompt. The editor is picked from `$VISUAL`, then `$EDITOR`, and falls back to `vi`. Lines that start with `#` are treated as comments and stripped; save with an empty body to cancel. Remember to `export` the variable in your shell rc (`export EDITOR=nvim`) — without `export`, the value is not inherited by child processes and the fallback to `vi` kicks in.
125
+
123
126
  Each post in the timeline is prefixed with a short `$XX` id, and URLs found inside posts get their own `$LX` ids. Use those ids with `$XX <text>` to reply or `:open $LX` to open a link. Like and repost events show the subject post's `$XX` id in trailing brackets (for example `liked @bob's post [$AA]`) whenever the original post is still in the session registry, so you can reply to it directly.
124
127
 
125
128
  ### CLI options
@@ -87,10 +87,12 @@ module Tempest
87
87
  cursor_store: cursor_store(env, did: target_did),
88
88
  filter: plan.filter,
89
89
  logger: debug_logger,
90
+ did: target_did,
90
91
  )
91
92
  watchdog = Tempest::Jetstream::Watchdog.new(
92
93
  stream_manager: stream_manager,
93
94
  logger: debug_logger,
95
+ did: target_did,
94
96
  **watchdog_options(env),
95
97
  )
96
98
 
@@ -127,8 +127,9 @@ module Tempest
127
127
  class Channel
128
128
  attr_reader :loggers
129
129
 
130
- def initialize(loggers:)
130
+ def initialize(loggers:, defaults: {})
131
131
  @loggers = Array(loggers)
132
+ @defaults = defaults.freeze
132
133
  end
133
134
 
134
135
  def info(mod, event:, **fields)
@@ -147,6 +148,15 @@ module Tempest
147
148
  emit(Logger::ERROR, mod, event, fields)
148
149
  end
149
150
 
151
+ # Returns a child channel that prepends `default_fields` to every
152
+ # subsequent log call. Used to attach per-session context such as `did=`
153
+ # to long-lived components (StreamManager, Watchdog) without sprinkling
154
+ # the field across every call site.
155
+ def with(**default_fields)
156
+ return self if default_fields.empty?
157
+ Channel.new(loggers: @loggers, defaults: @defaults.merge(default_fields))
158
+ end
159
+
150
160
  def close
151
161
  @loggers.each do |logger|
152
162
  begin
@@ -162,7 +172,8 @@ module Tempest
162
172
 
163
173
  def emit(level, mod, event, fields)
164
174
  return if @loggers.empty?
165
- msg = format_body(event, fields)
175
+ merged = @defaults.merge(fields)
176
+ msg = format_body(event, merged)
166
177
  @loggers.each { |logger| logger.add(level, msg, mod) }
167
178
  end
168
179
 
@@ -25,7 +25,7 @@ module Tempest
25
25
  def initialize(client:, backoff: DEFAULT_BACKOFF, sleeper: ->(s) { sleep(s) },
26
26
  clock: -> { Time.now }, cursor_store: nil,
27
27
  cursor_save_interval: DEFAULT_CURSOR_SAVE_INTERVAL,
28
- filter: nil, logger: nil)
28
+ filter: nil, logger: nil, did: nil)
29
29
  @client = client
30
30
  @backoff = backoff
31
31
  @sleeper = sleeper
@@ -33,7 +33,8 @@ module Tempest
33
33
  @cursor_store = cursor_store
34
34
  @cursor_save_interval = cursor_save_interval
35
35
  @filter = filter
36
- @logger = logger || Tempest::DebugLog.null_channel
36
+ base = logger || Tempest::DebugLog.null_channel
37
+ @logger = did ? base.with(did: did) : base
37
38
  @thread = nil
38
39
  @mutex = Mutex.new
39
40
  @stopping = false
@@ -25,13 +25,14 @@ module Tempest
25
25
  interval_seconds: DEFAULT_INTERVAL_SECONDS,
26
26
  clock: -> { Time.now },
27
27
  sleeper: ->(s) { sleep(s) },
28
- logger: nil)
28
+ logger: nil, did: nil)
29
29
  @stream_manager = stream_manager
30
30
  @threshold_seconds = threshold_seconds
31
31
  @interval_seconds = interval_seconds
32
32
  @clock = clock
33
33
  @sleeper = sleeper
34
- @logger = logger || Tempest::DebugLog.null_channel
34
+ base = logger || Tempest::DebugLog.null_channel
35
+ @logger = did ? base.with(did: did) : base
35
36
  @thread = nil
36
37
  @mutex = Mutex.new
37
38
  @stopping = false
@@ -0,0 +1,77 @@
1
+ require "tempfile"
2
+
3
+ require_relative "../../tempest"
4
+
5
+ module Tempest
6
+ module REPL
7
+ # Opens `$VISUAL` / `$EDITOR` on a scratch file so the user can compose a
8
+ # multi-line post in their normal editor, the same pattern `git commit`
9
+ # uses. Returns one of the status tuples below; the caller (typically
10
+ # `Runner#handle_compose`) maps each to a user-facing line and, when
11
+ # `:ok`, forwards the body to `Post.create`.
12
+ #
13
+ # Return values:
14
+ # [:ok, body] successful compose; body is non-empty
15
+ # [:empty, nil] user saved an empty body — treat as cancellation
16
+ # [:editor_failed, nil] the editor subprocess returned a non-zero status
17
+ #
18
+ # Lines beginning with `#` are stripped from the file before posting (so we
19
+ # can pre-populate the file with instructions a la `git commit`'s template).
20
+ module Compose
21
+ TEMPLATE = <<~EOT.freeze
22
+
23
+ # Compose your Bluesky post above this line.
24
+ # Lines starting with `#` and surrounding whitespace are stripped.
25
+ # Save with an empty body (or quit without changes) to cancel.
26
+ EOT
27
+
28
+ module_function
29
+
30
+ def run(env: ENV, runner: Kernel.method(:system),
31
+ tempfile_factory: ->(suffix) { Tempfile.new(["tempest-compose-", suffix]) })
32
+ editor = pick_editor(env)
33
+
34
+ file = tempfile_factory.call(".txt")
35
+ path = file.path
36
+ begin
37
+ file.write(TEMPLATE)
38
+ file.flush
39
+ file.close
40
+
41
+ ok = runner.call(editor, path)
42
+ return [:editor_failed, nil] unless ok
43
+
44
+ body = parse(File.read(path))
45
+ return [:empty, nil] if body.empty?
46
+ [:ok, body]
47
+ ensure
48
+ begin
49
+ file.unlink
50
+ rescue StandardError
51
+ # File may already be gone (e.g. editor moved it); best-effort.
52
+ end
53
+ end
54
+ end
55
+
56
+ # Editor resolution order, matching git's convention: $VISUAL, then
57
+ # $EDITOR, then "vi" as a POSIX-mandated last resort. The fallback means
58
+ # we never need to surface a "no editor" error to the user; if even vi
59
+ # cannot be exec'd, `Kernel.system` returns false and the call surfaces
60
+ # as :editor_failed.
61
+ def pick_editor(env)
62
+ candidate = env["VISUAL"]
63
+ candidate = env["EDITOR"] if candidate.nil? || candidate.strip.empty?
64
+ candidate = "vi" if candidate.nil? || candidate.strip.empty?
65
+ candidate
66
+ end
67
+
68
+ def parse(content)
69
+ content
70
+ .each_line
71
+ .reject { |line| line.start_with?("#") }
72
+ .join
73
+ .strip
74
+ end
75
+ end
76
+ end
77
+ end
@@ -5,7 +5,7 @@ module Tempest
5
5
  Command = Data.define(:name, :args)
6
6
 
7
7
  class Dispatcher
8
- KNOWN_COMMANDS = %i[timeline quit help stream open relogin fav].freeze
8
+ KNOWN_COMMANDS = %i[timeline quit help stream open relogin fav compose].freeze
9
9
  DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
10
10
 
11
11
  def dispatch(input)
@@ -4,6 +4,7 @@ require_relative "../../tempest"
4
4
  require_relative "../timeline"
5
5
  require_relative "../post"
6
6
  require_relative "../jetstream/stream_manager"
7
+ require_relative "compose"
7
8
  require_relative "dispatcher"
8
9
  require_relative "formatter"
9
10
  require_relative "registry"
@@ -17,6 +18,7 @@ module Tempest
17
18
  Available commands:
18
19
  :timeline Fetch and print the home timeline
19
20
  :stream on|off Toggle the Jetstream live feed
21
+ :compose Open $EDITOR to write a multi-line post
20
22
  :open $XX|$LX Open the post or URL with the given id in the browser
21
23
  :fav $XX Like the post with id $XX
22
24
  :relogin Re-authenticate when the cached session is dead
@@ -35,7 +37,7 @@ module Tempest
35
37
  def initialize(session:, client:, input:, output:, dispatcher: Dispatcher.new,
36
38
  stream_manager: nil, handle_resolver: nil, stream_output: nil,
37
39
  timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER,
38
- avatar_store: nil, reauth: nil)
40
+ avatar_store: nil, reauth: nil, compose: Compose.method(:run))
39
41
  @session = session
40
42
  @client = client
41
43
  @input = input
@@ -49,6 +51,7 @@ module Tempest
49
51
  @opener = opener
50
52
  @avatar_store = avatar_store
51
53
  @reauth = reauth
54
+ @compose = compose
52
55
  # URIs already printed via bootstrap_timeline or backfill_timeline.
53
56
  # Jetstream's cursor-replay can re-emit those same posts on startup
54
57
  # (the persisted cursor is older than the getTimeline window), so the
@@ -105,6 +108,8 @@ module Tempest
105
108
  handle_stream(command.args.first)
106
109
  when :post
107
110
  handle_post(command.args.first)
111
+ when :compose
112
+ handle_compose
108
113
  when :reply
109
114
  handle_reply(command.args[0], command.args[1])
110
115
  when :open
@@ -148,6 +153,30 @@ module Tempest
148
153
  @output.puts "error: #{e.message}"
149
154
  end
150
155
 
156
+ def handle_compose
157
+ # Hand the terminal off to $EDITOR for the duration of the compose so
158
+ # the editor can take full control of the screen. We re-park the
159
+ # Screen on return regardless of success or exception.
160
+ suspend_screen do
161
+ status, body = @compose.call
162
+ case status
163
+ when :ok
164
+ handle_post(body)
165
+ when :empty
166
+ @output.puts "compose cancelled (empty body)"
167
+ when :editor_failed
168
+ @output.puts "editor exited with a non-zero status; post not sent"
169
+ end
170
+ end
171
+ end
172
+
173
+ def suspend_screen
174
+ @output.suspend if @output.respond_to?(:suspend)
175
+ yield
176
+ ensure
177
+ @output.resume if @output.respond_to?(:resume)
178
+ end
179
+
151
180
  def handle_relogin
152
181
  if @reauth.nil?
153
182
  @output.puts "relogin is not available in this session"
@@ -46,6 +46,26 @@ module Tempest
46
46
  @enabled = false
47
47
  end
48
48
 
49
+ # Transient teardown for handing the terminal off to a subprocess (e.g.
50
+ # $EDITOR via `:compose`). Unlike `disable`, this does NOT issue the
51
+ # Kitty graphics delete sequence — terminals that support the Kitty
52
+ # protocol keep image placements in the main screen buffer even while
53
+ # the editor draws on the alternate buffer, so suspending without
54
+ # deleting lets the avatars re-appear automatically when the editor
55
+ # exits. Pair with `resume` to re-establish the scrolling region.
56
+ def suspend
57
+ return unless @enabled
58
+ uninstall_resize_trap
59
+ @io.print "\e[r"
60
+ @io.flush if @io.respond_to?(:flush)
61
+ @enabled = false
62
+ end
63
+
64
+ def resume
65
+ return if @enabled
66
+ enable
67
+ end
68
+
49
69
  def enabled?
50
70
  @enabled
51
71
  end
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tempest-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara
@@ -128,6 +128,7 @@ files:
128
128
  - lib/tempest/post.rb
129
129
  - lib/tempest/post_view.rb
130
130
  - lib/tempest/repl/async_output.rb
131
+ - lib/tempest/repl/compose.rb
131
132
  - lib/tempest/repl/dispatcher.rb
132
133
  - lib/tempest/repl/formatter.rb
133
134
  - lib/tempest/repl/registry.rb