ace-tmux 0.14.3 → 0.17.2

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.
@@ -7,76 +7,141 @@ module Ace
7
7
  module Tmux
8
8
  module CLI
9
9
  module Commands
10
- # List available tmux presets
11
10
  class List < Ace::Support::Cli::Command
12
11
  include Ace::Support::Cli::Base
13
12
 
14
13
  desc <<~DESC.strip
15
- List available tmux presets
14
+ List live tmux sessions, windows, or panes
16
15
 
17
- SYNTAX:
18
- ace-tmux list [TYPE]
16
+ Default behavior lists panes in the resolved current window.
19
17
 
20
- EXAMPLES:
21
-
22
- # List all presets
23
- $ ace-tmux list
24
-
25
- # List only session presets
26
- $ ace-tmux list sessions
27
-
28
- # List only window presets
29
- $ ace-tmux list windows
18
+ Scope flags:
19
+ --all-panes List panes across the resolved session
20
+ --windows List windows in the resolved session
21
+ --sessions List all tmux sessions
30
22
  DESC
31
23
 
32
24
  example [
33
- " # List all presets",
34
- "sessions # List session presets",
35
- "windows # List window presets",
36
- "panes # List pane presets"
25
+ " # List panes in the current window",
26
+ "--all-panes # List panes across the current session",
27
+ "--windows # List windows in the current session",
28
+ "--sessions # List tmux sessions",
29
+ "--session dev --window work # List panes in an explicit window"
37
30
  ]
38
31
 
39
- argument :type, required: false, desc: "Preset type: sessions, windows, or panes"
32
+ option :session, type: :string, aliases: %w[-s], desc: "Target session name"
33
+ option :window, type: :string, aliases: %w[-w], desc: "Target window name, index, or tmux window id (@2)"
34
+ option :all_panes, type: :boolean, desc: "List panes across the resolved session"
35
+ option :windows, type: :boolean, desc: "List windows in the resolved session"
36
+ option :sessions, type: :boolean, desc: "List all tmux sessions"
37
+
38
+ def call(**options)
39
+ validate_scope!(options)
40
+
41
+ control = Organisms::ControlSurface.new
42
+ scope = selected_scope(options)
43
+
44
+ case scope
45
+ when :sessions
46
+ print_sessions(control.list_sessions)
47
+ when :windows
48
+ print_windows(control.list_windows(session: options[:session]))
49
+ when :all_panes
50
+ print_panes(control.list_panes(session: options[:session], all_panes: true))
51
+ else
52
+ print_panes(control.list_panes(session: options[:session], window: options[:window]))
53
+ end
54
+ rescue Ace::Tmux::Error => e
55
+ raise Ace::Support::Cli::Error, e.message
56
+ end
57
+
58
+ private
59
+
60
+ def validate_scope!(options)
61
+ scopes = %i[all_panes windows sessions].select { |key| options[key] }
62
+ raise Ace::Support::Cli::Error, "Use only one of --all-panes, --windows, or --sessions" if scopes.length > 1
63
+
64
+ if options[:sessions] && (options[:session] || options[:window])
65
+ raise Ace::Support::Cli::Error, "--sessions does not accept --session or --window"
66
+ end
67
+
68
+ if options[:windows] && options[:window]
69
+ raise Ace::Support::Cli::Error, "--windows does not accept --window"
70
+ end
71
+
72
+ if options[:all_panes] && options[:window]
73
+ raise Ace::Support::Cli::Error, "--all-panes does not accept --window"
74
+ end
75
+ end
76
+
77
+ def selected_scope(options)
78
+ return :sessions if options[:sessions]
79
+ return :windows if options[:windows]
80
+ return :all_panes if options[:all_panes]
81
+
82
+ :panes
83
+ end
40
84
 
41
- option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
42
- option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
85
+ def print_sessions(rows)
86
+ return puts("No tmux sessions found.") if rows.empty?
43
87
 
