slk 0.4.0 → 0.4.1

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.
@@ -6,13 +6,16 @@ module Slk
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class MessageFormatter
8
8
  # rubocop:disable Metrics/ParameterLists
9
- def initialize(output:, mention_replacer:, emoji_replacer:, cache_store:, api_client: nil, on_debug: nil)
9
+ def initialize(output:, mention_replacer:, emoji_replacer:, cache_store:, api_client: nil,
10
+ text_processor: nil, on_debug: nil)
10
11
  @output = output
11
- @mentions = mention_replacer
12
- @emoji = emoji_replacer
13
12
  @cache = cache_store
14
13
  @api_client = api_client
15
14
  @on_debug = on_debug
15
+ @text_processor = text_processor || TextProcessor.new(
16
+ mention_replacer: mention_replacer,
17
+ emoji_replacer: emoji_replacer
18
+ )
16
19
  @reaction_formatter = build_reaction_formatter(output, emoji_replacer, cache_store)
17
20
  @json_formatter = JsonMessageFormatter.new(cache_store: cache_store)
18
21
  end
@@ -25,7 +28,7 @@ module Slk
25
28
  def format(message, workspace:, options: {})
26
29
  username = resolve_username(message, workspace, options)
27
30
  timestamp = format_timestamp(message.timestamp)
28
- text = process_text(message.text, workspace, options)
31
+ text = @text_processor.process(message.text, workspace, options)
29
32
 
30
33
  header = build_header(timestamp, username)
31
34
  display_text = build_display_text(text, message, header, options)
@@ -37,7 +40,7 @@ module Slk
37
40
  def format_simple(message, workspace:, options: {})
38
41
  username = resolve_username(message, workspace, options)
39
42
  timestamp = format_timestamp(message.timestamp)
40
- text = process_text(message.text, workspace, options)
43
+ text = @text_processor.process(message.text, workspace, options)
41
44
 
42
45
  reaction_text = ''
43
46
  unless options[:no_reactions] || message.reactions.empty?
@@ -88,7 +91,7 @@ module Slk
88
91
 
89
92
  def build_output_lines(main_line, message, workspace, options, display_text)
90
93
  lines = [main_line]
91
- text_processor = ->(txt) { process_text(txt, workspace, options) }
94
+ text_processor = ->(txt) { @text_processor.process(txt, workspace, options) }
92
95
 
93
96
  BlockFormatter.new(text_processor: text_processor)
94
97
  .format(message.blocks, message.text, lines, options)
@@ -118,18 +121,7 @@ module Slk
118
121
  end
119
122
 
120
123
  def format_timestamp(time)
121
- time.strftime('%Y-%m-%d %H:%M')
122
- end
123
-
124
- def process_text(text, workspace, options)
125
- result = decode_html_entities(text.dup)
126
- result = @mentions.replace(result, workspace)
127
- result = @emoji.replace(result, workspace) unless options[:no_emoji]
128
- result
129
- end
130
-
131
- def decode_html_entities(text)
132
- text.gsub('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>')
124
+ time.strftime('%Y-%m-%d %H:%M:%S')
133
125
  end
134
126
 
135
127
  def format_files(message, lines, options, skip_first: false)
@@ -80,7 +80,7 @@ module Slk
80
80
 
81
81
  def format_time(slack_timestamp)
82
82
  time = Time.at(slack_timestamp.to_f)
83
- time.strftime('%-I:%M %p') # e.g., "2:45 PM"
83
+ time.strftime('%-I:%M:%S %p') # e.g., "2:45:30 PM"
84
84
  end
85
85
  end
