tty-command-window 0.1.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.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Command
5
+ module Window
6
+ # Braille spinner used in block title bars.
7
+ class Spinner
8
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
9
+
10
+ def initialize
11
+ @index = 0
12
+ end
13
+
14
+ # Advance and return the next frame.
15
+ #
16
+ # @return [String]
17
+ def tick
18
+ frame = FRAMES[@index]
19
+ @index = (@index + 1) % FRAMES.length
20
+ frame
21
+ end
22
+
23
+ # @return [String] the current frame without advancing
24
+ def frame
25
+ FRAMES[@index]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Command
5
+ module Window
6
+ # Process-global owner of the signals this gem cares about
7
+ # (+WINCH+, +INT+, +TERM+, +HUP+).
8
+ #
9
+ # Ruby's +Signal.trap+ is a process singleton: two coordinators
10
+ # installing their own trap for the same signal will silently
11
+ # clobber each other and lose the host application's previous
12
+ # handler. TrapManager mediates that by installing exactly one
13
+ # trap per signal (the first time anyone subscribes) and
14
+ # multiplexing to a subscriber list.
15
+ #
16
+ # **Ownership contract.** Once a signal has been installed by
17
+ # TrapManager it remains installed for the life of the process
18
+ # (only {.reset!}, which is test-only, uninstalls). This avoids
19
+ # clobbering a host-application handler that a caller installed
20
+ # *after* our first subscribe (follow-up review P1-6): restoring
21
+ # the snapshot we captured at first-install time would silently
22
+ # overwrite the host's newer handler.
23
+ #
24
+ # Thread-safe. Trap-safe: the trap block reads a reference to
25
+ # the subscriber array under MRI's GVL and iterates a local
26
+ # snapshot; unsubscribe swaps the array reference so an
27
+ # in-flight trap keeps iterating the array it saw.
28
+ class TrapManager
29
+ @mutex = Mutex.new
30
+ @subscribers = {} # signal (String) => [callable, ...]
31
+ @originals = {} # signal (String) => Proc / String / nil returned by Signal.trap
32
+
33
+ class << self
34
+ # Register +callable+ as a handler for +signal+.
35
+ #
36
+ # @param signal [String, Symbol] e.g. "INT", "TERM", "WINCH"
37
+ # @param callable [#call] invoked as +callable.call(signal)+ in trap context
38
+ # @return [#call] the subscribed callable (pass back to {.unsubscribe})
39
+ def subscribe(signal, callable)
40
+ key = signal.to_s
41
+ @mutex.synchronize do
42
+ # Populate the subscriber list BEFORE installing the trap
43
+ # so a signal arriving between Signal.trap returning and
44
+ # the append cannot observe an empty list (follow-up
45
+ # review P1-7).
46
+ list = @subscribers[key] || []
47
+ @subscribers[key] = list + [callable]
48
+ install_once(key) unless @originals.key?(key)
49
+ end
50
+ callable
51
+ end
52
+
53
+ # Remove a previously subscribed handler.
54
+ #
55
+ # @return [void]
56
+ def unsubscribe(signal, callable)
57
+ key = signal.to_s
58
+ @mutex.synchronize do
59
+ list = @subscribers[key]
60
+ next if list.nil?
61
+
62
+ new_list = list.reject { |c| c.equal?(callable) }
63
+ # Keep the installed trap even when the subscriber list
64
+ # empties: uninstalling would restore the snapshot we took
65
+ # at first-install time and clobber a host handler
66
+ # installed after that snapshot (follow-up review P1-6).
67
+ # reset! (test-only) is the one path that uninstalls.
68
+ @subscribers[key] = new_list
69
+ end
70
+ end
71
+
72
+ # Uninstall every subscriber and restore original handlers.
73
+ # Intended for tests and emergency teardown.
74
+ def reset!
75
+ @mutex.synchronize do
76
+ # Materialize keys before iteration because uninstall mutates @originals.
77
+ @subscribers.keys.to_a.each { |key| uninstall(key) }
78
+ @subscribers.clear
79
+ end
80
+ end
81
+
82
+ # @api private for tests
83
+ def subscribed?(signal)
84
+ @mutex.synchronize { !(@subscribers[signal.to_s] || []).empty? }
85
+ end
86
+
87
+ private
88
+
89
+ def install_once(signal)
90
+ @originals[signal] = Signal.trap(signal) do
91
+ # Trap context: no mutex, no allocation-heavy work.
92
+ subs = @subscribers[signal] || []
93
+ subs.each do |sub|
94
+ sub.call(signal)
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ orig = @originals[signal]
99
+ orig.call if orig.respond_to?(:call)
100
+ end
101
+ rescue ArgumentError
102
+ # Reserved signal (e.g. running under a debugger). Leave @originals[signal] unset;
103
+ # subscribers still get called from any trap someone else might install.
104
+ @originals[signal] = nil
105
+ end
106
+
107
+ def uninstall(signal)
108
+ original = @originals.delete(signal)
109
+ Signal.trap(signal, original || "DEFAULT")
110
+ rescue ArgumentError
111
+ nil
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Command
5
+ module Window
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Command
5
+ module Window
6
+ # Immutable, validated configuration for a single windowed run.
7
+ #
8
+ # Built from the raw options hash the user passed to +run_windowed+
9
+ # by {.build}. Runner and Block consume named accessors on this
10
+ # object instead of dereferencing symbol keys, so the option
11
+ # contract is explicit.
12
+ #
13
+ # +Struct.new(..., keyword_init: true)+ is used (rather than
14
+ # +Data.define+) because +Data.define+ ships in Ruby 3.2 and the
15
+ # gemspec's +required_ruby_version+ floor is 3.1. Instances are
16
+ # frozen in {.build} for immutability.
17
+ WindowOptions = Struct.new(
18
+ :lines, :title, :on_exit, :capture, :capture_max_bytes,
19
+ :scrollback, :output_log, :interactive,
20
+ keyword_init: true
21
+ ) do
22
+ # @param window_options [Hash] the raw window options subset
23
+ # returned by {Window.split_options}
24
+ # @param cmd [TTY::Command::Cmd] used to derive a default title
25
+ # @return [WindowOptions]
26
+ def self.build(window_options, cmd)
27
+ lines = Integer(window_options.fetch(:lines, DEFAULT_LINES))
28
+ raise ArgumentError, "lines must be >= 1" if lines < 1
29
+
30
+ on_exit = validated_enum(window_options, :on_exit, ON_EXIT_MODES, :freeze)
31
+ capture = validated_enum(window_options, :capture, CAPTURE_MODES, :raw)
32
+
33
+ capture_max_bytes = window_options.fetch(:capture_max_bytes, DEFAULT_CAPTURE_MAX_BYTES)
34
+ unless capture_max_bytes.nil?
35
+ capture_max_bytes = Integer(capture_max_bytes)
36
+ raise ArgumentError, "capture_max_bytes must be >= 1 or nil" if capture_max_bytes < 1
37
+ end
38
+
39
+ Window.validate_on_unavailable!(window_options)
40
+
41
+ title = window_options.fetch(:title, nil)
42
+ title = cmd.to_command if title.nil?
43
+
44
+ new(
45
+ lines: lines,
46
+ title: title,
47
+ on_exit: on_exit,
48
+ capture: capture,
49
+ capture_max_bytes: capture_max_bytes,
50
+ scrollback: Integer(window_options.fetch(:scrollback, DEFAULT_SCROLLBACK)),
51
+ output_log: window_options[:output_log],
52
+ interactive: window_options[:interactive] == true
53
+ ).freeze
54
+ end
55
+
56
+ def self.validated_enum(window_options, key, modes, default)
57
+ value = window_options.fetch(key, default)
58
+ unless modes.include?(value)
59
+ raise ArgumentError, "#{key} must be one of #{modes.join(', ')}"
60
+ end
61
+
62
+ value
63
+ end
64
+ private_class_method :validated_enum
65
+
66
+ def interactive?
67
+ interactive
68
+ end
69
+
70
+ def capture_screen?
71
+ capture == :screen
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "tty/command"
5
+
6
+ require_relative "window/version"
7
+ require_relative "window/ansi"
8
+ require_relative "window/emulator"
9
+ require_relative "window/spinner"
10
+ require_relative "window/child_session"
11
+ require_relative "window/block"
12
+ require_relative "window/trap_manager"
13
+ require_relative "window/coordinator"
14
+ require_relative "window/input_router"
15
+ require_relative "window/window_options"
16
+ require_relative "window/runner"
17
+ require_relative "window/integration"
18
+
19
+ module TTY
20
+ class Command
21
+ # Namespace for the windowed-run extension.
22
+ #
23
+ # Requiring this file adds {TTY::Command#run_windowed} and
24
+ # {TTY::Command#run_windowed!} to +TTY::Command+.
25
+ module Window
26
+ DEFAULT_LINES = 5
27
+ DEFAULT_SCROLLBACK = 10_000
28
+ DEFAULT_CAPTURE_MAX_BYTES = 10 * 1024 * 1024
29
+
30
+ # Option keys consumed by run_windowed and stripped before any
31
+ # delegation to plain tty-command.
32
+ OPTION_KEYS = %i[
33
+ lines title on_exit scrollback output_log interactive capture
34
+ capture_max_bytes output window width on_unavailable tty
35
+ ].freeze
36
+
37
+ ON_EXIT_MODES = %i[freeze dump_on_failure collapse].freeze
38
+ CAPTURE_MODES = %i[raw stripped screen].freeze
39
+ ON_UNAVAILABLE_MODES = %i[fallback raise].freeze
40
+
41
+ # Raised by +run_windowed+ / +run_windowed!+ when
42
+ # +on_unavailable: :raise+ is set and the environment cannot support
43
+ # windowed rendering (no TTY / PTY, on Windows, or in dry-run mode).
44
+ class Unavailable < StandardError; end
45
+
46
+ class << self
47
+ # Treat non-TTY outputs as terminals.
48
+ #
49
+ # @api private Intended for test suites that render into a StringIO.
50
+ # Prefer per-call +window: true+ or the scoped
51
+ # {.with_assume_tty} block form; unscoped writes leak across
52
+ # examples under +config.order = :random+.
53
+ # @return [Boolean]
54
+ attr_accessor :assume_tty
55
+
56
+ # Set {.assume_tty} for the duration of the block and restore the
57
+ # previous value on exit — including on exception.
58
+ #
59
+ # @api private
60
+ # @param value [Boolean]
61
+ # @yieldreturn [Object] the block's return value is returned
62
+ def with_assume_tty(value = true) # rubocop:disable Style/OptionalBooleanParameter
63
+ previous = assume_tty
64
+ self.assume_tty = value
65
+ yield
66
+ ensure
67
+ self.assume_tty = previous
68
+ end
69
+
70
+ # @return [Boolean] true when running on Windows (no PTY support)
71
+ def windows?
72
+ !!(RbConfig::CONFIG["host_os"] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/)
73
+ end
74
+
75
+ # @return [Boolean] true when the PTY library is usable
76
+ def pty_available?
77
+ return @pty_available unless @pty_available.nil?
78
+
79
+ @pty_available =
80
+ begin
81
+ require "pty"
82
+ true
83
+ rescue LoadError
84
+ false
85
+ end
86
+ end
87
+
88
+ # Whether a windowed run can render onto the given IO.
89
+ #
90
+ # @param output [IO]
91
+ # @return [Boolean]
92
+ def renderable?(output)
93
+ return false if windows? || !pty_available?
94
+
95
+ assume_tty || (output.respond_to?(:tty?) && output.tty?)
96
+ end
97
+
98
+ # Strip ANSI escape sequences (CSI, OSC, DCS, simple escapes) from a string.
99
+ #
100
+ # @param text [String]
101
+ # @return [String]
102
+ def strip_ansi(text)
103
+ ANSI.strip(text)
104
+ end
105
+ end
106
+
107
+ self.assume_tty = false
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tty/command/window"
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tty-command-window
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Matyas
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pastel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: tty-command
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: unicode-display_width
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: '4.0'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '2.0'
58
+ - - "<"
59
+ - !ruby/object:Gem::Version
60
+ version: '4.0'
61
+ description: 'Extends tty-command with run_windowed: the child process runs in a PTY
62
+ that reports N rows, its output (including cursor-movement escape codes) is interpreted
63
+ by a built-in terminal emulator and rendered as a static N-line block that streams
64
+ in place. Supports stacked concurrent windows, interactive stdin with focus switching,
65
+ resize handling and failure dumps.'
66
+ email:
67
+ - michal.matyas@helpling.com
68
+ executables: []
69
+ extensions: []
70
+ extra_rdoc_files: []
71
+ files:
72
+ - CHANGELOG.md
73
+ - LICENSE.txt
74
+ - README.md
75
+ - lib/tty-command-window.rb
76
+ - lib/tty/command/window.rb
77
+ - lib/tty/command/window/ansi.rb
78
+ - lib/tty/command/window/block.rb
79
+ - lib/tty/command/window/child_session.rb
80
+ - lib/tty/command/window/coordinator.rb
81
+ - lib/tty/command/window/emulator.rb
82
+ - lib/tty/command/window/input_router.rb
83
+ - lib/tty/command/window/integration.rb
84
+ - lib/tty/command/window/runner.rb
85
+ - lib/tty/command/window/spinner.rb
86
+ - lib/tty/command/window/trap_manager.rb
87
+ - lib/tty/command/window/version.rb
88
+ - lib/tty/command/window/window_options.rb
89
+ homepage: https://github.com/helpling/tty-command-window
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/helpling/tty-command-window
94
+ source_code_uri: https://github.com/helpling/tty-command-window
95
+ changelog_uri: https://github.com/helpling/tty-command-window/blob/main/CHANGELOG.md
96
+ rubygems_mfa_required: 'true'
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 3.1.0
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.4.10
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: Run commands with tty-command inside a live, fixed-height terminal window
116
+ test_files: []