Module: Tempest::REPL::Formatter

Defined in:
lib/tempest/repl/formatter.rb

Overview

Renders posts and Jetstream events as terminal lines, earthquake-style:

[$AA] [HH:MM] @handle: text

The leading [$AA] is only emitted when a Registry is supplied (and the event is something that can be replied to — a post, not a delete or a like/repost record). URLs found in the body are annotated inline with their own ($LA) ids when a registry is supplied. ANSI colors are emitted only when Formatter.color is true (set by the CLI when stdout is a TTY); tests run with color disabled.

Constant Summary collapse

RESET =
"\e[0m".freeze
CYAN =
"\e[36m".freeze
GREEN =
"\e[32m".freeze
DIM =
"\e[2m".freeze
HASHTAG_BLUE =
"\e[38;5;110m".freeze
VAR_PALETTE =

Palette of ANSI 256-color codes used to colorize id vars ($AA, $LA …). Hand-picked across the hue wheel for readability on dark backgrounds, avoiding the cyan (time), green (handle), and blue-110 (hashtag) already used elsewhere. Entries stay within the 6x6x6 cube’s middle bands (rgb components mostly in 3..4) so the colors read as muted rather than saturated neon — id vars sit beside the post text and shouldn’t fight it for attention. Order is the hue-sorted set rotated by step 13 (coprime with 24), so consecutive palette indices land on near-opposite hues; combined with var_index this keeps adjacent vars ($AY/$AZ/$BA …) visibly distinct.