86
86
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Formats saved/later items for terminal display
6
+ class SavedItemFormatter
7
+ def initialize(output:, mention_replacer:, text_processor:, on_debug: nil)
8
+ @output = output
9
+ @mentions = mention_replacer
10
+ @text_processor = text_processor
11
+ @on_debug = on_debug
12
+ end
13
+
14
+ # Display a single saved item
15
+ # @param truncate [Boolean] if true, truncate to single line at width instead of wrapping
16
+ def display_item(item, workspace, message: nil, width: nil, truncate: false)
17
+ status_badge = format_status_badge(item)
18
+ due_info = format_due_info(item)
19
+
20
+ # First line: status badge and due info
21
+ header_parts = [status_badge, due_info].compact.reject(&:empty?)
22
+ @output.puts header_parts.join(' | ') unless header_parts.empty?
23
+
24
+ # Message content
25
+ display_message(message, workspace, width: width, truncate: truncate) if message
26
+ @output.puts # blank line between items
27
+ end
28
+
29
+ private
30
+
31
+ # rubocop:disable Metrics/MethodLength
32
+ def format_status_badge(item)
33
+ badge = case item.state
34
+ when 'completed' then '[completed]'
35
+ when 'in_progress' then '[in_progress]'
36
+ when 'saved' then '[saved]'
37
+ else "[#{item.state}]"
38
+ end
39
+
40
+ # Color based on overdue status
41
+ if item.overdue?
42
+ @output.red(badge)
43
+ elsif item.state == 'completed'
44
+ @output.green(badge)
45
+ elsif item.state == 'in_progress'
46
+ @output.yellow(badge)
47
+ else
48
+ @output.blue(badge)
49
+ end
50
+ end
51
+
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ def format_due_info(item)
55
+ return '' unless item.due_date?
56
+
57
+ time_diff = item.time_until_due
58
+ formatted = format_time_difference(time_diff)
59
+
60
+ if item.overdue?
61
+ "Due: #{@output.red(formatted)}"
62
+ else
63
+ "Due: #{formatted}"
64
+ end
65
+ end
66
+
67
+ # rubocop:disable Metrics/MethodLength
68
+ def format_time_difference(seconds)
69
+ abs_seconds = seconds.abs
70
+ ago = seconds.negative?
71
+
72
+ formatted = if abs_seconds < 60
73
+ "#{abs_seconds}s"
74
+ elsif abs_seconds < 3600
75
+ "#{abs_seconds / 60}m"
76
+ elsif abs_seconds < 86_400
77
+ "#{abs_seconds / 3600}h"
78
+ else
79
+ "#{abs_seconds / 86_400}d"
80
+ end
81
+
82
+ ago ? "#{formatted} ago" : "in #{formatted}"
83
+ end
84
+ # rubocop:enable Metrics/MethodLength
85
+
86
+ def display_message(message, workspace, width: nil, truncate: false)
87
+ username = resolve_message_author(message, workspace)
88
+ text = prepare_message_text(message, workspace)
89
+ header = " #{@output.bold(username)}: "
90
+ header_width = Support::TextWrapper.visible_length(header)
91
+
92
+ if truncate
93
+ display_truncated_message(header, text, width, header_width)
94
+ else
95
+ display_wrapped_message(header, text, width, header_width)
96
+ end
97
+ end
98
+
99
+ def display_truncated_message(header, text, width, header_width)
100
+ # Single line, truncated at width
101
+ first_line = text.lines.first&.strip || text
102
+ max_text_width = width ? [width - header_width, 10].max : 100
103
+ @output.puts "#{header}#{truncate_text(first_line, max_text_width)}"
104
+ end
105
+
106
+ def display_wrapped_message(header, text, width, header_width)
107
+ # Full message with wrapping
108
+ wrapped = wrap_text(text, header_width, width)
109
+ lines = wrapped.lines
110
+
111
+ first_line = lines.first&.rstrip || wrapped
112
+ @output.puts "#{header}#{first_line}"
113
+ lines[1..].each { |line| @output.puts " #{line.rstrip}" } if lines.length > 1
114
+ end
115
+
116
+ def resolve_message_author(message, workspace)
117
+ if message['user']
118
+ @mentions.lookup_user_name(workspace, message['user']) || message['user']
119
+ elsif message['bot_id']
120
+ 'Bot'
121
+ else
122
+ 'Unknown'
123
+ end
124
+ end
125
+
126
+ def prepare_message_text(message, workspace)
127
+ @text_processor.process(message['text'], workspace)
128
+ end
129
+
130
+ def wrap_text(text, indent_width, width)
131
+ return text if text.empty? || !width
132
+
133
+ # If header is wider than target, first line gets no wrap, subsequent lines wrap at width - 2
134
+ first_line_width = [width - indent_width, 10].max
135
+ continuation_width = [width - 2, 10].max
136
+ Support::TextWrapper.wrap(text, first_line_width, continuation_width)
137
+ end
138
+
139
+ def truncate_text(text, max_length)
140
+ text.length > max_length ? "#{text[0...max_length]}..." : text
141
+ end
142
+ end
143
+ end
144
+ end
@@ -4,10 +4,10 @@ module Slk
4
4
  module Formatters