44
- def call(type: nil, **options)
45
- preset_loader = Molecules::PresetLoader.new(
46
- gem_root: Tmux.gem_root
88
+ table = build_table(
89
+ %w[SESSION ATTACHED WINDOWS],
90
+ rows.map { |row| [row[:session], row[:attached_clients].to_s, row[:window_count].to_s] }
47
91
  )
92
+ puts table
93
+ end
48
94
 
49
- if type
50
- validate_type!(type)
51
- presets = preset_loader.list(type)
52
- display_type(type, presets, verbose: options[:verbose])
53
- else
54
- all = preset_loader.list_all
55
- all.each do |preset_type, presets|
56
- display_type(preset_type, presets, verbose: options[:verbose])
95
+ def print_windows(rows)
96
+ return puts("No windows found.") if rows.empty?
97
+
98
+ table = build_table(
99
+ %w[A WINDOW SESSION:INDEX NAME PANES],
100
+ rows.map do |row|
101
+ [
102
+ row[:active] ? "*" : " ",
103
+ row[:id],
104
+ "#{row[:session]}:#{row[:index]}",
105
+ row[:name],
106
+ row[:pane_count].to_s
107
+ ]
57
108
  end
58
- puts "No presets found." if all.empty?
59
- end
109
+ )
110
+ puts table
60
111
  end
61
112
 
62
- private
113
+ def print_panes(rows)
114
+ return puts("No panes found.") if rows.empty?
115
+
116
+ table = build_table(
117
+ %w[A PANE TARGET CMD CWD],
118
+ rows.map do |row|
119
+ [
120
+ row[:active] ? "*" : " ",
121
+ row[:pane_id],
122
+ row[:target],
123
+ row[:command],
124
+ row[:cwd]
125
+ ]
126
+ end
127
+ )
128
+ puts table
129
+ end
63
130
 
64
- def validate_type!(type)
65
- unless Molecules::PresetLoader::PRESET_TYPES.include?(type)
66
- raise Ace::Support::Cli::Error.new(
67
- "Unknown preset type: #{type}. Valid types: #{Molecules::PresetLoader::PRESET_TYPES.join(", ")}"
68
- )
131
+ def build_table(headers, rows)
132
+ widths = headers.each_index.map do |index|
133
+ ([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
69
134
  end
135
+
136
+ lines = []
137
+ lines << render_row(headers, widths)
138
+ lines << render_row(widths.map { |width| "-" * width }, widths)
139
+ rows.each { |row| lines << render_row(row, widths) }
140
+ lines.join("\n")
70
141
  end
71
142
 
72
- def display_type(type, presets, verbose: false)
73
- puts "#{type}:"
74
- if presets.empty?
75
- puts " (none)"
76
- else
77
- presets.each { |name| puts " #{name}" }
78
- end
79
- puts
143
+ def render_row(values, widths)
144
+ values.each_with_index.map { |value, index| value.to_s.ljust(widths[index]) }.join(" ").rstrip
80
145
  end
81
146
  end
82
147
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+
6
+ module Ace
7
+ module Tmux
8
+ module CLI
9
+ module Commands
10
+ class ListPresets < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc <<~DESC.strip
14
+ List available tmux presets
15
+
16
+ Run without a type to list all preset groups, or pass one of:
17
+ sessions
18
+ windows
19
+ panes
20
+ DESC
21
+
22
+ example [
23
+ " # List all preset groups",
24
+ "sessions # List session presets",
25
+ "windows # List window presets",
26
+ "panes # List pane presets"
27
+ ]
28
+
29
+ argument :type, required: false, desc: "Preset type: sessions, windows, or panes"
30
+
31
+ def call(type: nil, **_options)
32
+ preset_loader = Molecules::PresetLoader.new(gem_root: Tmux.gem_root)
33
+
34
+ if type
35
+ validate_type!(type)
36
+ display_type(type, preset_loader.list(type))
37
+ return
38
+ end
39
+
40
+ all = preset_loader.list_all
41
+ all.each { |preset_type, presets| display_type(preset_type, presets) }
42
+ puts "No presets found." if all.empty?
43
+ end
44
+
45
+ private
46
+
47
+ def validate_type!(type)
48
+ return if Molecules::PresetLoader::PRESET_TYPES.include?(type)
49
+
50
+ raise Ace::Support::Cli::Error,
51
+ "Unknown preset type: #{type}. Valid types: #{Molecules::PresetLoader::PRESET_TYPES.join(", ")}"
52
+ end
53
+
54
+ def display_type(type, presets)
55
+ puts "#{type}:"
56
+ if presets.empty?
57
+ puts " (none)"
58
+ else
59
+ presets.each { |name| puts " #{name}" }
60
+ end
61
+ puts
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+
6
+ module Ace
7
+ module Tmux
8
+ module CLI
9
+ module Commands
10
+ class Send < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ DEFAULT_CAPTURE_LINES = 40
14
+ DEFAULT_CAPTURE_WAIT = 2.0
15
+
16
+ desc <<~DESC.strip
17
+ Send submitted commands, raw text, or named keys to a tmux pane
18
+
19
+ Bare --wait defaults to the interactive agent condition.
20
+ Bare --capture prints a post-send pane tail using default line and wait values.
21
+ DESC
22
+
23
+ example [
24
+ "--pane %1 --cmd 'bundle exec rake test'",
25
+ "--pane %8 --cmd 'Reply with exactly: pong' --wait --capture 20",
26
+ "--pane %1 --msg 'echo ready' --key Enter",
27
+ "--pane %1 --cmd 'echo done' --wait output --pattern done --capture 20"
28
+ ]
29
+
30
+ option :session, type: :string, aliases: %w[-s], desc: "Target session name"
31
+ option :window, type: :string, aliases: %w[-w], desc: "Target window name, index, or tmux window id (@2)"
32
+ option :pane, type: :string, aliases: %w[-p], desc: "Target pane id (%8), full pane target (dev:work.1), or current-window pane shorthand (.1)"
33
+ option :cmd, type: :string, aliases: %w[-c], desc: "Command text to send and submit with Enter"
34
+ option :msg, type: :string, aliases: %w[-m], repeat: true, desc: "Literal text chunk to send without Enter"
35
+ option :key, type: :string, aliases: %w[-k], repeat: true, desc: "Named key to send (for example Enter, C-c)"
36
+ option :capture, type: :string, default: false, optional_value: true, desc: "Capture pane output after send as [lines[:wait]]; bare --capture defaults to 40 lines"
37
+ option :wait, type: :string, default: false, optional_value: true, desc: "Wait after send as [agent|output|window-exists|window-active|pane-exists|pane-exited]; bare --wait defaults to agent"
38
+ option :pattern, type: :string, desc: "Pattern to match when waiting for output"
39
+ option :timeout, type: :string, aliases: %w[-t], default: Organisms::ControlSurface::DEFAULT_TIMEOUT.to_s, desc: "Wait timeout in seconds when --wait is used"
40
+ option :interval, type: :string, aliases: %w[-i], default: Organisms::ControlSurface::DEFAULT_INTERVAL.to_s, desc: "Wait polling interval in seconds when --wait is used"
41
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
42
+
43
+ def call(**options)
44
+ control = Organisms::ControlSurface.new
45
+ cmd = normalized_cmd(options)
46
+ messages = normalized_messages(options)
47
+ keys = normalized_keys(options)
48
+ wait_condition = normalized_wait_condition(options.fetch(:wait, false))
49
+
50
+ validate_payloads!(cmd: cmd, messages: messages, keys: keys)
51
+ validate_wait_condition!(wait_condition: wait_condition, pattern: options[:pattern]) if wait_condition
52
+
53
+ capture_spec = parse_capture_spec(
54
+ options.fetch(:capture, false),
55
+ default_wait: wait_condition ? 0.0 : DEFAULT_CAPTURE_WAIT
56
+ )
57
+ wait_lines = capture_spec ? capture_spec[:lines] : DEFAULT_CAPTURE_LINES
58
+ baseline_output = wait_baseline_for(control, options, wait_condition, lines: wait_lines)
59
+
60
+ control.send_sequence(**target_options(options), command: cmd, messages: messages, keys: keys)
61
+
62
+ if wait_condition
63
+ control.wait_for_condition(
64
+ **target_options(options),
65
+ condition: wait_condition,
66
+ pattern: options[:pattern],
67
+ timeout: Float(options.fetch(:timeout, Organisms::ControlSurface::DEFAULT_TIMEOUT.to_s)),
68
+ interval: Float(options.fetch(:interval, Organisms::ControlSurface::DEFAULT_INTERVAL.to_s)),
69
+ lines: wait_lines,
70
+ baseline_output: baseline_output,
71
+ require_change: wait_condition == "agent"
72
+ )
73
+ end
74
+
75
+ if capture_spec
76
+ sleep(capture_spec[:wait]) if capture_spec[:wait].positive?
77
+ output = control.capture_recent_output(**target_options(options), lines: capture_spec[:lines])
78
+ puts output
79
+ elsif !options[:quiet]
80
+ puts success_message(cmd: cmd, messages: messages, keys: keys, wait_condition: wait_condition)
81
+ end
82
+ rescue Ace::Tmux::Error => e
83
+ raise Ace::Support::Cli::Error, e.message
84
+ rescue ArgumentError
85
+ raise Ace::Support::Cli::Error, "--timeout and --interval must be numeric"
86
+ end
87
+
88
+ private
89
+
90
+ def target_options(options)
91
+ options.slice(:session, :window, :pane)
92
+ end
93
+
94
+ def normalized_cmd(options)
95
+ value = options[:cmd].to_s
96
+ return nil if value.strip.empty?
97
+
98
+ value
99
+ end
100
+
101
+ def normalized_messages(options)
102
+ Array(options[:msg]).map(&:to_s)
103
+ end
104
+
105
+ def normalized_keys(options)
106
+ Array(options[:key]).map(&:to_s).reject { |value| value.strip.empty? }
107
+ end
108
+
109
+ def validate_payloads!(cmd:, messages:, keys:)
110
+ raise Ace::Support::Cli::Error, "Provide at least one of --cmd, --msg, or --key" if cmd.nil? && messages.empty? && keys.empty?
111
+ raise Ace::Support::Cli::Error, "Use either --cmd or --msg, not both" if cmd && !messages.empty?
112
+ end
113
+
114
+ def parse_capture_spec(raw, default_wait: DEFAULT_CAPTURE_WAIT)
115
+ return nil if raw == false
116
+ return { lines: DEFAULT_CAPTURE_LINES, wait: default_wait } if raw.nil? || raw == true || raw.to_s.strip.empty?
117
+
118
+ value = raw.to_s.strip
119
+ parts = value.split(":", 2)
120
+
121
+ lines = Integer(parts[0])
122
+ wait = parts[1] ? Float(parts[1]) : default_wait
123
+ raise ArgumentError if lines <= 0 || wait.negative?
124
+
125
+ { lines: lines, wait: wait }
126
+ rescue ArgumentError
127
+ raise Ace::Support::Cli::Error, "Invalid --capture value '#{raw}'. Use [lines[:wait]] such as 40 or 40:2"
128
+ end
129
+
130
+ def normalized_wait_condition(raw)
131
+ return nil if raw == false
132
+ return "agent" if raw.nil? || raw == true || raw.to_s.strip.empty?
133
+
134
+ raw.to_s.strip
135
+ end
136
+
137
+ def validate_wait_condition!(wait_condition:, pattern:)
138
+ Molecules::WaitConditionValidator.validate!(condition: wait_condition, pattern: pattern)
139
+ rescue Ace::Tmux::ValidationError => e
140
+ raise Ace::Support::Cli::Error, e.message
141
+ end
142
+
143
+ def wait_baseline_for(control, options, wait_condition, lines:)
144
+ return nil unless %w[agent output].include?(wait_condition)
145
+
146
+ control.capture_recent_output(**target_options(options), lines: lines)
147
+ end
148
+
149
+ def success_message(cmd:, messages:, keys:, wait_condition:)
150
+ message =
151
+ if cmd
152
+ "Sent command"
153
+ elsif !messages.empty? && !keys.empty?
154
+ "Sent #{messages.length} message#{messages.length == 1 ? '' : 's'} and #{keys.length} key#{keys.length == 1 ? '' : 's'}"
155
+ elsif !messages.empty?
156
+ "Sent #{messages.length} message#{messages.length == 1 ? '' : 's'}"
157
+ else
158
+ "Sent #{keys.length} key#{keys.length == 1 ? '' : 's'}"
159
+ end
160
+
161
+ return message unless wait_condition
162
+
163
+ "#{message} and waited for #{wait_condition}"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+
6
+ module Ace
7
+ module Tmux
8
+ module CLI
9
+ module Commands
10
+ class Wait < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc <<~DESC.strip
14
+ Wait for a bounded tmux condition
15
+
16
+ Use --lines to control the pane tail observed by output and agent waits.
17
+ DESC
18
+
19
+ example [
20
+ "--pane %1 --for output --pattern 'Task context:' --lines 80",
21
+ "--pane %8 --for agent",
22
+ "--session dev --for window-active --window work-fs",
23
+ "--pane %1 --for pane-exited"
24
+ ]
25
+
26
+ option :for, type: :string, aliases: %w[-f], desc: "Condition: agent|output|window-exists|window-active|pane-exists|pane-exited"
27
+ option :session, type: :string, aliases: %w[-s], desc: "Target session name"
28
+ option :window, type: :string, aliases: %w[-w], desc: "Target window name, index, or tmux window id (@2)"
29
+ option :pane, type: :string, aliases: %w[-p], desc: "Target pane id (%8), full pane target (dev:work.1), or current-window pane shorthand (.1)"
30
+ option :pattern, type: :string, desc: "Pattern to match when waiting for output"
31
+ option :lines, type: :integer, aliases: %w[-n], default: Organisms::ControlSurface::DEFAULT_LINES, desc: "Number of lines to observe for output and agent waits"
32
+ option :timeout, type: :string, aliases: %w[-t], default: "10.0", desc: "Timeout in seconds"
33
+ option :interval, type: :string, aliases: %w[-i], default: "0.2", desc: "Polling interval in seconds"
34
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
35
+
36
+ def call(**options)
37
+ Organisms::ControlSurface.new.wait_for_condition(
38
+ condition: options[:for],
39
+ session: options[:session],
40
+ window: options[:window],
41
+ pane: options[:pane],
42
+ pattern: options[:pattern],
43
+ lines: options[:lines],
44
+ timeout: Float(options[:timeout]),
45
+ interval: Float(options[:interval])
46
+ )
47
+ puts "Condition met: #{options[:for]}" unless options[:quiet]
48
+ rescue Ace::Tmux::Error => e
49
+ raise Ace::Support::Cli::Error, e.message
50
+ rescue ArgumentError
51
+ raise Ace::Support::Cli::Error, "--timeout and --interval must be numeric"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -59,9 +59,8 @@ module Ace
59
59
  tmux: tmux_bin
60
60
  )
61
61
 
62
- effective_name = options[:name] || (options[:root] ? File.basename(options[:root]) : preset)
63
- puts "Adding window '#{effective_name}' (preset: #{preset})..." unless options[:quiet]
64
- manager.add_window(preset, session: options[:session], root: options[:root], name: options[:name])
62
+ puts "Adding window from preset '#{preset}'..." unless options[:quiet]
63
+ effective_name = manager.add_window(preset, session: options[:session], root: options[:root], name: options[:name])
65
64
  puts "Window '#{effective_name}' added." unless options[:quiet]
66
65
  rescue Ace::Tmux::PresetNotFoundError => e
67
66
  raise Ace::Support::Cli::Error.new(e.message)
data/lib/ace/tmux/cli.rb CHANGED
@@ -6,6 +6,12 @@ require_relative "../tmux"
6
6
  require_relative "cli/commands/start"
7
7
  require_relative "cli/commands/window"
8
8
  require_relative "cli/commands/list"
9
+ require_relative "cli/commands/list_presets"
10
+ require_relative "cli/commands/send"
11
+ require_relative "cli/commands/capture"
12
+ require_relative "cli/commands/wait"
13
+ require_relative "cli/commands/attach"
14
+ require_relative "cli/commands/detach"
9
15
 
10
16
  module Ace
11
17
  module Tmux
@@ -19,13 +25,26 @@ module Ace
19
25
  REGISTERED_COMMANDS = [
20
26
  ["start", "Start tmux session from preset"],
21
27
  ["window", "Add window to existing session"],
22
- ["list", "List available presets"]
28
+ ["list", "List live tmux sessions, windows, or panes"],
29
+ ["--list-presets", "List available tmux presets"],
30
+ ["send", "Send submitted commands, raw text, or named keys to a pane"],
31
+ ["capture", "Capture recent output from a pane"],
32
+ ["wait", "Wait for a bounded tmux condition"],
33
+ ["attach", "Attach to a tmux session"],
34
+ ["detach", "Detach clients from a tmux session"]
23
35
  ].freeze
24
36
 
25
37
  HELP_EXAMPLES = [
26
38
  "ace-tmux start dev # Launch dev session preset",
27
39
  "ace-tmux window cc --root ~/project # Add window to session",
28
- "ace-tmux list # Available presets"
40
+ "ace-tmux --list-presets windows # Available window presets",
41
+ "ace-tmux list # Panes in the current window",
42
+ "ace-tmux list --windows # Windows in the current session",
43
+ "ace-tmux send --pane %1 --cmd 'bundle exec rake test' # Send command",
44
+ "ace-tmux send --pane .1 --msg 'echo ready' --key Enter # Current-window pane shortcut",
45
+ "ace-tmux wait --session dev --for window-active --window work-fs # Wait for window",
46
+ "ace-tmux capture --pane %1 --lines 40 # Capture pane output",
47
+ "ace-tmux detach --session dev # Detach session clients"
29
48
  ].freeze
30
49
 
31
50
  # Start the CLI
@@ -40,6 +59,12 @@ module Ace
40
59
  register "start", CLI::Commands::Start.new
41
60
  register "window", CLI::Commands::Window.new
42
61
  register "list", CLI::Commands::List.new
62
+ register "--list-presets", CLI::Commands::ListPresets.new
63
+ register "send", CLI::Commands::Send.new
64
+ register "capture", CLI::Commands::Capture.new
65
+ register "wait", CLI::Commands::Wait.new
66
+ register "attach", CLI::Commands::Attach.new
67
+ register "detach", CLI::Commands::Detach.new
43
68
 
44
69
  # Register version command
45
70
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Models
6
+ class RuntimeTarget
7
+ attr_reader :session, :window, :pane, :source
8
+
9
+ def initialize(session: nil, window: nil, pane: nil, source:, raw_window_target: nil)
10
+ @session = presence(session)
11
+ @window = presence(window)
12
+ @pane = presence(pane)
13
+ @source = source.to_s
14
+ @raw_window_target = presence(raw_window_target)
15
+ end
16
+
17
+ def session_target
18
+ session
19
+ end
20
+
21
+ def window_target
22
+ return @raw_window_target if @raw_window_target
23
+ return nil unless session && window
24
+
25
+ "#{session}:#{window}"
26
+ end
27
+
28
+ def pane_target
29
+ return pane if pane&.start_with?("%")
30
+ return pane if pane&.start_with?("@")
31
+ return pane if pane&.include?(":")
32
+ return nil unless pane && window_target
33
+
34
+ "#{window_target}.#{pane}"
35
+ end
36
+
37
+ private
38
+
39
+ def presence(value)
40
+ normalized = value.to_s.strip
41
+ normalized.empty? ? nil : normalized
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end