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 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
+ ![docker compose confined to a 5-line window](docs/demo-compose.gif)
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
+ ![failure dump](docs/demo-failure.gif)
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
+ ![three concurrent windows](docs/demo-multi.gif)
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