5
5
  # Formats search results for terminal display
6
6
  class SearchFormatter
7
- def initialize(output:, emoji_replacer:, mention_replacer:)
7
+ def initialize(output:, mention_replacer:, text_processor:)
8
8
  @output = output
9
- @emoji = emoji_replacer
10
9
  @mentions = mention_replacer
10
+ @text_processor = text_processor
11
11
  end
12
12
 
13
13
  # Display a list of search results
@@ -54,11 +54,7 @@ module Slk
54
54
  end
55
55
 
56
56
  def prepare_text(text, workspace, options)
57
- return text if options[:no_emoji] && options[:no_mentions]
58
-
59
- text = @emoji.replace(text) unless options[:no_emoji]
60
- text = @mentions.replace(text, workspace) unless options[:no_mentions]
61
- text
57
+ @text_processor.process(text, workspace, options)
62
58
  end
63
59
 
64
60
  def display_files(files)
@@ -68,7 +64,7 @@ module Slk
68
64
  end
69
65
 
70
66
  def format_time(time)
71
- time.strftime('%Y-%m-%d %H:%M')
67
+ time.strftime('%Y-%m-%d %H:%M:%S')
72
68
  end
73
69
  end
74
70
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Centralized text processing for message display
6
+ # Handles HTML entity decoding, mention replacement, and emoji replacement
7
+ class TextProcessor
8
+ def initialize(mention_replacer:, emoji_replacer:)
9
+ @mentions = mention_replacer
10
+ @emoji = emoji_replacer
11
+ end
12
+
13
+ # Process raw Slack message text for display
14
+ # @param text [String] Raw message text from Slack API
15
+ # @param workspace [Models::Workspace] The workspace for name resolution
16
+ # @param options [Hash] Processing options
17
+ # @option options [Boolean] :no_emoji Skip emoji replacement
18
+ # @option options [Boolean] :no_mentions Skip mention replacement
19
+ # @return [String] Processed text ready for display
20
+ def process(text, workspace, options = {})
21
+ return '[No text]' if text.to_s.empty?
22
+
23
+ result = decode_html_entities(text.dup)
24
+ result = safe_replace_mentions(result, workspace) unless options[:no_mentions]
25
+ result = safe_replace_emoji(result, workspace) unless options[:no_emoji]
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def decode_html_entities(text)
32
+ text.gsub('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>')
33
+ end
34
+
35
+ def safe_replace_mentions(text, workspace)
36
+ @mentions.replace(text, workspace)
37
+ rescue StandardError
38
+ text
39
+ end
40
+
41
+ def safe_replace_emoji(text, workspace)
42
+ @emoji.replace(text, workspace)
43
+ rescue StandardError
44
+ text
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # State constants for saved items
6
+ SAVED_STATE_SAVED = 'saved'
7
+ SAVED_STATE_IN_PROGRESS = 'in_progress'
8
+ SAVED_STATE_COMPLETED = 'completed'
9
+
10
+ # Represents a saved/later item from Slack's saved.list API
11
+ # rubocop:disable Metrics/ParameterLists
12
+ SavedItem = Data.define(
13
+ :item_id,
14
+ :item_type,
15
+ :ts,
16
+ :state,
17
+ :date_created,
18
+ :date_due,
19
+ :date_completed,
20
+ :is_archived
21
+ ) do
22
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
23
+ def self.from_api(data)
24
+ new(
25
+ item_id: data['item_id'] || data['channel_id'] || data['conversation_id'],
26
+ item_type: data['item_type'] || data['type'] || 'message',
27
+ ts: data['ts'] || data['message_ts'],
28
+ state: data['state'] || SAVED_STATE_SAVED,
29
+ date_created: parse_timestamp(data['date_created']),
30
+ date_due: parse_timestamp(data['date_due']),
31
+ date_completed: parse_timestamp(data['date_completed']),
32
+ is_archived: data['is_archived'] || false
33
+ )
34
+ end
35
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
36
+
37
+ def self.parse_timestamp(value)
38
+ return nil if value.nil? || value.to_i.zero?
39
+
40
+ value.to_i
41
+ end
42
+
43
+ private_class_method :parse_timestamp
44
+
45
+ # rubocop:disable Naming/MethodParameterName
46
+ def initialize(
47
+ item_id:,
48
+ item_type: 'message',
49
+ ts: nil,
50
+ state: SAVED_STATE_SAVED,
51
+ date_created: nil,
52
+ date_due: nil,
53
+ date_completed: nil,
54
+ is_archived: false
55
+ )
56
+ super(
57
+ item_id: item_id.to_s.freeze,
58
+ item_type: item_type.to_s.freeze,
59
+ ts: ts&.to_s&.freeze,
60
+ state: state.to_s.freeze,
61
+ date_created: date_created,
62
+ date_due: date_due,
63
+ date_completed: date_completed,
64
+ is_archived: is_archived
65
+ )
66
+ end
67
+ # rubocop:enable Naming/MethodParameterName
68
+
69
+ # Channel ID alias for compatibility with message fetching
70
+ alias_method :channel_id, :item_id
71
+
72
+ # State predicates
73
+ def completed?
74
+ state == SAVED_STATE_COMPLETED
75
+ end
76
+
77
+ def in_progress?
78
+ state == SAVED_STATE_IN_PROGRESS
79
+ end
80
+
81
+ def saved?
82
+ state == SAVED_STATE_SAVED
83
+ end
84
+
85
+ def archived?
86
+ is_archived
87
+ end
88
+
89
+ # Due date helpers
90
+ def due_date?
91
+ !date_due.nil?
92
+ end
93
+
94
+ def overdue?
95
+ return false unless due_date?
96
+ return false if completed?
97
+
98
+ date_due < Time.now.to_i
99
+ end
100
+
101
+ def due_time
102
+ return nil unless due_date?
103
+
104
+ Time.at(date_due)
105
+ end
106
+
107
+ def created_time
108
+ return nil unless date_created
109
+
110
+ Time.at(date_created)
111
+ end
112
+
113
+ def completed_time
114
+ return nil unless date_completed
115
+
116
+ Time.at(date_completed)
117
+ end
118
+
119
+ # Time until/since due date
120
+ def time_until_due
121
+ return nil unless due_date?
122
+
123
+ date_due - Time.now.to_i
124
+ end
125
+ end
126
+ # rubocop:enable Metrics/ParameterLists
127
+ end
128
+ end
data/lib/slk/runner.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Slk
4
4
  # Dependency injection container providing services to commands
