tans-parser 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 +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +104 -0
- data/lib/tans-parser.rb +10 -0
- data/lib/tans_parser/ansi_parser.rb +736 -0
- data/lib/tans_parser/ansi_utils.rb +77 -0
- data/lib/tans_parser/state.rb +148 -0
- data/lib/tans_parser/version.rb +5 -0
- metadata +166 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7f88d3f3ba4bc87f81c46fb3168b660728f980e5fa97daf608ba9f68e35f2bdf
|
|
4
|
+
data.tar.gz: 5b535136d317e598098cca288e7f8f157f0d0d45b6974f437572860d5554cb14
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 32f9a060a089b2d8771b249e5a548b1725498d6b1b1a101cc8cbd547a41939fc8683d93853c734a8eb3f7edcbce6e5cb0fc3af162207727b02ac5f8850e5a5b5
|
|
7
|
+
data.tar.gz: 34481b9990055c0ba0fdd4d972703cbcab3208360ad6e4877cc0953e3f9de91838095aa5e9f7c54b4e3e563820d5e1b752a3925883021fdda9b45dd0f882862d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial release: ANSI escape sequence parser extracted from tui-td
|
|
6
|
+
- `TansParser::ANSIParser` — parses raw terminal output into structured state (735 lines)
|
|
7
|
+
- `TansParser::ANSIUtils` — shared ANSI color and style helpers (77 lines)
|
|
8
|
+
- `TansParser::State` — high-level query API for terminal state (148 lines)
|
|
9
|
+
- Zero runtime dependencies (pure Ruby stdlib)
|
|
10
|
+
- 188 tests with 100% line and branch coverage
|
|
11
|
+
- SGR colors (16, 256, TrueColor), cursor movement, erase, scroll
|
|
12
|
+
- Alternate screen buffer support (DEC private modes 47, 1047, 1049)
|
|
13
|
+
- ISO-2022 charset switching (G0/G1, DEC Special Character & Line Drawing)
|
|
14
|
+
- Mouse tracking mode/format parsing (1000, 1002, 1003, 1006)
|
|
15
|
+
- DECSC/DECRC cursor save/restore (ESC 7/8 and CSI s/u variants)
|
|
16
|
+
- DECSTBM scroll region support
|
|
17
|
+
- DSR (Device Status Report) detection
|
|
18
|
+
- `build_frame` — reconstruct ANSI output from state hash
|
|
19
|
+
- `_color_code` — convert named/hex colors to ANSI color sequences
|
|
20
|
+
- UTF-8 multi-byte character support
|
|
21
|
+
- RuboCop, Reek, Bundler-Audit configured
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haluk Durmus
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# tans-parser — Terminal ANSI State Utils
|
|
2
|
+
|
|
3
|
+
Parse raw terminal output with ANSI escape sequences into structured, queryable data.
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies. Ruby stdlib only.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Ruby 3.0+ required.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gem install tans-parser
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Parse ANSI output
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "tans-parser"
|
|
21
|
+
|
|
22
|
+
# Parse a raw ANSI string into a structured grid
|
|
23
|
+
raw = "\e[31mERROR:\e[0m Something went wrong\n\e[32mOK:\e[0m All good"
|
|
24
|
+
state_data = TansParser::ANSIParser.parse(raw, rows: 40, cols: 120)
|
|
25
|
+
|
|
26
|
+
# state_data is a Hash with:
|
|
27
|
+
# :size → {rows:, cols:}
|
|
28
|
+
# :cursor → {row:, col:, visible:, style:}
|
|
29
|
+
# :rows → [[{char:, fg:, bg:, bold:, italic:, underline:, blink:}, ...], ...]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Query the state
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
state = TansParser::State.new(state_data)
|
|
36
|
+
|
|
37
|
+
# Plain text of the entire screen
|
|
38
|
+
state.plain_text
|
|
39
|
+
# => "ERROR: Something went wrong\nOK: All good"
|
|
40
|
+
|
|
41
|
+
# Search for text
|
|
42
|
+
state.find_text("ERROR")
|
|
43
|
+
# => [{row: 0, col: 0, text: "ERROR", full_line: "ERROR: Something went wrong"}]
|
|
44
|
+
|
|
45
|
+
# Cell-level queries
|
|
46
|
+
state.foreground_at(0, 0) # => "red"
|
|
47
|
+
state.background_at(0, 0) # => "default"
|
|
48
|
+
state.style_at(0, 0) # => {bold: false, italic: false, underline: false}
|
|
49
|
+
|
|
50
|
+
# AI-friendly JSON with highlights
|
|
51
|
+
state.to_ai_json
|
|
52
|
+
# => {size:, cursor:, text:, highlights:, summary:}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Rebuild ANSI from state
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
ansi = TansParser::ANSIParser.build_frame(state_data)
|
|
59
|
+
# => "\e[0m\e[2J\e[H\e[31mE\e[31mR\e[31mR..."
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Color utilities
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
include TansParser::ANSIUtils
|
|
66
|
+
|
|
67
|
+
resolve_color("red", nil) # => [0xAA, 0x00, 0x00]
|
|
68
|
+
resolve_color("#ff8800", nil) # => [255, 136, 0]
|
|
69
|
+
resolve_color("color82", nil) # => [95, 255, 0]
|
|
70
|
+
xterm_256(16) # => [0x00, 0x00, 0x00]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Cell format
|
|
74
|
+
|
|
75
|
+
Each cell is a Hash with these keys:
|
|
76
|
+
|
|
77
|
+
| Key | Type | Description |
|
|
78
|
+
|-----|------|-------------|
|
|
79
|
+
| `char` | String | Single character (UTF-8) |
|
|
80
|
+
| `fg` | String | Foreground color name, hex, or "colorN" |
|
|
81
|
+
| `bg` | String | Background color name, hex, or "colorN" |
|
|
82
|
+
| `bold` | Boolean | Bold style |
|
|
83
|
+
| `italic` | Boolean | Italic style |
|
|
84
|
+
| `underline` | Boolean | Underline style |
|
|
85
|
+
| `blink` | Boolean | Blink style |
|
|
86
|
+
|
|
87
|
+
Default cell: `{char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false}`
|
|
88
|
+
|
|
89
|
+
## Supported ANSI sequences
|
|
90
|
+
|
|
91
|
+
- **SGR** — colors (16, 256, TrueColor), bold, italic, underline, blink, reverse
|
|
92
|
+
- **Cursor** — CUU, CUD, CUF, CUB, CUP, CHA
|
|
93
|
+
- **Erase** — ED (erase display), EL (erase line), ECH (erase characters)
|
|
94
|
+
- **Scroll** — scroll regions (DECSTBM), overflow scrolling
|
|
95
|
+
- **Alt screen** — DEC private modes 47, 1047, 1049
|
|
96
|
+
- **Cursor save/restore** — DECSC, DECRC, CSI s, CSI u
|
|
97
|
+
- **Cursor style** — DECSCUSR
|
|
98
|
+
- **Mouse tracking** — DEC private modes 1000, 1002, 1003, 1006
|
|
99
|
+
- **ISO 2022** — G0/G1 charset switching, DEC Special Graphics
|
|
100
|
+
- **UTF-8** — Multi-byte characters including emoji
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
data/lib/tans-parser.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TansParser
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "tans_parser/version"
|
|
8
|
+
require_relative "tans_parser/ansi_parser"
|
|
9
|
+
require_relative "tans_parser/ansi_utils"
|
|
10
|
+
require_relative "tans_parser/state"
|
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TansParser
|
|
4
|
+
# Parses raw terminal output (ANSI escape sequences + text) into a
|
|
5
|
+
# structured state representation.
|
|
6
|
+
#
|
|
7
|
+
# Handles:
|
|
8
|
+
# - SGR (Select Graphic Rendition) — colors, bold, italic, underline
|
|
9
|
+
# - Cursor movement (CUU, CUD, CUF, CUB, CUP)
|
|
10
|
+
# - Erase (ED, EL)
|
|
11
|
+
# - Line feed, carriage return, backspace, tab
|
|
12
|
+
#
|
|
13
|
+
# Output: {rows: [[{char, fg, bg, bold, italic, underline}]], cursor: {row, col}, size: {rows, cols}}
|
|
14
|
+
#
|
|
15
|
+
# rubocop:disable Metrics/ModuleLength
|
|
16
|
+
module ANSIParser
|
|
17
|
+
SGR_COLORS = {
|
|
18
|
+
0 => :reset,
|
|
19
|
+
1 => :bold,
|
|
20
|
+
3 => :italic,
|
|
21
|
+
4 => :underline,
|
|
22
|
+
5 => :blink,
|
|
23
|
+
7 => :reverse,
|
|
24
|
+
22 => :normal,
|
|
25
|
+
23 => :no_italic,
|
|
26
|
+
24 => :no_underline,
|
|
27
|
+
30 => :black,
|
|
28
|
+
31 => :red,
|
|
29
|
+
32 => :green,
|
|
30
|
+
33 => :yellow,
|
|
31
|
+
34 => :blue,
|
|
32
|
+
35 => :magenta,
|
|
33
|
+
36 => :cyan,
|
|
34
|
+
37 => :white,
|
|
35
|
+
38 => :xterm_fg, # 38;5;N or 38;2;R;G;B
|
|
36
|
+
39 => :default_fg,
|
|
37
|
+
40 => :bg_black,
|
|
38
|
+
41 => :bg_red,
|
|
39
|
+
42 => :bg_green,
|
|
40
|
+
43 => :bg_yellow,
|
|
41
|
+
44 => :bg_blue,
|
|
42
|
+
45 => :bg_magenta,
|
|
43
|
+
46 => :bg_cyan,
|
|
44
|
+
47 => :bg_white,
|
|
45
|
+
48 => :xterm_bg, # 48;5;N or 48;2;R;G;B
|
|
46
|
+
49 => :default_bg,
|
|
47
|
+
90 => :bright_black,
|
|
48
|
+
91 => :bright_red,
|
|
49
|
+
92 => :bright_green,
|
|
50
|
+
93 => :bright_yellow,
|
|
51
|
+
94 => :bright_blue,
|
|
52
|
+
95 => :bright_magenta,
|
|
53
|
+
96 => :bright_cyan,
|
|
54
|
+
97 => :bright_white,
|
|
55
|
+
100 => :bg_bright_black,
|
|
56
|
+
101 => :bg_bright_red,
|
|
57
|
+
102 => :bg_bright_green,
|
|
58
|
+
103 => :bg_bright_yellow,
|
|
59
|
+
104 => :bg_bright_blue,
|
|
60
|
+
105 => :bg_bright_magenta,
|
|
61
|
+
106 => :bg_bright_cyan,
|
|
62
|
+
107 => :bg_bright_white,
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
SGR_16_TO_NAME = {
|
|
66
|
+
0 => "black",
|
|
67
|
+
1 => "red",
|
|
68
|
+
2 => "green",
|
|
69
|
+
3 => "yellow",
|
|
70
|
+
4 => "blue",
|
|
71
|
+
5 => "magenta",
|
|
72
|
+
6 => "cyan",
|
|
73
|
+
7 => "white",
|
|
74
|
+
8 => "bright_black",
|
|
75
|
+
9 => "bright_red",
|
|
76
|
+
10 => "bright_green",
|
|
77
|
+
11 => "bright_yellow",
|
|
78
|
+
12 => "bright_blue",
|
|
79
|
+
13 => "bright_magenta",
|
|
80
|
+
14 => "bright_cyan",
|
|
81
|
+
15 => "bright_white",
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
DEC_MAP = {
|
|
85
|
+
"`" => "◆",
|
|
86
|
+
"a" => "▒",
|
|
87
|
+
"b" => "\u2409",
|
|
88
|
+
"c" => "\u240C",
|
|
89
|
+
"d" => "\u240D",
|
|
90
|
+
"e" => "\u240A",
|
|
91
|
+
"f" => "°",
|
|
92
|
+
"g" => "±",
|
|
93
|
+
"h" => "\u2424",
|
|
94
|
+
"i" => "\u240B",
|
|
95
|
+
"j" => "┘",
|
|
96
|
+
"k" => "┐",
|
|
97
|
+
"l" => "┌",
|
|
98
|
+
"m" => "└",
|
|
99
|
+
"n" => "┼",
|
|
100
|
+
"o" => "⎺",
|
|
101
|
+
"p" => "⎻",
|
|
102
|
+
"q" => "─",
|
|
103
|
+
"r" => "⎼",
|
|
104
|
+
"s" => "⎽",
|
|
105
|
+
"t" => "├",
|
|
106
|
+
"u" => "┤",
|
|
107
|
+
"v" => "┴",
|
|
108
|
+
"w" => "┬",
|
|
109
|
+
"x" => "│",
|
|
110
|
+
"y" => "≤",
|
|
111
|
+
"z" => "≥",
|
|
112
|
+
"{" => "π",
|
|
113
|
+
"|" => "≠",
|
|
114
|
+
"}" => "£",
|
|
115
|
+
"~" => "·",
|
|
116
|
+
}.freeze
|
|
117
|
+
|
|
118
|
+
# Parse raw terminal output into a structured state Hash
|
|
119
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
120
|
+
def self.parse(raw, rows = 40, cols = 120)
|
|
121
|
+
grid = Array.new(rows) do
|
|
122
|
+
Array.new(cols) { default_cell.dup }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
cursor = { row: 0, col: 0 }
|
|
126
|
+
attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
127
|
+
saved_cursor = nil
|
|
128
|
+
scroll_region = { top: 0, bottom: rows - 1 }
|
|
129
|
+
pending_dsr = false
|
|
130
|
+
|
|
131
|
+
normal_grid = grid
|
|
132
|
+
alt_grid = nil
|
|
133
|
+
|
|
134
|
+
normal_cursor = cursor
|
|
135
|
+
alt_cursor = { row: 0, col: 0 }
|
|
136
|
+
|
|
137
|
+
normal_saved_cursor = nil
|
|
138
|
+
alt_saved_cursor = nil
|
|
139
|
+
|
|
140
|
+
use_alt_screen = false
|
|
141
|
+
|
|
142
|
+
cursor_visible = true
|
|
143
|
+
cursor_style = 1 # 1 = blinking block (default)
|
|
144
|
+
|
|
145
|
+
g0_charset = :ascii
|
|
146
|
+
g1_charset = :dec
|
|
147
|
+
active_charset = :g0
|
|
148
|
+
|
|
149
|
+
mouse_mode = :none
|
|
150
|
+
mouse_format = :normal
|
|
151
|
+
|
|
152
|
+
# Strip everything before the last full clear (if any)
|
|
153
|
+
# to avoid accumulated garbage
|
|
154
|
+
processed = raw
|
|
155
|
+
|
|
156
|
+
i = 0
|
|
157
|
+
while i < processed.length
|
|
158
|
+
if processed[i] == "\e" && processed[i + 1] == "["
|
|
159
|
+
# Find end of CSI sequence
|
|
160
|
+
j = i + 2
|
|
161
|
+
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnRrsuq]/)
|
|
162
|
+
seq = processed[i..j]
|
|
163
|
+
|
|
164
|
+
dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
165
|
+
pending_dsr ||= dsr
|
|
166
|
+
|
|
167
|
+
if new_saved
|
|
168
|
+
if use_alt_screen
|
|
169
|
+
alt_saved_cursor = new_saved
|
|
170
|
+
saved_cursor = alt_saved_cursor
|
|
171
|
+
else
|
|
172
|
+
normal_saved_cursor = new_saved
|
|
173
|
+
saved_cursor = normal_saved_cursor
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if action.key?(:alt_screen)
|
|
178
|
+
new_alt = action[:alt_screen]
|
|
179
|
+
code = action[:alt_screen_code]
|
|
180
|
+
if new_alt != use_alt_screen
|
|
181
|
+
if new_alt
|
|
182
|
+
# Switch to Alternate Screen
|
|
183
|
+
# Save normal cursor
|
|
184
|
+
normal_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
185
|
+
|
|
186
|
+
# Lazy initialize alt grid
|
|
187
|
+
alt_grid ||= Array.new(rows) do
|
|
188
|
+
Array.new(cols) { default_cell.dup }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# For \e[?1049h, clear alternate screen and reset cursor to 0,0
|
|
192
|
+
if code == 1049
|
|
193
|
+
alt_grid = Array.new(rows) do
|
|
194
|
+
Array.new(cols) { default_cell.dup }
|
|
195
|
+
end
|
|
196
|
+
alt_cursor = { row: 0, col: 0 }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
grid = alt_grid
|
|
200
|
+
cursor = alt_cursor
|
|
201
|
+
saved_cursor = alt_saved_cursor
|
|
202
|
+
use_alt_screen = true
|
|
203
|
+
else
|
|
204
|
+
# Switch to Normal Screen
|
|
205
|
+
# Save alt cursor
|
|
206
|
+
alt_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
207
|
+
|
|
208
|
+
grid = normal_grid
|
|
209
|
+
cursor = normal_cursor
|
|
210
|
+
saved_cursor = normal_saved_cursor
|
|
211
|
+
use_alt_screen = false
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
cursor_visible = action[:cursor_visible] if action.key?(:cursor_visible)
|
|
217
|
+
|
|
218
|
+
cursor_style = action[:cursor_style] if action.key?(:cursor_style)
|
|
219
|
+
|
|
220
|
+
mouse_mode = action[:mouse_mode] if action.key?(:mouse_mode)
|
|
221
|
+
|
|
222
|
+
mouse_format = action[:mouse_format] if action.key?(:mouse_format)
|
|
223
|
+
|
|
224
|
+
i = j + 1
|
|
225
|
+
elsif processed[i] == "\n"
|
|
226
|
+
cursor[:row] += 1
|
|
227
|
+
cursor[:col] = 0
|
|
228
|
+
i += 1
|
|
229
|
+
elsif processed[i] == "\r"
|
|
230
|
+
cursor[:col] = 0
|
|
231
|
+
i += 1
|
|
232
|
+
elsif processed[i] == "\t"
|
|
233
|
+
cursor[:col] = ((cursor[:col] / 8) + 1) * 8
|
|
234
|
+
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
235
|
+
i += 1
|
|
236
|
+
elsif processed[i] == "\b"
|
|
237
|
+
cursor[:col] -= 1 if cursor[:col].positive?
|
|
238
|
+
i += 1
|
|
239
|
+
elsif processed[i] == "\a"
|
|
240
|
+
# Bell — ignore
|
|
241
|
+
i += 1
|
|
242
|
+
elsif processed[i] == "\x0e"
|
|
243
|
+
active_charset = :g1
|
|
244
|
+
i += 1
|
|
245
|
+
elsif processed[i] == "\x0f"
|
|
246
|
+
active_charset = :g0
|
|
247
|
+
i += 1
|
|
248
|
+
elsif processed[i] == "\e"
|
|
249
|
+
# Handle non-CSI escape sequences
|
|
250
|
+
if processed[i + 1] == "7"
|
|
251
|
+
# DECSC — Save Cursor
|
|
252
|
+
if use_alt_screen
|
|
253
|
+
alt_saved_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
254
|
+
saved_cursor = alt_saved_cursor
|
|
255
|
+
else
|
|
256
|
+
normal_saved_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
257
|
+
saved_cursor = normal_saved_cursor
|
|
258
|
+
end
|
|
259
|
+
i += 2
|
|
260
|
+
elsif processed[i + 1] == "8"
|
|
261
|
+
# DECRC — Restore Cursor
|
|
262
|
+
if saved_cursor
|
|
263
|
+
cursor[:row] = saved_cursor[:row]
|
|
264
|
+
cursor[:col] = saved_cursor[:col]
|
|
265
|
+
end
|
|
266
|
+
i += 2
|
|
267
|
+
elsif processed[i + 1] == "(" && %w[0 B].include?(processed[i + 2])
|
|
268
|
+
g0_charset = (processed[i + 2] == "0" ? :dec : :ascii)
|
|
269
|
+
i += 3
|
|
270
|
+
elsif processed[i + 1] == ")" && %w[0 B].include?(processed[i + 2])
|
|
271
|
+
g1_charset = (processed[i + 2] == "0" ? :dec : :ascii)
|
|
272
|
+
i += 3
|
|
273
|
+
elsif processed[i + 1]&.match?(%r{[()*+\-./]})
|
|
274
|
+
# Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
|
|
275
|
+
i += 3
|
|
276
|
+
else
|
|
277
|
+
i += 1
|
|
278
|
+
end
|
|
279
|
+
elsif (char, char_len = _utf8_char_at(processed, i))
|
|
280
|
+
# Printable character (including multi-byte UTF-8)
|
|
281
|
+
# cursor row/col are always clamped within bounds
|
|
282
|
+
cell = grid[cursor[:row]][cursor[:col]]
|
|
283
|
+
current_charset = (active_charset == :g1 ? g1_charset : g0_charset)
|
|
284
|
+
mapped_char = char
|
|
285
|
+
mapped_char = DEC_MAP[char] if current_charset == :dec && DEC_MAP.key?(char)
|
|
286
|
+
cell[:char] = mapped_char
|
|
287
|
+
cell.merge!(attrs)
|
|
288
|
+
cursor[:col] += 1
|
|
289
|
+
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
290
|
+
i += char_len
|
|
291
|
+
else # rubocop:disable Lint/DuplicateBranch
|
|
292
|
+
i += 1
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Handle scrolling within the defined scroll region
|
|
296
|
+
region_top = scroll_region[:top]
|
|
297
|
+
region_bottom = scroll_region[:bottom]
|
|
298
|
+
|
|
299
|
+
next unless cursor[:row] > region_bottom
|
|
300
|
+
|
|
301
|
+
scroll_lines = [cursor[:row] - region_bottom, rows].min
|
|
302
|
+
# Shift lines within the scroll region up
|
|
303
|
+
(region_top..(region_bottom - scroll_lines)).each do |ri|
|
|
304
|
+
src = ri + scroll_lines
|
|
305
|
+
grid[ri] = grid[src]
|
|
306
|
+
end
|
|
307
|
+
# Fill bottom of scroll region with blank lines
|
|
308
|
+
((region_bottom - scroll_lines + 1)..region_bottom).each do |ri|
|
|
309
|
+
grid[ri] = Array.new(cols) { default_cell.dup }
|
|
310
|
+
end
|
|
311
|
+
cursor[:row] = region_bottom
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
{
|
|
315
|
+
size: { rows: rows, cols: cols },
|
|
316
|
+
cursor: {
|
|
317
|
+
row: cursor[:row],
|
|
318
|
+
col: cursor[:col],
|
|
319
|
+
visible: cursor_visible,
|
|
320
|
+
style: cursor_style,
|
|
321
|
+
},
|
|
322
|
+
rows: grid,
|
|
323
|
+
pending_dsr: pending_dsr,
|
|
324
|
+
cursor_visible: cursor_visible,
|
|
325
|
+
cursor_style: cursor_style,
|
|
326
|
+
mouse_mode: mouse_mode,
|
|
327
|
+
mouse_format: mouse_format,
|
|
328
|
+
}
|
|
329
|
+
end
|
|
330
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
331
|
+
|
|
332
|
+
# Rebuild ANSI output from a state hash (for rendering/screenshot)
|
|
333
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
334
|
+
def self.build_frame(state)
|
|
335
|
+
rows = state.dig(:size, :rows) || state["size"]["rows"]
|
|
336
|
+
state.dig(:size, :cols) || state["size"]["cols"]
|
|
337
|
+
grid = state[:rows] || state["rows"]
|
|
338
|
+
cursor = state[:cursor] || state["cursor"]
|
|
339
|
+
mouse_mode = state[:mouse_mode] || state["mouse_mode"] || :none
|
|
340
|
+
mouse_format = state[:mouse_format] || state["mouse_format"] || :normal
|
|
341
|
+
|
|
342
|
+
out = +""
|
|
343
|
+
out << "\e[0m"
|
|
344
|
+
out << "\e[2J\e[H"
|
|
345
|
+
|
|
346
|
+
grid.each_with_index do |row, ri|
|
|
347
|
+
row.each_with_index do |cell, _ci|
|
|
348
|
+
char = cell[:char] || cell["char"] || " "
|
|
349
|
+
fg = cell[:fg] || cell["fg"] || "default"
|
|
350
|
+
bg = cell[:bg] || cell["bg"] || "default"
|
|
351
|
+
bold = cell[:bold] || cell["bold"] || false
|
|
352
|
+
italic = cell[:italic] || cell["italic"] || false
|
|
353
|
+
underline = cell[:underline] || cell["underline"] || false
|
|
354
|
+
blink = cell[:blink] || cell["blink"] || false
|
|
355
|
+
|
|
356
|
+
codes = []
|
|
357
|
+
codes << "1" if bold
|
|
358
|
+
codes << "3" if italic
|
|
359
|
+
codes << "4" if underline
|
|
360
|
+
codes << "5" if blink
|
|
361
|
+
|
|
362
|
+
fg_code = _color_code(fg, "38")
|
|
363
|
+
bg_code = _color_code(bg, "48")
|
|
364
|
+
|
|
365
|
+
codes << fg_code if fg_code
|
|
366
|
+
codes << bg_code if bg_code
|
|
367
|
+
|
|
368
|
+
out << "\e[#{codes.join(";")}m" unless codes.empty?
|
|
369
|
+
out << char
|
|
370
|
+
end
|
|
371
|
+
out << "\n" if ri < rows - 1
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Reconstruct cursor visibility
|
|
375
|
+
cursor_vis = true
|
|
376
|
+
cursor_vis = cursor[:visible] != false && cursor["visible"] != false if cursor.is_a?(Hash)
|
|
377
|
+
out << (cursor_vis ? "\e[?25h" : "\e[?25l")
|
|
378
|
+
|
|
379
|
+
# Reconstruct cursor style
|
|
380
|
+
if cursor.is_a?(Hash)
|
|
381
|
+
style = cursor[:style] || cursor["style"]
|
|
382
|
+
out << "\e[#{style} q" if style
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Reconstruct mouse mode and format
|
|
386
|
+
out << case mouse_mode
|
|
387
|
+
when :normal
|
|
388
|
+
"\e[?1000h"
|
|
389
|
+
when :drag
|
|
390
|
+
"\e[?1002h"
|
|
391
|
+
when :all
|
|
392
|
+
"\e[?1003h"
|
|
393
|
+
else
|
|
394
|
+
"\e[?1000l\e[?1002l\e[?1003l"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
out << if mouse_format == :sgr
|
|
398
|
+
"\e[?1006h"
|
|
399
|
+
else
|
|
400
|
+
"\e[?1006l"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
out << "\e[0m"
|
|
404
|
+
out
|
|
405
|
+
end
|
|
406
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
407
|
+
|
|
408
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
409
|
+
def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
410
|
+
# Strip leading escape char if present
|
|
411
|
+
cleaned = seq.sub(/^\e/, "")
|
|
412
|
+
match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@`fhlmnRrsuq])$/)
|
|
413
|
+
return [false, nil, {}] unless match
|
|
414
|
+
|
|
415
|
+
is_private = (match[1] == "?")
|
|
416
|
+
params = match[2].split(";").map(&:to_i)
|
|
417
|
+
match[3]
|
|
418
|
+
command = match[4]
|
|
419
|
+
|
|
420
|
+
new_saved = nil
|
|
421
|
+
action = {}
|
|
422
|
+
|
|
423
|
+
case command
|
|
424
|
+
when "m"
|
|
425
|
+
_apply_sgr(params, attrs)
|
|
426
|
+
when "A" # CUU — Cursor Up
|
|
427
|
+
n = params[0] || 1
|
|
428
|
+
n = 1 if n.zero?
|
|
429
|
+
cursor[:row] = [cursor[:row] - n, 0].max
|
|
430
|
+
when "B" # CUD — Cursor Down
|
|
431
|
+
n = params[0] || 1
|
|
432
|
+
n = 1 if n.zero?
|
|
433
|
+
cursor[:row] = [cursor[:row] + n, rows - 1].min
|
|
434
|
+
when "C" # CUF — Cursor Forward
|
|
435
|
+
n = params[0] || 1
|
|
436
|
+
n = 1 if n.zero?
|
|
437
|
+
cursor[:col] = [cursor[:col] + n, cols - 1].min
|
|
438
|
+
when "D" # CUB — Cursor Back
|
|
439
|
+
n = params[0] || 1
|
|
440
|
+
n = 1 if n.zero?
|
|
441
|
+
cursor[:col] = [cursor[:col] - n, 0].max
|
|
442
|
+
when "H", "f" # CUP — Cursor Position
|
|
443
|
+
r = (params[0] || 1) - 1
|
|
444
|
+
c = (params[1] || 1) - 1
|
|
445
|
+
cursor[:row] = r.clamp(0, rows - 1)
|
|
446
|
+
cursor[:col] = c.clamp(0, cols - 1)
|
|
447
|
+
when "J" # ED — Erase in Display
|
|
448
|
+
case params[0]
|
|
449
|
+
when nil, 0
|
|
450
|
+
_erase_down(cursor, grid, rows, cols)
|
|
451
|
+
when 1
|
|
452
|
+
_erase_up(cursor, grid, cols)
|
|
453
|
+
when 2, 3
|
|
454
|
+
_erase_all(grid, rows, cols)
|
|
455
|
+
cursor[:row] = 0
|
|
456
|
+
cursor[:col] = 0
|
|
457
|
+
end
|
|
458
|
+
when "K" # EL — Erase in Line
|
|
459
|
+
case params[0]
|
|
460
|
+
when nil, 0
|
|
461
|
+
_erase_line_right(cursor, grid, cols)
|
|
462
|
+
when 1
|
|
463
|
+
_erase_line_left(cursor, grid, cols)
|
|
464
|
+
when 2
|
|
465
|
+
_erase_line(cursor, grid, cols)
|
|
466
|
+
end
|
|
467
|
+
when "X" # Erase Characters
|
|
468
|
+
n = params[0] || 1
|
|
469
|
+
n.times do |i|
|
|
470
|
+
next unless cursor[:row] < rows && cursor[:col] + i < cols
|
|
471
|
+
|
|
472
|
+
grid[cursor[:row]][cursor[:col] + i][:char] = " "
|
|
473
|
+
end
|
|
474
|
+
when "s" # DECSC — Save Cursor (CSI variant)
|
|
475
|
+
new_saved = { row: cursor[:row], col: cursor[:col] }
|
|
476
|
+
when "u" # DECRC — Restore Cursor (CSI variant)
|
|
477
|
+
if saved_cursor
|
|
478
|
+
cursor[:row] = saved_cursor[:row]
|
|
479
|
+
cursor[:col] = saved_cursor[:col]
|
|
480
|
+
end
|
|
481
|
+
when "r" # DECSTBM — Set Scroll Region
|
|
482
|
+
top = (params[0] || 1) - 1
|
|
483
|
+
bottom = (params[1] || rows) - 1
|
|
484
|
+
top = top.clamp(0, rows - 1)
|
|
485
|
+
bottom = bottom.clamp(0, rows - 1)
|
|
486
|
+
if top < bottom
|
|
487
|
+
scroll_region[:top] = top
|
|
488
|
+
scroll_region[:bottom] = bottom
|
|
489
|
+
else
|
|
490
|
+
scroll_region[:top] = 0
|
|
491
|
+
scroll_region[:bottom] = rows - 1
|
|
492
|
+
end
|
|
493
|
+
cursor[:row] = 0
|
|
494
|
+
cursor[:col] = 0
|
|
495
|
+
when "h"
|
|
496
|
+
if is_private
|
|
497
|
+
params.each do |p|
|
|
498
|
+
case p
|
|
499
|
+
when 47, 1047, 1049
|
|
500
|
+
action[:alt_screen] = true
|
|
501
|
+
action[:alt_screen_code] = p
|
|
502
|
+
when 25
|
|
503
|
+
action[:cursor_visible] = true
|
|
504
|
+
when 1000
|
|
505
|
+
action[:mouse_mode] = :normal
|
|
506
|
+
when 1002
|
|
507
|
+
action[:mouse_mode] = :drag
|
|
508
|
+
when 1003
|
|
509
|
+
action[:mouse_mode] = :all
|
|
510
|
+
when 1006
|
|
511
|
+
action[:mouse_format] = :sgr
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
when "l"
|
|
516
|
+
if is_private
|
|
517
|
+
params.each do |p|
|
|
518
|
+
case p
|
|
519
|
+
when 47, 1047, 1049
|
|
520
|
+
action[:alt_screen] = false
|
|
521
|
+
action[:alt_screen_code] = p
|
|
522
|
+
when 25
|
|
523
|
+
action[:cursor_visible] = false
|
|
524
|
+
when 1000, 1002, 1003
|
|
525
|
+
action[:mouse_mode] = :none
|
|
526
|
+
when 1006
|
|
527
|
+
action[:mouse_format] = :normal
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
when "q"
|
|
532
|
+
style_val = params[0] || 0
|
|
533
|
+
action[:cursor_style] = style_val
|
|
534
|
+
when "n" # DSR — Device Status Report request
|
|
535
|
+
return [params[0] == 6, nil, {}]
|
|
536
|
+
when "R" # DSR response (from terminal side) or CPR — ignore
|
|
537
|
+
nil
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
[false, new_saved, action]
|
|
541
|
+
end
|
|
542
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
543
|
+
|
|
544
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
545
|
+
def self._apply_sgr(params, attrs)
|
|
546
|
+
if params.empty? || params == [0]
|
|
547
|
+
return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false,
|
|
548
|
+
blink: false,)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
i = 0
|
|
552
|
+
while i < params.length
|
|
553
|
+
p = params[i]
|
|
554
|
+
case p
|
|
555
|
+
when 0
|
|
556
|
+
attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false)
|
|
557
|
+
when 1
|
|
558
|
+
attrs[:bold] = true
|
|
559
|
+
when 3
|
|
560
|
+
attrs[:italic] = true
|
|
561
|
+
when 4
|
|
562
|
+
attrs[:underline] = true
|
|
563
|
+
when 5, 6
|
|
564
|
+
attrs[:blink] = true
|
|
565
|
+
when 22
|
|
566
|
+
attrs[:bold] = false
|
|
567
|
+
when 23
|
|
568
|
+
attrs[:italic] = false
|
|
569
|
+
when 24
|
|
570
|
+
attrs[:underline] = false
|
|
571
|
+
when 25
|
|
572
|
+
attrs[:blink] = false
|
|
573
|
+
when 7, 27
|
|
574
|
+
# Reverse — swap fg and bg
|
|
575
|
+
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
576
|
+
when 30..37
|
|
577
|
+
attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
|
|
578
|
+
when 38
|
|
579
|
+
# Extended foreground
|
|
580
|
+
if params[i + 1] == 5
|
|
581
|
+
color = params[i + 2]
|
|
582
|
+
attrs[:fg] = "color#{color}"
|
|
583
|
+
i += 2
|
|
584
|
+
# :nocov:
|
|
585
|
+
elsif params[i + 1] == 2
|
|
586
|
+
# :nocov:
|
|
587
|
+
r = params[i + 2]
|
|
588
|
+
g = params[i + 3]
|
|
589
|
+
b = params[i + 4]
|
|
590
|
+
attrs[:fg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
|
|
591
|
+
i += 4
|
|
592
|
+
end
|
|
593
|
+
when 39
|
|
594
|
+
attrs[:fg] = "default"
|
|
595
|
+
when 40..47
|
|
596
|
+
attrs[:bg] = SGR_16_TO_NAME[p - 40] || "bg_color#{p - 40}"
|
|
597
|
+
when 48
|
|
598
|
+
# Extended background
|
|
599
|
+
if params[i + 1] == 5
|
|
600
|
+
color = params[i + 2]
|
|
601
|
+
attrs[:bg] = "color#{color}"
|
|
602
|
+
i += 2
|
|
603
|
+
elsif params[i + 1] == 2
|
|
604
|
+
r = params[i + 2]
|
|
605
|
+
g = params[i + 3]
|
|
606
|
+
b = params[i + 4]
|
|
607
|
+
attrs[:bg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
|
|
608
|
+
i += 4
|
|
609
|
+
end
|
|
610
|
+
when 49
|
|
611
|
+
attrs[:bg] = "default"
|
|
612
|
+
when 90..97
|
|
613
|
+
attrs[:fg] = "bright_#{SGR_16_TO_NAME[p - 90] || "color#{p - 90 + 8}"}"
|
|
614
|
+
when 100..107
|
|
615
|
+
attrs[:bg] = "bright_#{SGR_16_TO_NAME[p - 100] || "color#{p - 100 + 8}"}"
|
|
616
|
+
end
|
|
617
|
+
i += 1
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
621
|
+
|
|
622
|
+
def self._erase_down(cursor, grid, rows, cols)
|
|
623
|
+
r = cursor[:row]
|
|
624
|
+
c = cursor[:col]
|
|
625
|
+
|
|
626
|
+
# Erase from cursor to end of line
|
|
627
|
+
(c...cols).each { |ci| _erase_cell(grid[r][ci]) if r < rows }
|
|
628
|
+
|
|
629
|
+
# Erase remaining lines
|
|
630
|
+
((r + 1)...rows).each do |ri|
|
|
631
|
+
cols.times { |ci| _erase_cell(grid[ri][ci]) }
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def self._erase_up(cursor, grid, cols)
|
|
636
|
+
r = cursor[:row]
|
|
637
|
+
c = cursor[:col]
|
|
638
|
+
|
|
639
|
+
# Erase lines above cursor
|
|
640
|
+
(0...r).each do |ri|
|
|
641
|
+
cols.times { |ci| _erase_cell(grid[ri][ci]) }
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Erase from start of line to cursor
|
|
645
|
+
(0..c).each { |ci| _erase_cell(grid[r][ci]) }
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def self._erase_all(grid, rows, cols)
|
|
649
|
+
rows.times do |ri|
|
|
650
|
+
cols.times { |ci| _erase_cell(grid[ri][ci]) }
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def self._erase_cell(cell)
|
|
655
|
+
cell.merge!(default_cell)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def self._erase_line_right(cursor, grid, cols)
|
|
659
|
+
r = cursor[:row]
|
|
660
|
+
c = cursor[:col]
|
|
661
|
+
(c...cols).each { |ci| _erase_cell(grid[r][ci]) if r < grid.length }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def self._erase_line_left(cursor, grid, _cols)
|
|
665
|
+
r = cursor[:row]
|
|
666
|
+
c = cursor[:col]
|
|
667
|
+
(0..c).each { |ci| _erase_cell(grid[r][ci]) if r < grid.length }
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def self._erase_line(cursor, grid, cols)
|
|
671
|
+
r = cursor[:row]
|
|
672
|
+
cols.times { |ci| _erase_cell(grid[r][ci]) if r < grid.length }
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
676
|
+
def self._color_code(name, prefix)
|
|
677
|
+
case name
|
|
678
|
+
when "default" then nil
|
|
679
|
+
when /^#([0-9a-fA-F]{6})$/
|
|
680
|
+
r = ::Regexp.last_match(1)[0..1].to_i(16)
|
|
681
|
+
g = ::Regexp.last_match(1)[2..3].to_i(16)
|
|
682
|
+
b = ::Regexp.last_match(1)[4..5].to_i(16)
|
|
683
|
+
"#{prefix};2;#{r};#{g};#{b}"
|
|
684
|
+
when /^(bright_)?(.+)$/
|
|
685
|
+
base_name = ::Regexp.last_match(2)
|
|
686
|
+
index = SGR_16_TO_NAME.key(base_name)
|
|
687
|
+
index += 8 if ::Regexp.last_match(1) && index && index < 8
|
|
688
|
+
index ? "#{prefix};5;#{index}" : nil
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
692
|
+
|
|
693
|
+
def self.default_cell
|
|
694
|
+
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Extract a single UTF-8 character at position i in a binary string.
|
|
698
|
+
# Returns [char_string, byte_length] or nil if the byte is not printable/valid.
|
|
699
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
|
700
|
+
def self._utf8_char_at(str, i)
|
|
701
|
+
byte = str.getbyte(i)
|
|
702
|
+
return nil unless byte
|
|
703
|
+
|
|
704
|
+
if byte < 0x80
|
|
705
|
+
# Single-byte ASCII
|
|
706
|
+
return nil unless byte >= 0x20 # only printable, skip control chars
|
|
707
|
+
|
|
708
|
+
return [byte.chr, 1]
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Multi-byte UTF-8
|
|
712
|
+
len = if byte & 0xE0 == 0xC0
|
|
713
|
+
2
|
|
714
|
+
elsif byte & 0xF0 == 0xE0
|
|
715
|
+
3
|
|
716
|
+
elsif byte & 0xF8 == 0xF0
|
|
717
|
+
4
|
|
718
|
+
else
|
|
719
|
+
return nil # continuation byte or invalid — let main loop advance
|
|
720
|
+
end
|
|
721
|
+
return nil if i + len > str.bytesize
|
|
722
|
+
|
|
723
|
+
bytes = str.byteslice(i, len)
|
|
724
|
+
char = bytes.dup.force_encoding("UTF-8")
|
|
725
|
+
return nil unless char.valid_encoding?
|
|
726
|
+
|
|
727
|
+
[char, len]
|
|
728
|
+
# :nocov:
|
|
729
|
+
rescue StandardError
|
|
730
|
+
nil
|
|
731
|
+
# :nocov:
|
|
732
|
+
end
|
|
733
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
|
734
|
+
end
|
|
735
|
+
# rubocop:enable Metrics/ModuleLength
|
|
736
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TansParser
|
|
4
|
+
# Shared ANSI color constants and helpers.
|
|
5
|
+
# Used by Screenshot, HtmlRenderer, and other color-aware renderers.
|
|
6
|
+
module ANSIUtils
|
|
7
|
+
ANSI_RGB = {
|
|
8
|
+
"black" => [0x00, 0x00, 0x00],
|
|
9
|
+
"red" => [0xAA, 0x00, 0x00],
|
|
10
|
+
"green" => [0x00, 0xAA, 0x00],
|
|
11
|
+
"yellow" => [0xAA, 0x55, 0x00],
|
|
12
|
+
"blue" => [0x00, 0x00, 0xAA],
|
|
13
|
+
"magenta" => [0xAA, 0x00, 0xAA],
|
|
14
|
+
"cyan" => [0x00, 0xAA, 0xAA],
|
|
15
|
+
"white" => [0xAA, 0xAA, 0xAA],
|
|
16
|
+
"bright_black" => [0x55, 0x55, 0x55],
|
|
17
|
+
"bright_red" => [0xFF, 0x55, 0x55],
|
|
18
|
+
"bright_green" => [0x55, 0xFF, 0x55],
|
|
19
|
+
"bright_yellow" => [0xFF, 0xFF, 0x55],
|
|
20
|
+
"bright_blue" => [0x55, 0x55, 0xFF],
|
|
21
|
+
"bright_magenta" => [0xFF, 0x55, 0xFF],
|
|
22
|
+
"bright_cyan" => [0x55, 0xFF, 0xFF],
|
|
23
|
+
"bright_white" => [0xFF, 0xFF, 0xFF],
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
|
|
27
|
+
|
|
28
|
+
ANSI_INDEX = %w[
|
|
29
|
+
black red green yellow blue magenta cyan white
|
|
30
|
+
bright_black bright_red bright_green bright_yellow
|
|
31
|
+
bright_blue bright_magenta bright_cyan bright_white
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
|
|
35
|
+
DEFAULT_BG = [0x00, 0x00, 0x00].freeze
|
|
36
|
+
|
|
37
|
+
def resolve_color(name, fallback)
|
|
38
|
+
case name
|
|
39
|
+
when "default"
|
|
40
|
+
fallback
|
|
41
|
+
when /^#([0-9a-fA-F]{6})$/
|
|
42
|
+
[::Regexp.last_match(1)[0..1].to_i(16), ::Regexp.last_match(1)[2..3].to_i(16),
|
|
43
|
+
::Regexp.last_match(1)[4..5].to_i(16),]
|
|
44
|
+
when /\Acolor(\d+)\z/
|
|
45
|
+
xterm_256(::Regexp.last_match(1).to_i)
|
|
46
|
+
when /\Abright_(.+)\z/
|
|
47
|
+
ANSI_RGB[name] || fallback
|
|
48
|
+
else # rubocop:disable Lint/DuplicateBranch
|
|
49
|
+
ANSI_RGB[name] || fallback
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def xterm_256(index) # rubocop:disable Naming/VariableNumber
|
|
54
|
+
if index < 16
|
|
55
|
+
name = ANSI_INDEX[index]
|
|
56
|
+
ANSI_RGB[name] || DEFAULT_FG
|
|
57
|
+
elsif index < 232
|
|
58
|
+
r = CUBE[((index - 16) / 36) % 6]
|
|
59
|
+
g = CUBE[((index - 16) / 6) % 6]
|
|
60
|
+
b = CUBE[(index - 16) % 6]
|
|
61
|
+
[r, g, b]
|
|
62
|
+
else
|
|
63
|
+
v = 8 + ((index - 232) * 10)
|
|
64
|
+
[v, v, v]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def _dig(hash, *keys)
|
|
69
|
+
keys.each do |k|
|
|
70
|
+
return nil unless hash
|
|
71
|
+
|
|
72
|
+
hash = hash[k] || hash[k.to_s]
|
|
73
|
+
end
|
|
74
|
+
hash
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
4
|
+
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module TansParser
|
|
8
|
+
# Represents the parsed state of a terminal screen.
|
|
9
|
+
# Provides high-level query methods for AI consumption.
|
|
10
|
+
class State
|
|
11
|
+
attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
|
|
12
|
+
|
|
13
|
+
def initialize(data)
|
|
14
|
+
raise ArgumentError, "State data must include :size key" unless data[:size]
|
|
15
|
+
raise ArgumentError, "State data must include :rows key" unless data[:rows]
|
|
16
|
+
|
|
17
|
+
@rows = data[:size][:rows]
|
|
18
|
+
@cols = data[:size][:cols]
|
|
19
|
+
@grid = data[:rows]
|
|
20
|
+
@cursor = data[:cursor]
|
|
21
|
+
|
|
22
|
+
cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
|
|
23
|
+
@cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
|
|
24
|
+
@cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
|
|
25
|
+
|
|
26
|
+
@mouse_mode = data[:mouse_mode] || :none
|
|
27
|
+
@mouse_format = data[:mouse_format] || :normal
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get plain text of the entire terminal (no ANSI)
|
|
31
|
+
def plain_text
|
|
32
|
+
@grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get text at a specific position
|
|
36
|
+
def text_at(row, col, length = @cols - col)
|
|
37
|
+
return "" if row >= @rows || col >= @cols
|
|
38
|
+
|
|
39
|
+
@grid[row][col, length].map { |c| c[:char] }.join
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Search for text across the entire terminal.
|
|
43
|
+
# For regex patterns, matching is bounded by a timeout to prevent ReDoS.
|
|
44
|
+
TEXT_SEARCH_TIMEOUT = 5
|
|
45
|
+
|
|
46
|
+
def find_text(pattern)
|
|
47
|
+
results = []
|
|
48
|
+
is_regex = pattern.is_a?(Regexp)
|
|
49
|
+
|
|
50
|
+
@grid.each_with_index do |row, ri|
|
|
51
|
+
text = row.map { |c| c[:char] }.join
|
|
52
|
+
pos = 0
|
|
53
|
+
begin
|
|
54
|
+
if is_regex
|
|
55
|
+
Timeout.timeout(TEXT_SEARCH_TIMEOUT) do
|
|
56
|
+
while (match = text.index(pattern, pos))
|
|
57
|
+
results << { row: ri, col: match, text: pattern.to_s, full_line: text }
|
|
58
|
+
pos = match + 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
while (match = text.index(pattern, pos))
|
|
63
|
+
results << { row: ri, col: match, text: pattern, full_line: text }
|
|
64
|
+
pos = match + 1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue Timeout::Error
|
|
68
|
+
# Stop processing on timeout — return partial results
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
results
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get the color at a specific cell
|
|
75
|
+
def foreground_at(row, col)
|
|
76
|
+
return nil if row >= @rows || col >= @cols
|
|
77
|
+
|
|
78
|
+
@grid[row][col][:fg]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def background_at(row, col)
|
|
82
|
+
return nil if row >= @rows || col >= @cols
|
|
83
|
+
|
|
84
|
+
@grid[row][col][:bg]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def style_at(row, col)
|
|
88
|
+
return nil if row >= @rows || col >= @cols
|
|
89
|
+
|
|
90
|
+
cell = @grid[row][col]
|
|
91
|
+
{ bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_ai_json
|
|
95
|
+
h = extract_highlights
|
|
96
|
+
cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
|
|
97
|
+
r = cursor_info[:row] || cursor_info["row"] || 0
|
|
98
|
+
c = cursor_info[:col] || cursor_info["col"] || 0
|
|
99
|
+
styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
|
|
100
|
+
|
|
101
|
+
summary = "Cursor at [#{r},#{c}]. "
|
|
102
|
+
summary << "#{styled_count} styled row#{"s" unless styled_count == 1}"
|
|
103
|
+
fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
|
|
104
|
+
bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
|
|
105
|
+
summary << ", colors: fg=#{fgs.sort.join(",")}" unless fgs.empty?
|
|
106
|
+
summary << ", bg=#{bgs.sort.join(",")}" unless bgs.empty?
|
|
107
|
+
summary << "."
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
size: { rows: @rows, cols: @cols },
|
|
111
|
+
cursor: cursor_info,
|
|
112
|
+
text: plain_text,
|
|
113
|
+
highlights: h,
|
|
114
|
+
summary: summary,
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def extract_highlights
|
|
121
|
+
highlights = []
|
|
122
|
+
@grid.each_with_index do |row, ri|
|
|
123
|
+
row_text = row.map { |c| c[:char] }.join
|
|
124
|
+
next if row_text.strip.empty?
|
|
125
|
+
|
|
126
|
+
fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
|
|
127
|
+
.uniq.reject { |c| c == "default" }
|
|
128
|
+
bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
|
|
129
|
+
.uniq.reject { |c| c == "default" }
|
|
130
|
+
bold = row.any? { |c| c[:bold] || c["bold"] }
|
|
131
|
+
italic = row.any? { |c| c[:italic] || c["italic"] }
|
|
132
|
+
underline = row.any? { |c| c[:underline] || c["underline"] }
|
|
133
|
+
|
|
134
|
+
next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
|
|
135
|
+
|
|
136
|
+
h = { row: ri, text: row_text }
|
|
137
|
+
h[:bold] = true if bold
|
|
138
|
+
h[:italic] = true if italic
|
|
139
|
+
h[:underline] = true if underline
|
|
140
|
+
h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
|
|
141
|
+
h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
|
|
142
|
+
highlights << h
|
|
143
|
+
end
|
|
144
|
+
highlights
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tans-parser
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Haluk Durmus
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bundler-audit
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.9'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.9'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pry
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.14'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.14'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: reek
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '6.3'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '6.3'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rspec
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.12'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.12'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.50'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.50'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop-rake
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.7'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.7'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: rubocop-rspec
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.6'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.6'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: simplecov
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0.22'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0.22'
|
|
124
|
+
description: tans-parser parses raw terminal output with ANSI escape sequences into
|
|
125
|
+
a structured grid representation with per-cell attributes (char, fg, bg, bold, italic,
|
|
126
|
+
underline, blink). Includes a query API (State) for text search, color inspection,
|
|
127
|
+
and AI-friendly JSON output.
|
|
128
|
+
email:
|
|
129
|
+
- haluk_durmus@yahoo.de
|
|
130
|
+
executables: []
|
|
131
|
+
extensions: []
|
|
132
|
+
extra_rdoc_files: []
|
|
133
|
+
files:
|
|
134
|
+
- CHANGELOG.md
|
|
135
|
+
- LICENSE.txt
|
|
136
|
+
- README.md
|
|
137
|
+
- lib/tans-parser.rb
|
|
138
|
+
- lib/tans_parser/ansi_parser.rb
|
|
139
|
+
- lib/tans_parser/ansi_utils.rb
|
|
140
|
+
- lib/tans_parser/state.rb
|
|
141
|
+
- lib/tans_parser/version.rb
|
|
142
|
+
homepage: https://github.com/vurte/tans-parser
|
|
143
|
+
licenses:
|
|
144
|
+
- MIT
|
|
145
|
+
metadata:
|
|
146
|
+
homepage_uri: https://github.com/vurte/tans-parser
|
|
147
|
+
source_code_uri: https://github.com/vurte/tans-parser
|
|
148
|
+
rubygems_mfa_required: 'true'
|
|
149
|
+
rdoc_options: []
|
|
150
|
+
require_paths:
|
|
151
|
+
- lib
|
|
152
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
153
|
+
requirements:
|
|
154
|
+
- - ">="
|
|
155
|
+
- !ruby/object:Gem::Version
|
|
156
|
+
version: 3.0.0
|
|
157
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
158
|
+
requirements:
|
|
159
|
+
- - ">="
|
|
160
|
+
- !ruby/object:Gem::Version
|
|
161
|
+
version: '0'
|
|
162
|
+
requirements: []
|
|
163
|
+
rubygems_version: 4.0.6
|
|
164
|
+
specification_version: 4
|
|
165
|
+
summary: Terminal ANSI State Utils — parse ANSI terminal output into structured data
|
|
166
|
+
test_files: []
|