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 +4 -4
- data/CHANGELOG.md +22 -2
- data/README.md +39 -0
- data/lib/tty/command/window/block.rb +50 -13
- data/lib/tty/command/window/coordinator.rb +26 -2
- data/lib/tty/command/window/integration.rb +44 -0
- data/lib/tty/command/window/runner.rb +47 -18
- data/lib/tty/command/window/step.rb +305 -0
- data/lib/tty/command/window/version.rb +1 -1
- data/lib/tty/command/window/window_options.rb +9 -2
- data/lib/tty/command/window.rb +18 -2
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e113fbc7a82f69f888cf1b28b007f526077c813bddbdc5d81be332f47f8b95d5
|
|
4
|
+
data.tar.gz: 529b3ea936fdcfe996fa199bdf4314fad0833d1d1ab4154afb788a1073c78358
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
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
|
|
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
|
-
|
|
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
|
|
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}")}
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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 @
|
|
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
|
|
@@ -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
|
|
data/lib/tty/command/window.rb
CHANGED
|
@@ -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.
|
|
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
|
-
-
|
|
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/
|
|
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/
|
|
94
|
-
source_code_uri: https://github.com/
|
|
95
|
-
changelog_uri: https://github.com/
|
|
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: []
|