5
+ # rubocop:disable Metrics/ClassLength
5
6
  class Runner
6
7
  attr_reader :output, :config, :token_store, :api_client, :cache_store, :preset_store
7
8
 
@@ -83,6 +84,17 @@ module Slk
83
84
  Api::Search.new(@api_client, workspace(workspace_name))
84
85
  end
85
86
 
87
+ def saved_api(workspace_name = nil)
88
+ Api::Saved.new(@api_client, workspace(workspace_name))
89
+ end
90
+
91
+ def message_resolver(workspace_name = nil)
92
+ Services::MessageResolver.new(
93
+ conversations_api: conversations_api(workspace_name),
94
+ on_debug: ->(msg) { @output.debug(msg) }
95
+ )
96
+ end
97
+
86
98
  # Formatter helpers
87
99
  def message_formatter
88
100
  @message_formatter ||= Formatters::MessageFormatter.new(
@@ -107,6 +119,13 @@ module Slk
107
119
  @emoji_replacer ||= Formatters::EmojiReplacer.new
108
120
  end
109
121
 
122
+ def text_processor
123
+ @text_processor ||= Formatters::TextProcessor.new(
124
+ mention_replacer: mention_replacer,
125
+ emoji_replacer: emoji_replacer
126
+ )
127
+ end
128
+
110
129
  def duration_formatter
111
130
  @duration_formatter ||= Formatters::DurationFormatter.new
112
131
  end
@@ -114,8 +133,8 @@ module Slk
114
133
  def search_formatter
115
134
  @search_formatter ||= Formatters::SearchFormatter.new(
116
135
  output: @output,
117
- emoji_replacer: emoji_replacer,
118
- mention_replacer: mention_replacer
136
+ mention_replacer: mention_replacer,
137
+ text_processor: text_processor
119
138
  )
120
139
  end
121
140
 
@@ -135,4 +154,5 @@ module Slk
135
154
  @cache_store.on_warning = warning_handler
136
155
  end
137
156
  end
157
+ # rubocop:enable Metrics/ClassLength
138
158
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Resolves and fetches individual messages by timestamp from channels
6
+ # Used by activity feed, saved items, and other features that need to
7
+ # fetch message content by ts.
8
+ class MessageResolver
9
+ def initialize(conversations_api:, on_debug: nil)
10
+ @api = conversations_api
11
+ @on_debug = on_debug
12
+ end
13
+
14
+ # Fetch a single message by its timestamp from a channel
15
+ # @param channel_id [String] The channel ID
16
+ # @param message_ts [String] The message timestamp
17
+ # @return [Hash, nil] The message data or nil if not found
18
+ def fetch_by_ts(channel_id, message_ts)
19
+ response = fetch_message_history(channel_id, message_ts)
20
+ return nil unless response['ok'] && response['messages']&.any?
21
+
22
+ response['messages'].find { |msg| msg['ts'] == message_ts }
23
+ rescue ApiError => e
24
+ @on_debug&.call("Could not fetch message #{message_ts} from #{channel_id}: #{e.message}")
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ def fetch_message_history(channel_id, message_ts)
31
+ # Use a narrow time window around the message timestamp
32
+ oldest_ts = (message_ts.to_f - 1).to_s
33
+ latest_ts = (message_ts.to_f + 1).to_s
34
+ @api.history(channel: channel_id, limit: 10, oldest: oldest_ts, latest: latest_ts)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,11 +2,17 @@
2
2
 
3
3
  module Slk
4
4
  module Support
5
- # Shared module for inline image display in iTerm2/WezTerm/Mintty terminals
5
+ # Shared module for inline image display in terminals supporting
6
+ # iTerm2 protocol (iTerm2/WezTerm/Mintty) or Kitty graphics protocol (Ghostty/Kitty)
6
7
  # Includes special handling for tmux passthrough sequences
7
8
  module InlineImages
8
- # Check if terminal supports iTerm2 inline image protocol
9
+ # Check if terminal supports any inline image protocol
9
10
  def inline_images_supported?
11
+ iterm2_protocol_supported? || kitty_graphics_supported?
12
+ end
13
+
14
+ # Check if terminal supports iTerm2 inline image protocol
15
+ def iterm2_protocol_supported?
10
16
  # iTerm2, WezTerm, Mintty support inline images
11
17
  # LC_TERMINAL persists through tmux/ssh
12
18
  ['iTerm.app', 'WezTerm'].include?(ENV.fetch('TERM_PROGRAM', nil)) ||
@@ -15,31 +21,88 @@ module Slk
15
21
  ENV['TERM'] == 'mintty'
16
22
  end
17
23
 
24
+ # Check if terminal supports Kitty graphics protocol (Ghostty, Kitty)
25
+ def kitty_graphics_supported?
26
+ ENV['TERM_PROGRAM'] == 'ghostty' ||
27
+ ENV['GHOSTTY_RESOURCES_DIR'] ||
28
+ ENV['TERM']&.include?('kitty') ||
29
+ tmux_client_is_kitty_compatible?
30
+ end
31
+
32
+ # Check if tmux client terminal supports Kitty graphics
33
+ def tmux_client_is_kitty_compatible?
34
+ return false unless in_tmux?
35
+
36
+ @tmux_client_is_kitty_compatible ||= begin
37
+ output = begin
38
+ `tmux display-message -p '\#{client_termname}'`.chomp
39
+ rescue StandardError
40
+ ''
41
+ end
42
+ output.include?('ghostty') || output.include?('kitty')
43
+ end
44
+ end
45
+
18
46
  # Check if running inside tmux
19
47
  def in_tmux?
20
48
  # tmux sets TERM to screen-* or tmux-*
21
49
  ENV['TERM']&.include?('screen') || ENV['TERM']&.start_with?('tmux')
22
50
  end
23
51
 
24
- # Print an inline image using iTerm2 protocol
52
+ # Print an inline image using the appropriate protocol
25
53
  # In tmux, uses passthrough sequence and cursor positioning
26
54
  def print_inline_image(path, height: 1)
27
- data = read_image_data(path)
55
+ data = read_image_data_for_protocol(path)
28
56
  return unless data
29
57
 
30
58
  encoded = [data].pack('m0')
31
- in_tmux? ? print_tmux_image(encoded, height) : print_iterm_image(encoded, height)
59
+
60
+ if kitty_graphics_supported?
61
+ in_tmux? ? print_tmux_kitty_image(encoded, height) : print_kitty_image(encoded, height)
62
+ else
63
+ in_tmux? ? print_tmux_iterm_image(encoded, height) : print_iterm_image(encoded, height)
64
+ end
32
65
  end
33
66
 
34
- def read_image_data(path)
67
+ def read_image_data_for_protocol(path)
35
68
  return nil unless File.exist?(path)
36
69
 
37
- File.binread(path)
70
+ data = File.binread(path)
71
+ return nil unless data
72
+
73
+ # Kitty protocol requires PNG format; convert GIF/JPEG if needed
74
+ if kitty_graphics_supported? && !png_data?(data)
75
+ convert_to_png(path)
76
+ else
77
+ data
78
+ end
38
79
  rescue IOError, SystemCallError
39
80
  nil
40
81
  end
41
82
 
42
- def print_tmux_image(encoded, height)
83
+ def png_data?(data)
84
+ # PNG files start with magic bytes: 137 80 78 71 13 10 26 10
85
+ data[0, 8]&.bytes == [137, 80, 78, 71, 13, 10, 26, 10]
86
+ end
87
+
88
+ def convert_to_png(path)
89
+ # Use sips (macOS) to convert to PNG
90
+ require 'tempfile'
91
+ temp = Tempfile.new(['emoji', '.png'])
92
+ temp.close
93
+
94
+ system('sips', '-s', 'format', 'png', path, '--out', temp.path,
95
+ out: File::NULL, err: File::NULL)
96
+
97
+ return nil unless File.exist?(temp.path) && File.size(temp.path).positive?
98
+
99
+ File.binread(temp.path)
100
+ ensure
101
+ temp&.unlink
102
+ end
103
+
104
+ # iTerm2 protocol methods
105
+ def print_tmux_iterm_image(encoded, height)
43
106
  fmt = "\ePtmux;\e\e]1337;File=inline=1;preserveAspectRatio=0;" \
44
107
  "size=%<size>d;height=%<height>d:%<data>s\a\e\\\n "
45
108
  printf fmt, size: encoded.length, height: height, data: encoded
@@ -49,6 +112,26 @@ module Slk
49
112
  printf "\e]1337;File=inline=1;height=%<height>d:%<data>s\a", height: height, data: encoded
50
113
  end
51
114
 
115
+ # Kitty graphics protocol methods (Ghostty, Kitty)
116
+ # a=T: transmit and display, q=1: suppress OK response, f=100: PNG, r=rows, m=0: no more chunks
117
+ def print_kitty_image(encoded, height)
118
+ printf "\e_Ga=T,q=1,f=100,r=%<height>d,m=0;%<data>s\e\\", height: height, data: encoded
119
+ end
120
+
121
+ # Kitty graphics with Unicode placeholders for tmux (images clear/scroll with text)
122
+ # See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
123
+ def print_tmux_kitty_image(encoded, _height)
124
+ @kitty_image_id ||= 30
125
+ @kitty_image_id = (@kitty_image_id % 255) + 1
126
+ image_id = @kitty_image_id
127
+
128
+ # tmux passthrough: transmit image with Unicode placeholder mode (U=1), 2 cols x 1 row
129
+ $stdout.print "\ePtmux;\e\e_Ga=T,U=1,q=1,f=100,i=#{image_id},c=2,r=1,m=0;#{encoded}\e\e\\\e\\"
130
+ # Output placeholder cells (U+10EEEE + row/col diacritics) with foreground color = image_id
131
+ $stdout.print "\e[38;5;#{image_id}m\u{10EEEE}\u0305\u0305\u{10EEEE}\u0305\u030D\e[39m"
132
+ $stdout.flush
133
+ end
134
+
52
135
  # Print inline image with name on same line
53
136
  # Handles tmux cursor positioning to keep image and text on same line
54
137
  def print_inline_image_with_text(path, text, height: 1) # rubocop:disable Naming/PredicateMethod
@@ -56,11 +139,12 @@ module Slk
56
139
 
57
140
  print_inline_image(path, height: height)
58
141
 
59
- if in_tmux?
60
- # tmux: image ends with \n + space, cursor on next line
142
+ if in_tmux? && !kitty_graphics_supported?
143
+ # iTerm2 in tmux: image ends with \n + space, cursor on next line
61
144
  # Move up 1 line, right 3 cols (past image), then print text
62
145
  print "\e[1A\e[3C#{text}\n"
63
146
  else
147
+ # Direct output or Unicode placeholders (regular text, just print after)
64
148
  puts " #{text}"
65
149
  end
66
150
 
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.1'
5
5
  end