tty-command-window 0.1.0 → 0.2.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: 2b39102b7bfbae84d31acca3a966ca18b2553381a5dc2085a13bd98308959ba0
4
- data.tar.gz: 75928be1bb72d1e7dfdd43e5e0b6678089f4c698f215fe6ec51b821cc07915cb
3
+ metadata.gz: e113fbc7a82f69f888cf1b28b007f526077c813bddbdc5d81be332f47f8b95d5
4
+ data.tar.gz: 529b3ea936fdcfe996fa199bdf4314fad0833d1d1ab4154afb788a1073c78358
5
5
  SHA512:
6
- metadata.gz: 3f9b8bad3b0255f783a37cfa94820df78b4a11841ca12e94331fd372ded05dfe1ea46b1d46fb974d61fe16d18a70befceff5a592f03d11acc2f07bde83f97de0
7
- data.tar.gz: 957da48ba1ffcc5a4776331975ae4f12e015a82cb9be1fe32dff96c46fd9d11cb7c5797c85cd31b7333b6190ada92ae03a0ace1b97fcadc5c7e8332ef8747b49
6
+ metadata.gz: 84e815c903062e5e30b68f998b1fcfd31d722a21738323bf867062015958bf09c3cf20da729ed60dd255e04e630eb482cf030b970e47d16350fbec5db8dde387
7
+ data.tar.gz: 68ddebea7c5e5a164fa9db3b445dae0d3c3728301c9c340388519575704de769356027f4a1f9e5c810ef46181407c06a66612d43bdd6b357cd44c49f475bdb7d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - Step windows: `TTY::Command::Window::Step.open(title:, lines:, ...)` opens a
13
+ caller-managed window whose lifecycle spans multiple commands. Run commands
14
+ into it with `cmd.run_windowed("...", window: step)` (or the `step.run` /
15
+ `step.run!` sugar), append plain lines with `step.log`, rename the title bar
16
+ with `step.retitle`, and close it with `step.finish(success:)`. The title
17
+ bar doubles as a progress line. A block form finishes automatically.
18
+ - `on_exit: :collapse_or_dump` — on success the window is replaced by its
19
+ one-line `✔ title • elapsed` summary as permanent scrolled output; on
20
+ failure the output history is dumped. Sequential steps leave a compact
21
+ checklist behind. This is the default mode for step windows.
22
+ - `dump_lines:` — caps how many history lines a failure dump prints; the
23
+ head is replaced by a truncation marker. Default: unlimited.
24
+ - When windowed rendering is unavailable (no TTY, Windows, no PTY),
25
+ `Step.open` returns a `PlainStep` with the same interface: a start marker
26
+ and `✔`/`✖` summary line as plain text, with command output streamed in
27
+ full between them — calling code needs no branch, and CI logs get a
28
+ readable checklist.
29
+
30
+ ## [0.1.0] - 2026-08-12
31
+
10
32
  ### Changed
11
33
 
12
34
  - **Renamed the `tty:` option to `window:`**. The old name was ambiguous
@@ -49,6 +71,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
49
71
  of the `assume_tty` testing aid, restores the previous value on exit.
50
72
  The `assume_tty` accessor is now marked `@api private`.
51
73
 
52
- ## [0.1.0] - Unreleased
53
-
54
74
  Initial release.
data/README.md CHANGED
@@ -107,6 +107,45 @@ end.each(&:join)
107
107
 
108
108
  ![three concurrent windows](docs/demo-multi.gif)
109
109
 
110
+ ### Step windows
111
+
112
+ A step is a window you open yourself, feed several commands (and plain log
113
+ lines), and close when the logical unit of work is done — the shape of a
114
+ step in a CLI progress checklist:
115
+
116
+ ```ruby
117
+ step = TTY::Command::Window::Step.open(title: "starting containers", lines: 8)
118
+ cmd = TTY::Command.new(printer: :null)
119
+
120
+ cmd.run_windowed("docker compose pull", window: step)
121
+ step.log "images pulled, starting"
122
+ result = cmd.run_windowed!("docker compose up -d", window: step)
123
+
124
+ step.finish(success: result.success?)
125
+ ```
126
+
127
+ The title bar doubles as the progress line: spinner while open, then
128
+ `✔ starting containers • 12.3s`. Steps default to `on_exit: :collapse_or_dump`:
129
+ on success the window is replaced by that one-line summary as permanent
130
+ output (sequential steps leave a compact checklist behind); on failure the
131
+ full output history is dumped — cap it with `dump_lines: 200`. `step.run` /
132
+ `step.run!` are sugar for `run_windowed(..., window: step)`, and a block form
133
+ finishes the step automatically:
134
+
135
+ ```ruby
136
+ TTY::Command::Window::Step.open(title: "deps") do |step|
137
+ step.run(cmd, "bundle install")
138
+ step.run(cmd, "yarn install")
139
+ end
140
+ ```
141
+
142
+ Geometry and end-of-run behavior (`lines:`, `title:`, `on_exit:`,
143
+ `scrollback:`, `dump_lines:`) are fixed at `Step.open` time; per-run calls
144
+ accept only `capture:`, `capture_max_bytes:` and `output_log:`. Without a
145
+ TTY, `Step.open` returns a `PlainStep` with the same interface — plain
146
+ start/summary marker lines with full streamed output between them, so the
147
+ same code produces a readable CI log.
148
+
110
149
  ### Interactive commands
