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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/lib/tty/command/window/ansi.rb +112 -0
- data/lib/tty/command/window/block.rb +184 -0
- data/lib/tty/command/window/child_session.rb +161 -0
- data/lib/tty/command/window/coordinator.rb +315 -0
- data/lib/tty/command/window/emulator.rb +613 -0
- data/lib/tty/command/window/input_router.rb +249 -0
- data/lib/tty/command/window/integration.rb +195 -0
- data/lib/tty/command/window/runner.rb +245 -0
- data/lib/tty/command/window/spinner.rb +30 -0
- data/lib/tty/command/window/trap_manager.rb +117 -0
- data/lib/tty/command/window/version.rb +9 -0
- data/lib/tty/command/window/window_options.rb +76 -0
- data/lib/tty/command/window.rb +110 -0
- data/lib/tty-command-window.rb +3 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2b39102b7bfbae84d31acca3a966ca18b2553381a5dc2085a13bd98308959ba0
|
|
4
|
+
data.tar.gz: 75928be1bb72d1e7dfdd43e5e0b6678089f4c698f215fe6ec51b821cc07915cb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3f9b8bad3b0255f783a37cfa94820df78b4a11841ca12e94331fd372ded05dfe1ea46b1d46fb974d61fe16d18a70befceff5a592f03d11acc2f07bde83f97de0
|
|
7
|
+
data.tar.gz: 957da48ba1ffcc5a4776331975ae4f12e015a82cb9be1fe32dff96c46fd9d11cb7c5797c85cd31b7333b6190ada92ae03a0ace1b97fcadc5c7e8332ef8747b49
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Renamed the `tty:` option to `window:`**. The old name was ambiguous
|
|
13
|
+
inside the `TTY::` namespace and semantically wrong (it toggles the
|
|
14
|
+
window, not "am I a TTY?"). `tty:` is kept as a deprecated alias for one
|
|
15
|
+
minor version and emits a one-time deprecation warning; it will be
|
|
16
|
+
removed in the following release.
|
|
17
|
+
- `Result#err` under windowed rendering is documented (in the `#run_windowed`
|
|
18
|
+
YARD `@note` and in the README) as always `""`: the PTY merges the child's
|
|
19
|
+
stderr into `Result#out` at the OS level.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `TTY::Command#run_windowed` / `#run_windowed!`: run a command inside a live,
|
|
24
|
+
fixed-height terminal window (default 5 lines) rendered as a static block.
|
|
25
|
+
- Pure-Ruby VT100-subset terminal emulator (cursor movement, erase, scroll
|
|
26
|
+
regions, insert/delete lines and characters, SGR colors, alternate screen,
|
|
27
|
+
wide characters, autowrap) driving the window contents.
|
|
28
|
+
- Child processes run in a PTY that reports `lines:` rows, so cursor-driven
|
|
29
|
+
programs (e.g. `docker compose`) lay themselves out for the window height.
|
|
30
|
+
- Title/status bar with spinner, elapsed time and success/failure state.
|
|
31
|
+
- `on_exit:` modes — `:freeze` (default), `:dump_on_failure`, `:collapse`.
|
|
32
|
+
- Configurable result capture — `capture: :raw` (default), `:stripped`, `:screen`.
|
|
33
|
+
- Plain-text scrollback (10k lines default, `scrollback:` option) used for
|
|
34
|
+
failure dumps and `capture: :screen`.
|
|
35
|
+
- `output_log:` tees the full raw child output to a file.
|
|
36
|
+
- Concurrent stacked windows from multiple threads.
|
|
37
|
+
- Interactive stdin passthrough (`interactive: true`) with `Ctrl-O` focus
|
|
38
|
+
cycling between interactive windows.
|
|
39
|
+
- SIGWINCH handling: terminal resizes propagate to child PTYs and the block
|
|
40
|
+
re-renders at the new width.
|
|
41
|
+
- Graceful degradation to plain `tty-command` streaming when stdout is not a
|
|
42
|
+
TTY, on Windows, or when PTY support is unavailable.
|
|
43
|
+
- `on_unavailable:` option — `:fallback` (default, previous behavior)
|
|
44
|
+
silently degrades to plain `run`; `:raise` raises the new
|
|
45
|
+
`TTY::Command::Window::Unavailable` error when the environment cannot
|
|
46
|
+
render a window. Lets callers who need PTY-reported rows opt out of the
|
|
47
|
+
silent-degradation default. Invalid values raise `ArgumentError`.
|
|
48
|
+
- `TTY::Command::Window.with_assume_tty(value) { ... }` — scoped block form
|
|
49
|
+
of the `assume_tty` testing aid, restores the previous value on exit.
|
|
50
|
+
The `assume_tty` accessor is now marked `@api private`.
|
|
51
|
+
|
|
52
|
+
## [0.1.0] - Unreleased
|
|
53
|
+
|
|
54
|
+
Initial release.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michal Matyas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# tty-command-window
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> This library has been built with Claude Code.
|
|
5
|
+
|
|
6
|
+
Run shell commands with [tty-command](https://github.com/piotrmurach/tty-command),
|
|
7
|
+
but show only the **last N lines** of output as a live, fixed-height block in
|
|
8
|
+
your terminal — like CI log folding, but local and real-time.
|
|
9
|
+
|
|
10
|
+
The trick: the child process runs in a PTY that reports **N rows**, so
|
|
11
|
+
cursor-driven programs (`docker compose`, installers, progress bars) lay
|
|
12
|
+
themselves out as if the terminal were N lines tall. A built-in pure-Ruby
|
|
13
|
+
terminal emulator interprets every escape sequence the child emits — cursor
|
|
14
|
+
movement, erase, scroll regions, colors, alternate screen — and paints the
|
|
15
|
+
resulting screen into a static block that streams in place.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add to your Gemfile:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "tty-command-window"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Ruby >= 3.1 and a Unix-like OS for windowed rendering (see
|
|
28
|
+
[Degradation](#degradation) for what happens elsewhere).
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "tty-command-window"
|
|
34
|
+
|
|
35
|
+
cmd = TTY::Command.new(printer: :null)
|
|
36
|
+
|
|
37
|
+
# Everything docker compose draws stays inside 5 lines:
|
|
38
|
+
cmd.run_windowed("docker compose up -d", lines: 5)
|
|
39
|
+
|
|
40
|
+
# Same semantics as tty-command: raises TTY::Command::ExitError on failure,
|
|
41
|
+
# run_windowed! doesn't. Returns a TTY::Command::Result.
|
|
42
|
+
result = cmd.run_windowed!("make -j8", lines: 8, title: "building")
|
|
43
|
+
puts result.out if result.failure?
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`run_windowed` accepts everything `run` does (`env:`, `chdir:`, `timeout:`,
|
|
47
|
+
`input:`, a streaming block, ...) plus the window options below.
|
|
48
|
+
|
|
49
|
+
> **Use `printer: :null`.** `tty-command-window` renders the child's output
|
|
50
|
+
> itself. If your `TTY::Command` instance uses a printer other than `:null`
|
|
51
|
+
> (`:pretty`, `:progress`, `:quiet`), the printer will *also* write to the
|
|
52
|
+
> terminal in parallel with the window, double-logging every line. Every
|
|
53
|
+
> example in this README uses `TTY::Command.new(printer: :null)` for that
|
|
54
|
+
> reason.
|
|
55
|
+
|
|
56
|
+
### Options
|
|
57
|
+
|
|
58
|
+
| Option | Default | What it does |
|
|
59
|
+
| ----------------- | -------------- | ------------ |
|
|
60
|
+
| `lines:` | `5` | Window height; also the row count the child's PTY reports. |
|
|
61
|
+
| `title:` | the command | Title-bar text. `false` hides the title bar. |
|
|
62
|
+
| `on_exit:` | `:freeze` | What the block does when the command ends (see below). |
|
|
63
|
+
| `capture:` | `:raw` | What `Result#out` contains (see below). |
|
|
64
|
+
| `capture_max_bytes:` | `10 MiB` | Cap on raw bytes kept for `Result#out`; the head is dropped, the tail kept. `nil` disables. |
|
|
65
|
+
| `scrollback:` | `10_000` | Plain-text history lines kept for dumps and `capture: :screen`. |
|
|
66
|
+
| `output_log:` | `nil` | Tee the full raw child output to this file while rendering. |
|
|
67
|
+
| `interactive:` | `false` | Forward your keystrokes to the child's PTY. |
|
|
68
|
+
| `output:` | printer output | IO to render on. |
|
|
69
|
+
| `window:` | auto-detect | Force (`true`) or forbid (`false`) windowed rendering. |
|
|
70
|
+
| `on_unavailable:` | `:fallback` | `:fallback` degrades to plain `run`; `:raise` raises `TTY::Command::Window::Unavailable`. |
|
|
71
|
+
| `width:` | auto-detect | Fixed render width. |
|
|
72
|
+
|
|
73
|
+
### End-of-run behavior (`on_exit:`)
|
|
74
|
+
|
|
75
|
+
- `:freeze` *(default)* — the final frame stays in the terminal and the
|
|
76
|
+
cursor moves below it. The title bar turns into `✔ title • 3.1s` (green)
|
|
77
|
+
or `✖` (red).
|
|
78
|
+
- `:dump_on_failure` — like `:freeze` on success; on failure the block is
|
|
79
|
+
replaced by the full plain-text output history, so the error that scrolled
|
|
80
|
+
away is right there.
|
|
81
|
+
- `:collapse` — the block shrinks to a single status line when done.
|
|
82
|
+
|
|
83
|
+

|
|
84
|
+
|
|
85
|
+
### Result capture (`capture:`)
|
|
86
|
+
|
|
87
|
+
- `:raw` *(default)* — `Result#out` is exactly what the child wrote,
|
|
88
|
+
escape codes included (tty-command semantics). `Result#err` is always
|
|
89
|
+
`""`: a PTY merges the child's stderr into the same stream at the OS
|
|
90
|
+
level, so windowed rendering cannot separate them without destroying
|
|
91
|
+
the layout fidelity that is the point of the gem. If you need stderr
|
|
92
|
+
independently, use plain `run`/`run!`.
|
|
93
|
+
- `:stripped` — the raw stream with ANSI sequences removed.
|
|
94
|
+
- `:screen` — the emulator's plain-text history (scrollback + final
|
|
95
|
+
screen): what a human saw, in order, without any escape codes.
|
|
96
|
+
|
|
97
|
+
### Concurrent windows
|
|
98
|
+
|
|
99
|
+
`run_windowed` is thread-safe; blocks from concurrent calls stack in the
|
|
100
|
+
terminal, each rendering independently:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
%w[api worker assets].map do |name|
|
|
104
|
+
Thread.new { cmd.run_windowed("bin/build #{name}", lines: 3, title: name, on_exit: :collapse) }
|
|
105
|
+
end.each(&:join)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
### Interactive commands
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
cmd.run_windowed("bin/deploy", lines: 5, interactive: true)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
With `interactive: true`, the terminal goes into raw mode and your
|
|
117
|
+
keystrokes are written to the child's PTY, so `Continue? (y/N)` prompts work
|
|
118
|
+
inside the window. When several interactive windows run at once, **Ctrl-O**
|
|
119
|
+
cycles the keyboard focus between them — the focused window shows a `▸`
|
|
120
|
+
marker and an inverse title.
|
|
121
|
+
|
|
122
|
+
Because raw mode delivers Ctrl-C to the focused child as a keystroke
|
|
123
|
+
(`0x03`) rather than signalling your process, the child decides what an
|
|
124
|
+
interrupt means while an interactive window is open.
|
|
125
|
+
|
|
126
|
+
### Resize
|
|
127
|
+
|
|
128
|
+
Terminal resizes are propagated: the child PTYs get the new width (rows stay
|
|
129
|
+
at `lines:`), the child re-renders — as any full-screen program does on
|
|
130
|
+
SIGWINCH — and the blocks repaint.
|
|
131
|
+
|
|
132
|
+
## Degradation
|
|
133
|
+
|
|
134
|
+
Windowed rendering needs a real terminal and PTY support. `run_windowed`
|
|
135
|
+
falls back to a plain `run` (full streamed output through your configured
|
|
136
|
+
printer, no window, no PTY) when:
|
|
137
|
+
|
|
138
|
+
- stdout (or the given `output:`) is not a TTY — CI, pipes, cron;
|
|
139
|
+
- the OS is Windows, or Ruby's `pty` library is unavailable;
|
|
140
|
+
- the command instance is in `dry_run` mode.
|
|
141
|
+
|
|
142
|
+
The fallback keeps the same raise/no-raise contract, so calling code never
|
|
143
|
+
needs a branch. If you *need* windowed rendering — for example, a test that
|
|
144
|
+
asserts the child laid itself out at 5 rows — pass `on_unavailable: :raise`
|
|
145
|
+
to opt out of degradation:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
cmd.run_windowed("bin/deploy", lines: 5, on_unavailable: :raise)
|
|
149
|
+
# raises TTY::Command::Window::Unavailable when there's no PTY / TTY.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Notes and limitations
|
|
153
|
+
|
|
154
|
+
- **Signals**: while windows are active, `SIGINT`/`SIGTERM` are forwarded to
|
|
155
|
+
the children's process groups (previous handlers are chained and restored
|
|
156
|
+
afterwards).
|
|
157
|
+
- **Cursor**: hidden while windows render, restored afterwards — also via an
|
|
158
|
+
`at_exit` hook if the process dies mid-run.
|
|
159
|
+
- **`Result#out` growth**: `capture: :raw` keeps up to `capture_max_bytes:`
|
|
160
|
+
(default 10 MiB) of the raw stream in memory, dropping the oldest bytes
|
|
161
|
+
beyond that. For multi-hour firehoses prefer `capture: :screen` (bounded
|
|
162
|
+
by `scrollback:`) plus `output_log:`, or raise/disable the cap.
|
|
163
|
+
- **Emulator coverage**: the common VT100/xterm repertoire (cursor
|
|
164
|
+
movement, EL/ED, IL/DL/DCH/ICH/ECH, SU/SD, DECSTBM scroll regions, SGR
|
|
165
|
+
colors incl. 256/truecolor, alternate screen, autowrap, wide characters,
|
|
166
|
+
DSR/DA reports). Exotic sequences are ignored rather than leaked to your
|
|
167
|
+
terminal.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
bundle install
|
|
173
|
+
bundle exec rake # specs + rubocop
|
|
174
|
+
ruby examples/compose_demo.rb # see it live
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The `examples/` directory doubles as a manual test rig; `examples/fake_compose.rb`
|
|
178
|
+
simulates docker compose's cursor choreography without needing Docker.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TTY
|
|
4
|
+
class Command
|
|
5
|
+
module Window
|
|
6
|
+
# ANSI helpers: SGR attribute tracking and escape-sequence stripping.
|
|
7
|
+
module ANSI
|
|
8
|
+
STRIP_PATTERN = Regexp.union(
|
|
9
|
+
/\e\][^\a\e]*(?:\a|\e\\)?/, # OSC ... BEL/ST
|
|
10
|
+
/\eP.*?(?:\e\\|\a)/m, # DCS ... ST
|
|
11
|
+
%r{\e\[[\d;:?<=>!]*[ -/]*[@-~]}, # CSI sequences
|
|
12
|
+
/\e[()*+][@-~]/, # charset designation
|
|
13
|
+
/\e[@-_]/, # other C1-introducing escapes
|
|
14
|
+
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ # stray control bytes (keep \t \n \r)
|
|
15
|
+
).freeze
|
|
16
|
+
# Remove escape sequences and normalize CRLF, leaving printable text.
|
|
17
|
+
#
|
|
18
|
+
# @param text [String]
|
|
19
|
+
# @return [String]
|
|
20
|
+
def self.strip(text)
|
|
21
|
+
text.gsub(STRIP_PATTERN, "").gsub("\r\n", "\n").delete("\r")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Tracks the current SGR (color/attribute) state of the emulator pen
|
|
25
|
+
# and serializes it to a compact parameter string stored per cell.
|
|
26
|
+
class SGRState
|
|
27
|
+
RESET_CODES = {
|
|
28
|
+
22 => %i[bold dim], 23 => %i[italic], 24 => %i[underline],
|
|
29
|
+
25 => %i[blink], 27 => %i[inverse], 28 => %i[hidden], 29 => %i[strike]
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
SET_CODES = {
|
|
33
|
+
1 => :bold, 2 => :dim, 3 => :italic, 4 => :underline,
|
|
34
|
+
5 => :blink, 7 => :inverse, 8 => :hidden, 9 => :strike
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
FLAG_SGR = {
|
|
38
|
+
bold: "1", dim: "2", italic: "3", underline: "4",
|
|
39
|
+
blink: "5", inverse: "7", hidden: "8", strike: "9"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
reset
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reset
|
|
47
|
+
@flags = {}
|
|
48
|
+
@fg = nil
|
|
49
|
+
@bg = nil
|
|
50
|
+
@serialized = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Apply a list of SGR parameters (integers, with extended color
|
|
54
|
+
# sub-parameters consumed in place).
|
|
55
|
+
#
|
|
56
|
+
# @param params [Array<Integer>]
|
|
57
|
+
def apply(params)
|
|
58
|
+
params = [0] if params.empty?
|
|
59
|
+
i = 0
|
|
60
|
+
i += apply_one(params, i) while i < params.length
|
|
61
|
+
@serialized = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String, nil] compact SGR params ("1;38;5;196") or nil when default
|
|
65
|
+
def to_params
|
|
66
|
+
if @serialized.nil?
|
|
67
|
+
parts = @flags.keys.map { |flag| FLAG_SGR[flag] }
|
|
68
|
+
parts << @fg if @fg
|
|
69
|
+
parts << @bg if @bg
|
|
70
|
+
@serialized = parts.empty? ? false : parts.join(";").freeze
|
|
71
|
+
end
|
|
72
|
+
@serialized || nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# @return [Integer] number of params consumed
|
|
78
|
+
def apply_one(params, index)
|
|
79
|
+
code = params[index] || 0
|
|
80
|
+
case code
|
|
81
|
+
when 0 then reset
|
|
82
|
+
when *SET_CODES.keys then @flags[SET_CODES[code]] = true
|
|
83
|
+
when *RESET_CODES.keys then RESET_CODES[code].each { |flag| @flags.delete(flag) }
|
|
84
|
+
when 30..37, 90..97 then @fg = code.to_s
|
|
85
|
+
when 40..47, 100..107 then @bg = code.to_s
|
|
86
|
+
when 39 then @fg = nil
|
|
87
|
+
when 49 then @bg = nil
|
|
88
|
+
when 38, 48 then return apply_extended_color(params, index)
|
|
89
|
+
end
|
|
90
|
+
1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def apply_extended_color(params, index)
|
|
94
|
+
target = params[index] == 38 ? :fg= : :bg=
|
|
95
|
+
case params[index + 1]
|
|
96
|
+
when 5
|
|
97
|
+
send(target, params[index..(index + 2)].join(";")) if params[index + 2]
|
|
98
|
+
3
|
|
99
|
+
when 2
|
|
100
|
+
send(target, params[index..(index + 4)].join(";")) if params[index + 4]
|
|
101
|
+
5
|
|
102
|
+
else
|
|
103
|
+
1
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
attr_writer :fg, :bg
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module TTY
|
|
6
|
+
class Command
|
|
7
|
+
module Window
|
|
8
|
+
# One live window in the terminal: an emulator plus title-bar state.
|
|
9
|
+
#
|
|
10
|
+
# Blocks are registered with a {Coordinator}, which stacks and paints
|
|
11
|
+
# them. All emulator access is synchronized through the block's mutex
|
|
12
|
+
# because the runner thread feeds data while the render thread paints.
|
|
13
|
+
class Block
|
|
14
|
+
SUCCESS_GLYPH = "✔"
|
|
15
|
+
FAILURE_GLYPH = "✖"
|
|
16
|
+
FOCUSED_MARKER = "▸ "
|
|
17
|
+
UNFOCUSED_MARKER = "▹ "
|
|
18
|
+
|
|
19
|
+
attr_reader :lines, :on_exit, :status
|
|
20
|
+
attr_accessor :focused
|
|
21
|
+
|
|
22
|
+
# @param emulator [Emulator]
|
|
23
|
+
# @param title [String, false] title text; false disables the title bar
|
|
24
|
+
# @param lines [Integer] window height (emulator rows)
|
|
25
|
+
# @param on_exit [Symbol] :freeze, :dump_on_failure or :collapse
|
|
26
|
+
# @param interactive [Boolean] eligible for stdin focus
|
|
27
|
+
def initialize(emulator:, title:, lines:, on_exit:, interactive: false)
|
|
28
|
+
@emulator = emulator
|
|
29
|
+
@title_enabled = title != false
|
|
30
|
+
@title_text = title.is_a?(String) ? title : ""
|
|
31
|
+
@lines = lines
|
|
32
|
+
@on_exit = on_exit
|
|
33
|
+
@interactive = interactive
|
|
34
|
+
@status = :running
|
|
35
|
+
@started_at = Block.clock
|
|
36
|
+
@runtime = nil
|
|
37
|
+
@focused = false
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.clock
|
|
42
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Feed raw child output into the emulator.
|
|
46
|
+
def feed(data)
|
|
47
|
+
@mutex.synchronize { @emulator.feed(data) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Mark the block finished.
|
|
51
|
+
#
|
|
52
|
+
# @param success [Boolean]
|
|
53
|
+
# @param runtime [Float] seconds
|
|
54
|
+
def finish(success, runtime)
|
|
55
|
+
@runtime = runtime
|
|
56
|
+
@status = success ? :success : :failure
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def running?
|
|
60
|
+
@status == :running
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def done?
|
|
64
|
+
!running?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def success?
|
|
68
|
+
@status == :success
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def failure?
|
|
72
|
+
@status == :failure
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def interactive?
|
|
76
|
+
@interactive
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def collapsed?
|
|
80
|
+
done? && @on_exit == :collapse
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether finalization should replace this block with its full history.
|
|
84
|
+
def dump_on_finalize?
|
|
85
|
+
failure? && @on_exit == :dump_on_failure
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Rendered height in terminal lines given the current state.
|
|
89
|
+
def height
|
|
90
|
+
return 1 if collapsed?
|
|
91
|
+
|
|
92
|
+
(@title_enabled ? 1 : 0) + @lines
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Render the block to an array of ANSI strings, +height+ elements.
|
|
96
|
+
#
|
|
97
|
+
# @param width [Integer] terminal width
|
|
98
|
+
# @param pastel [Pastel::Delegator]
|
|
99
|
+
# @param frame [String] current spinner frame
|
|
100
|
+
# @return [Array<String>]
|
|
101
|
+
def render(width:, pastel:, frame:)
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
out = []
|
|
104
|
+
out << title_line(width, pastel, frame) if @title_enabled || collapsed?
|
|
105
|
+
out.concat(@emulator.render_lines) unless collapsed?
|
|
106
|
+
out
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Full plain-text history (scrollback + visible screen).
|
|
111
|
+
def full_text
|
|
112
|
+
@mutex.synchronize { @emulator.full_text }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Plain title line plus history, used when dumping on failure.
|
|
116
|
+
def dump_text(pastel)
|
|
117
|
+
"#{status_glyph(pastel)} #{@title_text} #{pastel.dim("• #{elapsed_text}")}\n#{full_text}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Resize the emulator to a new terminal width. PTY resize is
|
|
121
|
+
# owned by {ChildSession}.
|
|
122
|
+
def resize(width)
|
|
123
|
+
@mutex.synchronize { @emulator.resize(cols: width) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
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
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def status_glyph(pastel)
|
|
140
|
+
success? ? pastel.green(SUCCESS_GLYPH) : pastel.red(FAILURE_GLYPH)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def title_line(width, pastel, frame)
|
|
144
|
+
marker = running? ? pastel.cyan(frame) : status_glyph(pastel)
|
|
145
|
+
focus =
|
|
146
|
+
if interactive? && running?
|
|
147
|
+
@focused ? pastel.yellow.bold(FOCUSED_MARKER) : pastel.dim(UNFOCUSED_MARKER)
|
|
148
|
+
else
|
|
149
|
+
""
|
|
150
|
+
end
|
|
151
|
+
suffix = pastel.dim("• #{elapsed_text}")
|
|
152
|
+
budget = width - 2 - (focus.empty? ? 0 : 2) - display_width(strip_len(suffix)) - 1
|
|
153
|
+
title = truncate_display(@title_text, budget)
|
|
154
|
+
title = pastel.inverse(title) if @focused && interactive? && running?
|
|
155
|
+
"#{marker} #{focus}#{title} #{suffix}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def strip_len(text)
|
|
159
|
+
ANSI.strip(text)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def display_width(text)
|
|
163
|
+
Unicode::DisplayWidth.of(text)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def truncate_display(text, budget)
|
|
167
|
+
return "" if budget <= 0
|
|
168
|
+
return text if display_width(text) <= budget
|
|
169
|
+
|
|
170
|
+
out = +""
|
|
171
|
+
used = 0
|
|
172
|
+
text.each_char do |char|
|
|
173
|
+
char_width = display_width(char)
|
|
174
|
+
break if used + char_width > budget - 1
|
|
175
|
+
|
|
176
|
+
out << char
|
|
177
|
+
used += char_width
|
|
178
|
+
end
|
|
179
|
+
"#{out}…"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|