[
  167, 115, 138, 109, 173, 67,  137, 97,
  186, 140, 150, 139, 108, 174, 116, 175,
  117, 180, 103, 179, 146, 143, 176, 114,
].freeze
HASHTAG_PATTERN =
/#[[:alnum:]_]+/.freeze
URL_PATTERN =
%r{https?://[^\s]+}.freeze
DECORATE_PATTERN =
Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
MEDIA_EMOJI =

Visible hint that a post carries an image / video embed. Only kinds that aren’t already surfaced by other UI (link cards become URLs, quote posts inline their record) get a marker.

{
  images: "📷",
  video:  "🎥",
}.freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.colorObject

Returns the value of attribute color.



53
54
55
# File 'lib/tempest/repl/formatter.rb', line 53

def color
  @color
end

Class Method Details

.annotate_urls(text, registry, facets: nil) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/tempest/repl/formatter.rb', line 167

def annotate_urls(text, registry, facets: nil)
  return text unless registry
  text = text.to_s
  if facets && !facets.empty?
    return annotate_urls_with_facets(text, registry, facets)
  end

  urls = URI.extract(text, ["http", "https"]).uniq
  urls.each do |url|
    var = registry.assign_url(url)
    text = text.sub(url, "#{url} (#{colorize_var(var)})")
  end
  text
end

.annotate_urls_with_facets(text, registry, facets) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/tempest/repl/formatter.rb', line 182

def annotate_urls_with_facets(text, registry, facets)
  text = text.dup.force_encoding(Encoding::UTF_8)
  bytesize = text.bytesize
  valid = facets
    .select { |f| f.byte_start.is_a?(Integer) && f.byte_end.is_a?(Integer) }
    .select { |f| f.byte_start >= 0 && f.byte_end <= bytesize && f.byte_start < f.byte_end }
    .sort_by(&:byte_start)

  # Assign vars in reading order so earlier facets get earlier ids.
  replacements = valid.map do |facet|
    var = registry.assign_url(facet.uri)
    domain = host_of(facet.uri) || facet.uri
    [facet, "[#{domain} #{colorize_var(var)}]"]
  end

  # Apply substitutions in reverse byte order so earlier ranges remain valid.
  replacements.reverse_each do |facet, replacement|
    head = text.byteslice(0, facet.byte_start) || ""
    tail = text.byteslice(facet.byte_end, text.bytesize - facet.byte_end) || ""
    text = (head + replacement + tail).force_encoding(Encoding::UTF_8)
  end
  text
end

.avatar_icon(did, avatar_store) ⇒ Object

Returns the Kitty graphics escape for this DID’s avatar, or nil when avatars are off, the store has nothing yet, or colors are disabled (test mode mirrors today’s color suppression so snapshots stay clean).



244
245
246
247
248
249
250
251
# File 'lib/tempest/repl/formatter.rb', line 244

def avatar_icon(did, avatar_store)
  return nil unless avatar_store
  return nil unless Formatter.color
  return nil if did.nil? || did.empty?
  path = avatar_store.path_for(did)
  return nil if path.nil?
  Kitty.inline(path)
end

.bracket(time) ⇒ Object



253
254
255
# File 'lib/tempest/repl/formatter.rb', line 253

def bracket(time)
  Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
end

.colorize_var(var) ⇒ Object

Returns a deterministic ANSI 256-color escape for the given var. Indexing by the two-letter id (var_index) avoids the byte-sum collisions that made $AZ and $BA share the same palette slot.



264
265
266
267
268
# File 'lib/tempest/repl/formatter.rb', line 264

def colorize_var(var)
  return var unless Formatter.color
  code = VAR_PALETTE[var_index(var) % VAR_PALETTE.size]
  "\e[38;5;#{code}m#{var}#{RESET}"
end

.compose(var, time, handle, did, text, icon: nil) ⇒ Object



225
226
227
228
229
230
231
232
# File 'lib/tempest/repl/formatter.rb', line 225

def compose(var, time, handle, did, text, icon: nil)
  prefix = ""
  prefix += id_label(var) if var
  prefix += bracket(time) if time
  identity = handle ? handle_label(handle) : did_label(did)
  identity = "#{icon}  #{identity}" if icon
  "#{prefix}#{identity}: #{text}"
end

.decorate_body(text) ⇒ Object



70
71
72
73
74
75
76
77
78
# File 'lib/tempest/repl/formatter.rb', line 70

def decorate_body(text)
  return text unless Formatter.color
  return text if text.nil? || text.empty?

  text.gsub(DECORATE_PATTERN) do |match|
    color = match.start_with?("http") ? DIM : HASHTAG_BLUE
    "#{color}#{match}#{RESET}"
  end
end

.did_label(did) ⇒ Object



285
286
287
# File 'lib/tempest/repl/formatter.rb', line 285

def did_label(did)
  Formatter.color ? "#{DIM}<#{did}>#{RESET}" : "<#{did}>"
end

.embed_kind_of(record) ⇒ Object



156
157
158
# File 'lib/tempest/repl/formatter.rb', line 156

def embed_kind_of(record)
  record.respond_to?(:embed_kind) ? record.embed_kind : nil
end

.event_line(event, registry: nil, resolver: nil, avatar_store: nil) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/tempest/repl/formatter.rb', line 100

def event_line(event, registry: nil, resolver: nil, avatar_store: nil)
  handle = resolver&.resolve(event.did)
  if event.operation == :delete
    body = "(deleted #{event.collection}/#{event.rkey})"
    var = nil
  elsif event.respond_to?(:like?) && event.like?
    body = "liked #{subject_owner_label(event.subject_uri, resolver, registry)}"
    var = nil
  elsif event.respond_to?(:repost?) && event.repost?
    body = "reposted #{subject_owner_label(event.subject_uri, resolver, registry)}"
    var = nil
  else
    facets = event.respond_to?(:facets) ? event.facets : nil
    body = annotate_urls(squeeze(event.text), registry, facets: facets)
    body = decorate_body(body)
    body = prepend_media_marker(body, embed_kind_of(event))
    body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
    var = registry&.assign_post(event)
  end
  icon = avatar_icon(event.did, avatar_store)
  compose(var, format_time(event.created_at), handle, event.did, body, icon: icon)
end

.format_status_time(time) ⇒ Object



96
97
98
# File 'lib/tempest/repl/formatter.rb', line 96

def format_status_time(time)
  time.respond_to?(:localtime) ? time.localtime.strftime("%H:%M") : time.to_s
end

.format_time(iso) ⇒ Object



218
219
220
221
222
223
# File 'lib/tempest/repl/formatter.rb', line 218

def format_time(iso)
  return nil if iso.nil? || iso.empty?
  Time.iso8601(iso).localtime.strftime("%H:%M")
rescue ArgumentError
  nil
end

.handle_label(handle) ⇒ Object



281
282
283
# File 'lib/tempest/repl/formatter.rb', line 281

def handle_label(handle)
  Formatter.color ? "#{GREEN}@#{handle}#{RESET}" : "@#{handle}"
end

.host_of(uri) ⇒ Object



206
207
208
209
210
211
212
# File 'lib/tempest/repl/formatter.rb', line 206

def host_of(uri)
  parsed = URI.parse(uri)
  host = parsed.host
  host && !host.empty? ? host : nil
rescue URI::InvalidURIError
  nil
end

.id_label(var) ⇒ Object



257
258
259
# File 'lib/tempest/repl/formatter.rb', line 257

def id_label(var)
  Formatter.color ? "[#{colorize_var(var)}] " : "[#{var}] "
end

.post_did(post) ⇒ Object

DID for a Post is extracted from its at:// URI, since Post itself only carries handle. AvatarStore is keyed by DID, so we have to derive it.



236
237
238
239
# File 'lib/tempest/repl/formatter.rb', line 236

def post_did(post)
  return nil unless post.respond_to?(:uri)
  subject_did(post.uri)
end

.post_line(post, registry: nil, avatar_store: nil) ⇒ Object



59
60
61
62
63
64
65
66
67
68
# File 'lib/tempest/repl/formatter.rb', line 59

def post_line(post, registry: nil, avatar_store: nil)
  var = registry&.assign_post(post)
  facets = post.respond_to?(:facets) ? post.facets : nil
  body = annotate_urls(squeeze(post.text), registry, facets: facets)
  body = decorate_body(body)
  body = prepend_media_marker(body, embed_kind_of(post))
  body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
  icon = avatar_icon(post_did(post), avatar_store)
  compose(var, format_time(post.created_at), post.handle, nil, body, icon: icon)
end

.prepend_media_marker(body, embed_kind) ⇒ Object



160
161
162
163
164
165
# File 'lib/tempest/repl/formatter.rb', line 160

def prepend_media_marker(body, embed_kind)
  emoji = MEDIA_EMOJI[embed_kind]
  return body unless emoji
  body = body.to_s
  body.empty? ? emoji : "#{emoji} #{body}"
end

.prepend_reply_marker(body, reply_parent_uri, registry) ⇒ Object



147
148
149
150
151
152
153
154
# File 'lib/tempest/repl/formatter.rb', line 147

def prepend_reply_marker(body, reply_parent_uri, registry)
  return body if reply_parent_uri.nil? || reply_parent_uri.empty?
  return body unless registry

  parent_var = registry.var_for_uri(reply_parent_uri)
  marker = parent_var ? "#{colorize_var(parent_var)} " : ""
  "#{marker}#{body}"
end

.reply_parent_uri_of(record) ⇒ Object



143
144
145
# File 'lib/tempest/repl/formatter.rb', line 143

def reply_parent_uri_of(record)
  record.respond_to?(:reply_parent_uri) ? record.reply_parent_uri : nil
end

.squeeze(text) ⇒ Object



214
215
216
# File 'lib/tempest/repl/formatter.rb', line 214

def squeeze(text)
  text.to_s.gsub(/\s*\n\s*/, " ")
end

.status_line(status) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/tempest/repl/formatter.rb', line 80

def status_line(status)
  body = case status.state
  when :disconnected
    status.reason == :error && status.error ? "disconnected: #{status.error.message}" : "disconnected"
  when :reconnecting
    "reconnecting..."
  when :live
    "live"
  when :gapped
    "fetching timeline (offline since #{format_status_time(status.since)})"
  else
    status.state.to_s
  end
  Formatter.color ? "#{DIM}-- #{body}#{RESET}" : "-- #{body}"
end

.subject_did(subject_uri) ⇒ Object



137
138
139
140
141
# File 'lib/tempest/repl/formatter.rb', line 137

def subject_did(subject_uri)
  return nil if subject_uri.nil? || subject_uri.empty?
  match = subject_uri.match(%r{\Aat://([^/]+)/})
  match && match[1]
end

.subject_owner_label(subject_uri, resolver, registry = nil) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/tempest/repl/formatter.rb', line 123

def subject_owner_label(subject_uri, resolver, registry = nil)
  did = subject_did(subject_uri)
  return "a post" unless did

  handle = resolver&.resolve(did)
  owner = handle ? handle_label(handle) : did_label(did)
  label = "#{owner}'s post"
  var = registry&.var_for_uri(subject_uri)
  return label unless var

  bracket = Formatter.color ? "[#{colorize_var(var)}]" : "[#{var}]"
  "#{label} #{bracket}"
end

.var_index(var) ⇒ Object

Converts the two-letter portion of “$XY” into a base-26 index so consecutive vars always map to consecutive palette slots. Falls back to 0 for malformed vars; the palette rotation handles the rest.



273
274
275
276
277
278
279
# File 'lib/tempest/repl/formatter.rb', line 273

def var_index(var)
  pair = var.to_s.sub(/\A\$/, "")
  return 0 if pair.length < 2
  high = pair[0].ord - "A".ord
  low = pair[1].ord - "A".ord
  high * 26 + low
end