111
150
 
112
151
  ```ruby
@@ -22,15 +22,19 @@ module TTY
22
22
  # @param emulator [Emulator]
23
23
  # @param title [String, false] title text; false disables the title bar
24
24
  # @param lines [Integer] window height (emulator rows)
25
- # @param on_exit [Symbol] :freeze, :dump_on_failure or :collapse
25
+ # @param on_exit [Symbol] :freeze, :dump_on_failure, :collapse or
26
+ # :collapse_or_dump
26
27
  # @param interactive [Boolean] eligible for stdin focus
27
- def initialize(emulator:, title:, lines:, on_exit:, interactive: false)
28
+ # @param dump_lines [Integer, nil] cap on history lines printed when
29
+ # dumping on failure; nil dumps everything
30
+ def initialize(emulator:, title:, lines:, on_exit:, interactive: false, dump_lines: nil)
28
31
  @emulator = emulator
29
32
  @title_enabled = title != false
30
33
  @title_text = title.is_a?(String) ? title : ""
31
34
  @lines = lines
32
35
  @on_exit = on_exit
33
36
  @interactive = interactive
37
+ @dump_lines = dump_lines
34
38
  @status = :running
35
39
  @started_at = Block.clock
36
40
  @runtime = nil
@@ -77,12 +81,21 @@ module TTY
77
81
  end
78
82
 
79
83
  def collapsed?
80
- done? && @on_exit == :collapse
84
+ done? && (@on_exit == :collapse || (@on_exit == :collapse_or_dump && success?))
81
85
  end
82
86
 
83
87
  # Whether finalization should replace this block with its full history.
84
88
  def dump_on_finalize?
85
- failure? && @on_exit == :dump_on_failure
89
+ failure? && %i[dump_on_failure collapse_or_dump].include?(@on_exit)
90
+ end
91
+
92
+ # Whether finalization should replace this block with its one-line
93
+ # summary as permanent scrolled output. Distinct from {#collapsed?}
94
+ # (which keeps the line inside the live region) so that sequential
95
+ # step windows leave a scrolling checklist behind instead of an
96
+ # ever-growing region.
97
+ def retire_on_finalize?
98
+ success? && @on_exit == :collapse_or_dump
86
99
  end
87
100
 
88
101
  # Rendered height in terminal lines given the current state.
@@ -112,9 +125,27 @@ module TTY
112
125
  @mutex.synchronize { @emulator.full_text }
113
126
  end
114
127
 
128
+ # Replace the title-bar text.
129
+ def retitle(title)
130
+ @mutex.synchronize { @title_text = title.to_s }
131
+ end
132
+
115
133
  # Plain title line plus history, used when dumping on failure.
134
+ # Honors the +dump_lines+ cap: only the trailing lines are kept,
135
+ # with a truncation marker in their place.
116
136
  def dump_text(pastel)
117
- "#{status_glyph(pastel)} #{@title_text} #{pastel.dim("• #{elapsed_text}")}\n#{full_text}"
137
+ summary = "#{status_glyph(pastel)} #{@title_text} #{pastel.dim("• #{elapsed_text}")}"
138
+ "#{summary}\n#{capped_history(pastel)}"
139
+ end
140
+
141
+ # The one-line summary written as permanent output when a
142
+ # :collapse_or_dump block finishes successfully.
143
+ #
144
+ # @param width [Integer] terminal width
145
+ # @param pastel [Pastel::Delegator]
146
+ # @return [String]
147
+ def summary_line(width:, pastel:)
148
+ @mutex.synchronize { title_line(width, pastel, "") }
118
149
  end
119
150
 
120
151
  # Resize the emulator to a new terminal width. PTY resize is
@@ -125,15 +156,21 @@ module TTY
125
156
 
126
157
  private
127
158
 
159
+ def capped_history(pastel)
160
+ text = full_text
161
+ return text unless @dump_lines
162
+
163
+ lines = text.split("\n", -1)
164
+ return text if lines.length <= @dump_lines
165
+
166
+ marker = pastel.dim(
167
+ "… earlier output truncated (showing last #{@dump_lines} of #{lines.length} lines)"
168
+ )
169
+ ([marker] + lines.last(@dump_lines)).join("\n")
170
+ end
171
+
128
172
  def elapsed_text
129
- seconds = @runtime || (Block.clock - @started_at)
130
- if seconds >= 3600
131
- format("%<h>dh %<m>02dm", h: seconds / 3600, m: (seconds % 3600) / 60)
132
- elsif seconds >= 60
133
- format("%<m>dm %<s>02ds", m: seconds / 60, s: seconds % 60)
134
- else
135
- format("%<s>.1fs", s: seconds)
136
- end
173
+ Window.format_elapsed(@runtime || (Block.clock - @started_at))
137
174
  end
138
175
 
139
176
  def status_glyph(pastel)
@@ -106,6 +106,10 @@ module TTY
106
106
  wipe_region
107
107
  @blocks.delete(block)
108
108
  write_permanent(block.dump_text(@pastel))
109
+ elsif block.retire_on_finalize?
110
+ wipe_region
111
+ @blocks.delete(block)
112
+ write_permanent(block.summary_line(width: width, pastel: @pastel))
109
113
  end
110
114
  # Drop the session unconditionally: the child has been reaped by
111
115
  # Runner and its PID is no longer safe to signal. Leaving stale
@@ -121,6 +125,23 @@ module TTY
121
125
  join_render_thread if drained
122
126
  end
123
127
 
128
+ # Attach a child session to an already-registered block so signal
129
+ # forwarding and PTY resize reach it. Used by step windows, whose
130
+ # blocks outlive any single command.
131
+ #
132
+ # @param block [Block]
133
+ # @param session [ChildSession]
134
+ def attach_session(block, session)
135
+ @mutex.synchronize { @sessions[block] = session }
136
+ end
137
+
138
+ # Detach the session attached via {#attach_session}.
139
+ #
140
+ # @param block [Block]
141
+ def detach_session(block)
142
+ @mutex.synchronize { @sessions.delete(block) }
143
+ end
144
+
124
145
  # Request a repaint on the next frame (cheap, lock-free).
125
146
  def mark_dirty
126
147
  @dirty = true
@@ -159,9 +180,12 @@ module TTY
159
180
  end
160
181
 
161
182
  # Must be called while holding @mutex; the caller joins the render
162
- # thread outside the lock afterwards.
183
+ # thread outside the lock afterwards. When every block was retired
184
+ # or dumped the region is already empty and the cursor sits at
185
+ # column 0 of a fresh line — adding a newline there would leave a
186
+ # blank line between sequential step windows.
163
187
  def drain
164
- @output.write("\r\n#{SHOW_CURSOR}")
188
+ @output.write("#{"\r\n" if @painted_height.positive?}#{SHOW_CURSOR}")
165
189
  @blocks.clear
166
190
  @sessions.clear
167
191
  @painted_height = 0
@@ -67,12 +67,23 @@ module TTY
67
67
  execute_windowed(args, raise_on_error: false, &)
68
68
  end
69
69
 
70
+ # Option keys that may accompany +window: <step>+; everything else
71
+ # is fixed at Step.open time.
72
+ STEP_RUN_KEYS = %i[window capture capture_max_bytes output_log].freeze
73
+
70
74
  private
71
75
 
72
76
  def execute_windowed(args, raise_on_error:, &block)
73
77
  window_options, plain_args = Window.split_options(args)
74
78
  Window.apply_tty_alias!(window_options)
75
79
  Window.validate_on_unavailable!(window_options)
80
+
81
+ target = window_options[:window]
82
+ if target.is_a?(Step) || target.is_a?(PlainStep)
83
+ return execute_in_step(target, window_options, plain_args,
84
+ raise_on_error: raise_on_error, &block)
85
+ end
86
+
76
87
  output = window_options[:output] || printer.output
77
88
 
78
89
  unless windowed_renderable?(window_options, output)
@@ -94,6 +105,39 @@ module TTY
94
105
  result
95
106
  end
96
107
 
108
+ # Run a command inside an existing step window. The step's block is
109
+ # reused (the step stays open afterwards); only per-run options are
110
+ # accepted here — geometry and end-of-run behavior were fixed when
111
+ # the step was opened.
112
+ def execute_in_step(step, window_options, plain_args, raise_on_error:, &block)
113
+ invalid = window_options.keys - STEP_RUN_KEYS
114
+ if invalid.any?
115
+ raise ArgumentError,
116
+ "options not allowed with window: <step>: #{invalid.join(', ')} " \
117
+ "(set them when opening the step)"
118
+ end
119
+ raise ArgumentError, "cannot run a command in a finished step" unless step.active?
120
+
121
+ if step.is_a?(PlainStep) || dry_run?
122
+ tee = lambda do |chunk, err|
123
+ step.write_output(chunk) if chunk && step.is_a?(PlainStep)
124
+ block&.call(chunk, err)
125
+ end
126
+ return raise_on_error ? run(*plain_args, &tee) : run!(*plain_args, &tee)
127
+ end
128
+
129
+ cmd = build_cmd(plain_args)
130
+ options = Window.normalize_options(
131
+ window_options.except(:window).merge(lines: step.lines, title: false),
132
+ cmd
133
+ )
134
+ result = Runner.new(cmd, options, step.coordinator, step: step, &block).run!
135
+
136
+ raise ExitError.new(cmd.to_command, result) if raise_on_error && result.failure?
137
+
138
+ result
139
+ end
140
+
97
141
  # BOUNDARY: this call relies on +TTY::Command#command+, which is
98
142
  # marked +@api private+ upstream. Reimplementing tty-command's
99
143
  # argument parsing would couple us to more internals than we save,
@@ -21,11 +21,15 @@ module TTY
21
21
  # @param cmd [TTY::Command::Cmd]
22
22
  # @param options [WindowOptions] validated window options
23
23
  # @param coordinator [Coordinator]
24
+ # @param step [Step, nil] when given, the command renders into the
25
+ # step's existing block instead of a block of its own; the step
26
+ # stays open after the command finishes
24
27
  # @param stream_block [Proc, nil] yields (chunk, nil) like tty-command
25
- def initialize(cmd, options, coordinator, &stream_block)
28
+ def initialize(cmd, options, coordinator, step: nil, &stream_block)
26
29
  @cmd = cmd
27
30
  @options = options
28
31
  @coordinator = coordinator
32
+ @step = step
29
33
  @stream_block = stream_block
30
34
  end
31
35
 
@@ -35,19 +39,24 @@ module TTY
35
39
  rows = @options.lines
36
40
  width = @coordinator.width
37
41
 
38
- emulator = Emulator.new(
39
- rows: rows, cols: width,
40
- scrollback_limit: @options.scrollback,
41
- responder: ->(reply) { @session&.write(reply) }
42
- )
43
- @block = Block.new(
44
- emulator: emulator,
45
- title: @options.title,
46
- lines: rows,
47
- on_exit: @options.on_exit,
48
- interactive: @options.interactive?
49
- )
50
- @emulator = emulator
42
+ if @step
43
+ @block = @step.block
44
+ else
45
+ emulator = Emulator.new(
46
+ rows: rows, cols: width,
47
+ scrollback_limit: @options.scrollback,
48
+ responder: ->(reply) { @session&.write(reply) }
49
+ )
50
+ @block = Block.new(
51
+ emulator: emulator,
52
+ title: @options.title,
53
+ lines: rows,
54
+ on_exit: @options.on_exit,
55
+ interactive: @options.interactive?,
56
+ dump_lines: @options.dump_lines
57
+ )
58
+ @emulator = emulator
59
+ end
51
60
 
52
61
  @session = spawn_child(rows, width)
53
62
  started = clock
@@ -59,8 +68,12 @@ module TTY
59
68
  @raw = @needs_raw ? (+"").force_encoding(Encoding::BINARY) : nil
60
69
  @log = @options.output_log && File.open(@options.output_log, "wb")
61
70
 
62
- @coordinator.register(@block, child_session: @session)
63
- InputRouter.attach(@block, @session, @coordinator) if @options.interactive?
71
+ if @step
72
+ @step.adopt_session(@session)
73
+ else
74
+ @coordinator.register(@block, child_session: @session)
75
+ InputRouter.attach(@block, @session, @coordinator) if @options.interactive?
76
+ end
64
77
 
65
78
  write_initial_input
66
79
  timed_out = timed_out_pumping?
@@ -194,7 +207,15 @@ module TTY
194
207
  end
195
208
  end
196
209
 
210
+ # For step runs the block belongs to the step: it stays running and
211
+ # is finished later by Step#finish; only the session is released.
197
212
  def finish(status, runtime, timed_out)
213
+ if @step
214
+ @finished = true
215
+ @step.release_session
216
+ return
217
+ end
218
+
198
219
  success = !timed_out && !status.nil? && status.success?
199
220
  @block.finish(success, runtime)
200
221
  InputRouter.detach(@block) if @options.interactive?
@@ -227,9 +248,17 @@ module TTY
227
248
  end
228
249
 
229
250
  # Ensure the child is dead and the block finalized even when the
230
- # pump raised (Interrupt, timeout, IO errors).
251
+ # pump raised (Interrupt, timeout, IO errors). For step runs the
252
+ # block is never finalized here — the step owns it — but the child
253
+ # must still die and the session detach from the step.
231
254
  def cleanup
232
- if @block&.running?
255
+ if @step
256
+ unless @finished
257
+ @session&.signal("SIGKILL")
258
+ wait_status if @session
259
+ @step.release_session
260
+ end
261
+ elsif @block&.running?
233
262
  @session&.signal("SIGKILL")
234
263
  wait_status
235
264
  @block.finish(false, 0.0)
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module TTY
6
+ class Command
7
+ module Window
8
+ # A caller-managed window whose lifecycle spans multiple commands.
9
+ #
10
+ # Where {Integration#run_windowed} opens a window for exactly one
11
+ # command, a step is opened explicitly, fed any number of commands
12
+ # (via +cmd.run_windowed(..., window: step)+ or {#run} / {#run!}),
13
+ # interleaved with plain {#log} lines, and closed with {#finish}.
14
+ # The title bar doubles as a progress line: spinner while open,
15
+ # +✔/✖ title • elapsed+ once finished.
16
+ #
17
+ # @example A build step with two commands and a note
18
+ # step = TTY::Command::Window::Step.open(title: "building", lines: 8)
19
+ # cmd = TTY::Command.new(printer: :null)
20
+ # cmd.run_windowed("make deps", window: step)
21
+ # step.log "deps done, compiling"
22
+ # result = cmd.run_windowed!("make -j8", window: step)
23
+ # step.finish(success: result.success?)
24
+ #
25
+ # When the environment cannot render windows (no TTY, Windows, no
26
+ # PTY), {.open} returns a {PlainStep} with the same interface: start
27
+ # and summary lines are printed as plain text and commands stream
28
+ # their full output, so calling code never needs a branch.
29
+ class Step
30
+ attr_reader :lines
31
+
32
+ # Open a step window (or a {PlainStep} fallback).
33
+ #
34
+ # @param title [String] title-bar text
35
+ # @param lines [Integer] window height (also the PTY row count
36
+ # commands see)
37
+ # @param output [IO] render target
38
+ # @param width [Integer, nil] fixed render width override
39
+ # @param on_exit [Symbol] :collapse_or_dump (default), :freeze,
40
+ # :dump_on_failure or :collapse
41
+ # @param scrollback [Integer] plain-text history limit
42
+ # @param dump_lines [Integer, nil] cap on history lines printed by a
43
+ # failure dump; nil dumps everything
44
+ # @param window [Boolean, nil] force (+true+) or forbid (+false+)
45
+ # windowed rendering, overriding TTY / PTY detection
46
+ #
47
+ # @yield [step] optional block form; the step is finished with
48
+ # +success: true+ on normal return and +success: false+ when the
49
+ # block raises (the exception propagates)
50
+ # @return [Step, PlainStep] the block form returns the block's value
51
+ def self.open(title:, lines: DEFAULT_LINES, output: $stdout, width: nil,
52
+ on_exit: :collapse_or_dump, scrollback: DEFAULT_SCROLLBACK,
53
+ dump_lines: nil, window: nil)
54
+ validate!(lines, on_exit, dump_lines)
55
+
56
+ step =
57
+ if renderable?(window, output)
58
+ new(title: title, lines: lines, output: output, width: width,
59
+ on_exit: on_exit, scrollback: scrollback, dump_lines: dump_lines)
60
+ else
61
+ PlainStep.new(title: title, output: output)
62
+ end
63
+ return step unless block_given?
64
+
65
+ begin
66
+ result = yield step
67
+ step.finish(success: true)
68
+ result
69
+ rescue Exception # rubocop:disable Lint/RescueException
70
+ step.finish(success: false)
71
+ raise
72
+ end
73
+ end
74
+
75
+ def self.renderable?(window, output)
76
+ case window
77
+ when true then !Window.windows? && Window.pty_available?
78
+ when false then false
79
+ else Window.renderable?(output)
80
+ end
81
+ end
82
+ private_class_method :renderable?
83
+
84
+ def self.validate!(lines, on_exit, dump_lines)
85
+ raise ArgumentError, "lines must be >= 1" if Integer(lines) < 1
86
+ unless ON_EXIT_MODES.include?(on_exit)
87
+ raise ArgumentError, "on_exit must be one of #{ON_EXIT_MODES.join(', ')}"
88
+ end
89
+ return unless dump_lines && Integer(dump_lines) < 1
90
+
91
+ raise ArgumentError,
92
+ "dump_lines must be >= 1 or nil"
93
+ end
94
+ private_class_method :validate!
95
+
96
+ # @api private Use {.open}.
97
+ def initialize(title:, lines:, output:, width:, on_exit:, scrollback:, dump_lines:)
98
+ @lines = lines
99
+ @coordinator = Coordinator.for(output, width: width)
100
+ @session = nil
101
+ @session_mutex = Mutex.new
102
+ @emulator = Emulator.new(
103
+ rows: lines, cols: @coordinator.width,
104
+ scrollback_limit: scrollback,
105
+ responder: ->(reply) { write_to_child(reply) }
106
+ )
107
+ @block = Block.new(
108
+ emulator: @emulator, title: title, lines: lines,
109
+ on_exit: on_exit, dump_lines: dump_lines
110
+ )
111
+ @active = true
112
+ @started_at = Block.clock
113
+ @coordinator.register(@block)
114
+ end
115
+
116
+ # @return [Boolean] false once {#finish} has run
117
+ def active?
118
+ @active
119
+ end
120
+
121
+ # Append plain text lines to the window, as if the child had
122
+ # printed them. Embedded ANSI colors are interpreted normally.
123
+ #
124
+ # @param text [String]
125
+ # @return [self]
126
+ def log(text)
127
+ return self unless @active
128
+
129
+ body = +""
130
+ # Start on a fresh line when a child left the cursor mid-line.
131
+ body << "\r\n" if @emulator.cursor_col.positive?
132
+ body << text.to_s.gsub("\n", "\r\n")
133
+ body << "\r\n" unless body.end_with?("\r\n")
134
+ @block.feed(body)
135
+ @coordinator.mark_dirty
136
+ self
137
+ end
138
+
139
+ # Replace the title-bar text.
140
+ #
141
+ # @param title [String]
142
+ # @return [self]
143
+ def retitle(title)
144
+ @block.retitle(title)
145
+ @coordinator.mark_dirty
146
+ self
147
+ end
148
+
149
+ # Run a command inside this step's window; raises
150
+ # +TTY::Command::ExitError+ on failure like +#run+.
151
+ #
152
+ # Sugar for +cmd.run_windowed(*args, window: self)+.
153
+ #
154
+ # @param cmd [TTY::Command]
155
+ # @return [TTY::Command::Result]
156
+ def run(cmd, *args, &)
157
+ cmd.run_windowed(*args_with_window(args), &)
158
+ end
159
+
160
+ # Same as {#run} but never raises on non-zero exit.
161
+ #
162
+ # @return [TTY::Command::Result]
163
+ def run!(cmd, *args, &)
164
+ cmd.run_windowed!(*args_with_window(args), &)
165
+ end
166
+
167
+ # Close the step: the title bar shows ✔/✖ and the window ends
168
+ # according to its +on_exit+ mode (for the default
169
+ # :collapse_or_dump — success leaves a one-line summary in the
170
+ # scrollback, failure dumps the output history).
171
+ #
172
+ # @param success [Boolean]
173
+ # @return [self]
174
+ def finish(success: true)
175
+ return self unless @active
176
+
177
+ @active = false
178
+ @block.finish(success ? true : false, Block.clock - @started_at)
179
+ @coordinator.finalize(@block)
180
+ self
181
+ end
182
+
183
+ # @api private Runner protocol: the block backing this step.
184
+ attr_reader :block
185
+
186
+ # @api private Runner protocol: the coordinator rendering this step.
187
+ attr_reader :coordinator
188
+
189
+ # @api private Runner protocol: a command's child session started.
190
+ def adopt_session(session)
191
+ @session_mutex.synchronize { @session = session }
192
+ @coordinator.attach_session(@block, session)
193
+ end
194
+
195
+ # @api private Runner protocol: the child session ended.
196
+ def release_session
197
+ @session_mutex.synchronize { @session = nil }
198
+ @coordinator.detach_session(@block)
199
+ @coordinator.mark_dirty
200
+ end
201
+
202
+ private
203
+
204
+ def write_to_child(reply)
205
+ session = @session_mutex.synchronize { @session }
206
+ session&.write(reply)
207
+ end
208
+
209
+ def args_with_window(args)
210
+ if args.last.respond_to?(:to_hash)
211
+ args[0..-2] + [args.last.to_hash.merge(window: self)]
212
+ else
213
+ args + [{ window: self }]
214
+ end
215
+ end
216
+ end
217
+
218
+ # Fallback step for environments without windowed rendering: the
219
+ # same interface as {Step}, rendered as plain scrolling text.
220
+ # Commands stream their full output between a start marker and a
221
+ # ✔/✖ summary line.
222
+ class PlainStep
223
+ attr_reader :output
224
+
225
+ def initialize(title:, output:)
226
+ @title = title.to_s
227
+ @output = output
228
+ @active = true
229
+ @at_line_start = true
230
+ @started_at = Block.clock
231
+ @pastel = Pastel.new(enabled: color?)
232
+ @output.puts("#{@pastel.dim('──')} #{@title}")
233
+ end
234
+
235
+ # @return [Boolean] false once {#finish} has run
236
+ def active?
237
+ @active
238
+ end
239
+
240
+ # @param text [String]
241
+ # @return [self]
242
+ def log(text)
243
+ @output.write("\n") unless @at_line_start
244
+ @at_line_start = true
245
+ @output.puts(text)
246
+ self
247
+ end
248
+
249
+ # @param title [String]
250
+ # @return [self]
251
+ def retitle(title)
252
+ @title = title.to_s
253
+ self
254
+ end
255
+
256
+ # (see Step#run)
257
+ def run(cmd, *args, &)
258
+ cmd.run_windowed(*args_with_window(args), &)
259
+ end
260
+
261
+ # (see Step#run!)
262
+ def run!(cmd, *args, &)
263
+ cmd.run_windowed!(*args_with_window(args), &)
264
+ end
265
+
266
+ # Print the ✔/✖ summary line.
267
+ #
268
+ # @param success [Boolean]
269
+ # @return [self]
270
+ def finish(success: true)
271
+ return self unless @active
272
+
273
+ @active = false
274
+ @output.write("\n") unless @at_line_start
275
+ glyph = success ? @pastel.green(Block::SUCCESS_GLYPH) : @pastel.red(Block::FAILURE_GLYPH)
276
+ elapsed = Window.format_elapsed(Block.clock - @started_at)
277
+ @output.puts("#{glyph} #{@title} #{@pastel.dim("• #{elapsed}")}")
278
+ self
279
+ end
280
+
281
+ # @api private Integration protocol: stream a command output chunk.
282
+ def write_output(chunk)
283
+ @at_line_start = chunk.end_with?("\n") unless chunk.empty?
284
+ @output.write(chunk)
285
+ end
286
+
287
+ private
288
+
289
+ def color?
290
+ return false if ENV.key?("NO_COLOR")
291
+
292
+ Window.assume_tty || (@output.respond_to?(:tty?) && @output.tty?)
293
+ end
294
+
295
+ def args_with_window(args)
296
+ if args.last.respond_to?(:to_hash)
297
+ args[0..-2] + [args.last.to_hash.merge(window: self)]
298
+ else
299
+ args + [{ window: self }]
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -3,7 +3,7 @@
3
3
  module TTY
4
4
  class Command
5
5
  module Window
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
@@ -16,7 +16,7 @@ module TTY
16
16
  # frozen in {.build} for immutability.
17
17
  WindowOptions = Struct.new(
18
18
  :lines, :title, :on_exit, :capture, :capture_max_bytes,
19
- :scrollback, :output_log, :interactive,
19
+ :scrollback, :output_log, :interactive, :dump_lines,
20
20
  keyword_init: true
21
21
  ) do
22
22
  # @param window_options [Hash] the raw window options subset
@@ -36,6 +36,12 @@ module TTY
36
36
  raise ArgumentError, "capture_max_bytes must be >= 1 or nil" if capture_max_bytes < 1
37
37
  end
38
38
 
39
+ dump_lines = window_options.fetch(:dump_lines, nil)
40
+ unless dump_lines.nil?
41
+ dump_lines = Integer(dump_lines)
42
+ raise ArgumentError, "dump_lines must be >= 1 or nil" if dump_lines < 1
43
+ end
44
+
39
45
  Window.validate_on_unavailable!(window_options)
40
46
 
41
47
  title = window_options.fetch(:title, nil)
@@ -49,7 +55,8 @@ module TTY
49
55
  capture_max_bytes: capture_max_bytes,
50
56
  scrollback: Integer(window_options.fetch(:scrollback, DEFAULT_SCROLLBACK)),
51
57
  output_log: window_options[:output_log],
52
- interactive: window_options[:interactive] == true
58
+ interactive: window_options[:interactive] == true,
59
+ dump_lines: dump_lines
53
60
  ).freeze
54
61
  end
55
62
 
@@ -14,6 +14,7 @@ require_relative "window/coordinator"
14
14
  require_relative "window/input_router"
15
15
  require_relative "window/window_options"
16
16
  require_relative "window/runner"
17
+ require_relative "window/step"
17
18
  require_relative "window/integration"
18
19
 
19
20
  module TTY
@@ -31,10 +32,10 @@ module TTY
31
32
  # delegation to plain tty-command.
32
33
  OPTION_KEYS = %i[
33
34
  lines title on_exit scrollback output_log interactive capture
34
- capture_max_bytes output window width on_unavailable tty
35
+ capture_max_bytes output window width on_unavailable tty dump_lines
35
36
  ].freeze
36
37
 
37
- ON_EXIT_MODES = %i[freeze dump_on_failure collapse].freeze
38
+ ON_EXIT_MODES = %i[freeze dump_on_failure collapse collapse_or_dump].freeze
38
39
  CAPTURE_MODES = %i[raw stripped screen].freeze
39
40
  ON_UNAVAILABLE_MODES = %i[fallback raise].freeze
40
41
 
@@ -102,6 +103,21 @@ module TTY
102
103
  def strip_ansi(text)
103
104
  ANSI.strip(text)
104
105
  end
106
+
107
+ # Human-readable elapsed time, as shown in title bars and step
108
+ # summaries.
109
+ #
110
+ # @param seconds [Float]
111
+ # @return [String]
112
+ def format_elapsed(seconds)
113
+ if seconds >= 3600
114
+ format("%<h>dh %<m>02dm", h: seconds / 3600, m: (seconds % 3600) / 60)
115
+ elsif seconds >= 60
116
+ format("%<m>dm %<s>02ds", m: seconds / 60, s: seconds % 60)
117
+ else
118
+ format("%<s>.1fs", s: seconds)
119
+ end
120
+ end
105
121
  end
106
122
 
107
123
  self.assume_tty = false
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tty-command-window
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Matyas
@@ -64,7 +64,7 @@ description: 'Extends tty-command with run_windowed: the child process runs in a
64
64
  in place. Supports stacked concurrent windows, interactive stdin with focus switching,
65
65
  resize handling and failure dumps.'
66
66
  email:
67
- - michal.matyas@helpling.com
67
+ - github@higher.lv
68
68
  executables: []
69
69
  extensions: []
70
70
  extra_rdoc_files: []
@@ -83,16 +83,17 @@ files:
83
83
  - lib/tty/command/window/integration.rb
84
84
  - lib/tty/command/window/runner.rb
85
85
  - lib/tty/command/window/spinner.rb
86
+ - lib/tty/command/window/step.rb
86
87
  - lib/tty/command/window/trap_manager.rb
87
88
  - lib/tty/command/window/version.rb
88
89
  - lib/tty/command/window/window_options.rb
89
- homepage: https://github.com/helpling/tty-command-window
90
+ homepage: https://github.com/d4rky-pl/tty-command-window
90
91
  licenses:
91
92
  - MIT
92
93
  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
94
+ homepage_uri: https://github.com/d4rky-pl/tty-command-window
95
+ source_code_uri: https://github.com/d4rky-pl/tty-command-window
96
+ changelog_uri: https://github.com/d4rky-pl/tty-command-window/blob/main/CHANGELOG.md
96
97
  rubygems_mfa_required: 'true'
97
98
  post_install_message:
98
99
  rdoc_options: []