tans-parser 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c7b3233b668eeef9d7240a8cddd5f8389cc62fdf3c641103472696daab956f4
4
- data.tar.gz: 39391122997e84cde9b4313542a1f2071cc752f66d9577e1c008133359064223
3
+ metadata.gz: fc3b07fbdd59e595af3d07d3091cdb75b9faa0c4daeaaaf3232f7787d35406e4
4
+ data.tar.gz: 75f354d01a6881cc8bb702bb900b41b2f654ecae8b7d5033bc439ad876eeb97a
5
5
  SHA512:
6
- metadata.gz: 6489e45f59dbb4f7e7375108bc4f6f881fab6989415b5d44d09337bbaa166edb43974c1f6ebf8f1af068637dc7ee8bd604e0e6f55c8ffc0268f5fe610a23ec3e
7
- data.tar.gz: e485259b38989cc3f1953691113bc6f92ee6da125c9a0502f38f5550ca75703e0ec3919043f9297b9ee0611044701086b937cb88d2e32a8c5d7047ae6267c461
6
+ metadata.gz: 7d79356c65433991718c0c1854faa58f5a077a906ec653f13202d241b37fc01bb3fe253de8ba4fa9deb3b7bdee17965a1b2d2476a3b782c0a849c8b0696c4a65
7
+ data.tar.gz: 05045d158716d7e4ef9f87ecd2d4936735d1de681fb17cb97835a078b31654c9d0ed47022b610db7824d2a7408e82b66c34df738f1bc3b0ad7e67e40bfb4e111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.3
4
+
5
+ - **Dialog recognition** — added support for rounded corners (`╭╮╰╯`) and double-line (`╔╗╚╝`) box-drawing characters
6
+ - `TOP_LEFT_CORNERS` extended with `╭╔╓╒`
7
+ - `dialog_top_width` extended with `╮╗╖╕` (top-right) and `═` (double horizontal)
8
+ - **Statusbar recognition** — more flexible detection:
9
+ - Now checks last 2 rows instead of only the last row
10
+ - Fallback: detects last row as statusbar if it has ≥30 characters of content, even without background color info
11
+ - Handles Karat-style footers (`? for shortcuts | mock ctx ░░░░░░░░░░ 0%`)
12
+ - **Custom role registration** — `State#annotate_role(role, row:, col:, width:, height:, text:, **extra)`:
13
+ - Manually annotate grid regions with semantic roles
14
+ - `Selector#detect_annotations` picks them up during `scan` alongside auto-detected elements
15
+ - Annotations support filters (text, checked, disabled) and all convenience methods
16
+ - **State#diff** — cell-level comparison between two State instances:
17
+ - `diff(other_state)` — compares all 7 cell keys (`char`, `fg`, `bg`, `bold`, `italic`, `underline`, `blink`)
18
+ - `diff(other_state, chars_only: true)` — compares only `:char`, ignores style/color changes
19
+ - Handles different grid sizes (fills missing cells with `DEFAULT_CELL`)
20
+ - Accepts raw hash or State object
21
+ - 19 new tests, 319 total, 100% line and branch coverage maintained
22
+
23
+ ## 0.1.2
24
+
25
+ - **Flexible text search** — `State#find_text(pattern, match: :partial)` with three modes:
26
+ - `:partial` (default) — substring match (unchanged)
27
+ - `:exact` — row text must equal pattern (ignoring trailing whitespace)
28
+ - `:regex` — compile String to Regexp for matching
29
+ - Regexp objects now return the actual matched substring in `result[:text]`
30
+ - **Enhanced `get_by_role`** — filter keyword arguments:
31
+ - `text:` — partial match on element text
32
+ - `checked:` — filter by checked state (`true`/`false`)
33
+ - `disabled:` — filter by disabled state (`true`/`false`)
34
+ - All plural convenience methods (`buttons`, `checkboxes`, etc.) accept the same filters
35
+ - **New UI element roles**:
36
+ - `:input` — `[________]` underscore-filled brackets (text input fields)
37
+ - `:label` — `Word:` or `Multiple Words:` patterns
38
+ - `:menu` — menu bars (row 0–1, spaced words) and `> Item` dropdown items
39
+ - `:tab` — closely-spaced `[Tab1] [Tab2]` brackets, with `focused` detection via underline/background
40
+ - **Singular convenience methods** — return first matching Element or `nil`:
41
+ - `button`, `checkbox`, `input`, `dialog`, `label`, `menu`, `tab`, `statusbar`, `progress_bar`
42
+ - All accept the same filter kwargs (`text:`, `checked:`, `disabled:`)
43
+ - Existing plural methods (`buttons`, `checkboxes`, etc.) unchanged
44
+ - **Element actions** — `Element` now has action methods returning descriptive hashes:
45
+ - `click` → `{action: :click, target:, row:, col:}`
46
+ - `type(text)` → `{action: :type, target:, row:, col:, text:}`
47
+ - `press_key(key)` → `{action: :press_key, target:, key:}`
48
+ - **Element predicates** — `checked?` and `disabled?` (always return boolean)
49
+ - **Element `bounds`** — returns `{row:, col:, width:, height:}`
50
+ - **`disabled` field** — added to Element struct
51
+ - **Scoping (`within`)** — `Selector#within(element, &block)` with `TansParser::ScopedSelector`:
52
+ - `get_by_role`, `get_by_text`, `find_text` restricted to element's bounding box
53
+ - All convenience methods (singular + plural) available inside scope
54
+ - Works with or without block
55
+ - **Button detection** — now skips checkbox markers (`[x]`, `[X]`, `[*]`, `[ ]`) and underscore-only brackets
56
+ - 105 new tests, 100% line and branch coverage maintained (300 total)
57
+
3
58
  ## 0.1.1
4
59
 
5
60
  - `TansParser::Element` — value object for recognized UI elements (role, text, position, size, colors)
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # tans-parser
2
2
 
3
3
  Parse raw terminal output with ANSI escape sequences into structured, queryable data.
4
+ Recognizes UI elements heuristically for AI-driven terminal interaction.
4
5
 
5
6
  ## Installation
6
7
 
@@ -36,9 +37,12 @@ state = TansParser::State.new(state_data)
36
37
  state.plain_text
37
38
  # => "ERROR: Something went wrong\nOK: All good"
38
39
 
39
- # Search for text
40
- state.find_text("ERROR")
41
- # => [{row: 0, col: 0, text: "ERROR", full_line: "ERROR: Something went wrong"}]
40
+ # Text search — three match modes
41
+ state.find_text("ERROR") # :partial (default) — substring
42
+ state.find_text("ERROR", match: :exact) # :exact row text must equal
43
+ state.find_text("\\d+", match: :regex) # :regex — compile string to Regexp
44
+ state.find_text(/\d{3}/) # Regexp object also supported
45
+ # => [{row:, col:, text:, full_line:}, ...]
42
46
 
43
47
  # Cell-level queries
44
48
  state.foreground_at(0, 0) # => "red"
@@ -74,34 +78,153 @@ xterm_256(16) # => [0x00, 0x00, 0x00]
74
78
  state = TansParser::State.new(state_data)
75
79
  selector = TansParser::Selector.new(state)
76
80
 
77
- # Find UI elements by role
78
- selector.buttons # => [Element, ...] — [ OK ], (Cancel), <Submit>
79
- selector.checkboxes # => [Element, ...] — [x], [*], [ ] at line starts
80
- selector.dialogs # => [Element, ...] — box-drawing character regions (┌─┐│└┘)
81
- selector.statusbars # => [Element, ...] bottom row with non-default background
82
- selector.progress_bars # => [Element, ...] [#### ], [====> ] patterns
81
+ # Find UI elements by role (plural — returns Array)
82
+ selector.buttons # [ OK ], (Cancel), <Submit>
83
+ selector.checkboxes # [x], [*], [ ] at line starts
84
+ selector.inputs # [________] underscore-filled brackets
85
+ selector.labels # Name: patterns (text followed by colon)
86
+ selector.menus # Menu bars (row 0–1) and > dropdown items
87
+ selector.tabs # Closely-spaced [Tab1] [Tab2] brackets
88
+ selector.dialogs # Box-drawing character regions (┌─┐│└┘)
89
+ selector.statusbars # Bottom row with non-default background
90
+ selector.progress_bars # [#### ], [====> ] patterns
91
+
92
+ # Singular convenience methods — return Element or nil
93
+ selector.button # first button
94
+ selector.checkbox(text: "Save") # first matching checkbox
95
+ selector.input # first input
96
+ selector.dialog # first dialog
97
+ selector.tab # first tab
98
+ # ... label, menu, statusbar, progress_bar
99
+ ```
100
+
101
+ ### Element filtering
102
+
103
+ ```ruby
104
+ # get_by_role with optional filters
105
+ selector.get_by_role(:button, text: "OK") # text filter (partial match)
106
+ selector.get_by_role(:checkbox, checked: true) # checked state filter
107
+ selector.get_by_role(:button, disabled: false) # disabled state filter
108
+
109
+ # Combined filters
110
+ selector.get_by_role(:checkbox, checked: true, text: "auto-save")
111
+
112
+ # Plural methods also accept filters
113
+ selector.checkboxes(checked: false) # unchecked only
114
+ selector.buttons(text: "Save") # buttons with matching text
115
+ ```
116
+
117
+ ### Scoping (within)
83
118
 
84
- # Find by text or role
85
- selector.get_by_text("OK") # partial match
86
- selector.get_by_role(:button) # also accepts "button" (String)
119
+ Restrict searches to an element's bounding box:
120
+
121
+ ```ruby
122
+ dialog = selector.dialog
123
+
124
+ # With block
125
+ selector.within(dialog) do |scope|
126
+ scope.buttons # only buttons inside the dialog
127
+ scope.find_text("OK")
128
+ scope.button # singular — first button inside dialog
129
+ end
130
+
131
+ # Without block — returns ScopedSelector
132
+ scoped = selector.within(dialog)
133
+ scoped.get_by_role(:button)
134
+ scoped.find_text("Retry", match: :exact)
87
135
  ```
88
136
 
89
- Each `TansParser::Element` is a Struct:
137
+ ### Custom role registration
138
+
139
+ When heuristic detection fails, annotate grid regions manually:
140
+
141
+ ```ruby
142
+ state = TansParser::State.new(state_data)
143
+
144
+ # Annotate a dialog that heuristics didn't recognize
145
+ state.annotate_role(:dialog, row: 5, col: 20, width: 28, height: 5, text: "Help")
146
+ state.annotate_role(:statusbar, row: 24, col: 0, width: 80, height: 1)
147
+
148
+ # Selector picks up annotations alongside auto-detected elements
149
+ selector = TansParser::Selector.new(state)
150
+ selector.dialogs # => includes annotated dialog
151
+ selector.statusbars # => includes annotated statusbar
152
+
153
+ # Annotations accept extra attributes
154
+ state.annotate_role(:button, row: 0, col: 0, width: 6, height: 1,
155
+ text: "Submit", fg: "green", disabled: false)
156
+ ```
157
+
158
+ ### State comparison (diff)
159
+
160
+ Compare two terminal states cell-by-cell:
161
+
162
+ ```ruby
163
+ before = TansParser::State.new(state_data)
164
+ # ... some action changes the screen ...
165
+ after = TansParser::State.new(new_state_data)
166
+
167
+ # Full diff — compares all cell keys
168
+ diff = before.diff(after)
169
+ # => [{row: 3, col: 2, before: {char: "T", fg: "default", ...},
170
+ # after: {char: "X", fg: "default", ...}}]
171
+
172
+ # Chars-only diff — ignores color/style changes
173
+ diff = before.diff(after, chars_only: true)
174
+ # Only reports actual character differences
175
+
176
+ # Accepts raw hash as argument
177
+ diff = before.diff({size: {rows: 5, cols: 10}, cursor: {...}, rows: [...]})
178
+ ```
179
+
180
+ ### Element actions & attributes
181
+
182
+ Each `TansParser::Element` is a Struct with data and action methods:
90
183
 
91
184
  ```ruby
92
185
  el = selector.buttons.first
93
- el.role # => :button
94
- el.text # => "OK"
95
- el.row # => 1
96
- el.col # => 2
97
- el.width # => 4
98
- el.height # => 1
99
- el.checked # => nil (für Checkboxen: true/false)
100
- el.fg # => "default"
101
- el.bg # => "default"
102
- el.to_h # => {role: :button, text: "OK", row: 1, col: 2, ...}
186
+
187
+ # Data attributes
188
+ el.role # => :button
189
+ el.text # => "OK"
190
+ el.row # => 1
191
+ el.col # => 2
192
+ el.width # => 4
193
+ el.height # => 1
194
+ el.checked # => true/false/nil
195
+ el.focused # => true/false/nil
196
+ el.disabled # => true/false/nil
197
+ el.fg # => "default"
198
+ el.bg # => "default"
199
+ el.to_h # => {role: :button, text: "OK", row: 1, col: 2, ...}
200
+
201
+ # Predicates
202
+ el.checked? # => false (always boolean)
203
+ el.disabled? # => false (always boolean)
204
+
205
+ # Geometry
206
+ el.bounds # => {row: 1, col: 2, width: 4, height: 1}
207
+
208
+ # Actions — return descriptive hashes for AI consumption
209
+ el.click # => {action: :click, target: el, row: 1, col: 4}
210
+ el.type("hello") # => {action: :type, target: el, row: 1, col: 4, text: "hello"}
211
+ el.press_key(:tab) # => {action: :press_key, target: el, key: :tab}
103
212
  ```
104
213
 
214
+ ### Recognized element patterns
215
+
216
+ | Role | Pattern | Example |
217
+ |------|---------|---------|
218
+ | `:button` | `[...]`, `(...)`, `<...>` | `[ OK ]`, `(Cancel)`, `<Submit>` |
219
+ | `:checkbox` | `[x]`, `[*]`, `[X]`, `[ ]` + label | `[x] Enable logging` |
220
+ | `:input` | `[_+]` inside brackets | `[________]` |
221
+ | `:label` | `Word:` or `Multiple Words:` | `Project Name:` |
222
+ | `:menu` | Menu bar (row 0–1, spaced words) or `> Item` | `File Edit Help`, `> New File` |
223
+ | `:tab` | ≥2 closely-spaced `[...]` on one row | `[Tab1] [Tab2] [Tab3]` |
224
+ | `:dialog` | Unicode box-drawing borders | `┌──┐` `│ │` `└──┘` |
225
+ | `:statusbar` | Last row with ≥3 non-default-bg cells | Inverse status line |
226
+ | `:progress` | `[###...]` with `#`, `>`, `=`, `-` fill | `[##### ] 50%` |
227
+
105
228
  ## Cell format
106
229
 
107
230
  Each cell is a Hash with these keys:
data/lib/tans-parser.rb CHANGED
@@ -9,4 +9,5 @@ require_relative "tans_parser/ansi_parser"
9
9
  require_relative "tans_parser/ansi_utils"
10
10
  require_relative "tans_parser/element"
11
11
  require_relative "tans_parser/selector"
12
+ require_relative "tans_parser/scoped_selector"
12
13
  require_relative "tans_parser/state"
@@ -10,8 +10,33 @@ module TansParser
10
10
  :checked,
11
11
  :focused,
12
12
  :fg, :bg,
13
+ :disabled,
13
14
  keyword_init: true,
14
15
  ) do
16
+ def checked?
17
+ !!checked
18
+ end
19
+
20
+ def disabled?
21
+ !!disabled
22
+ end
23
+
24
+ def bounds
25
+ { row: row, col: col, width: width, height: height }
26
+ end
27
+
28
+ def click
29
+ { action: :click, target: self, row: row, col: col + (width / 2) }
30
+ end
31
+
32
+ def type(text)
33
+ { action: :type, target: self, row: row, col: col + (width / 2), text: text }
34
+ end
35
+
36
+ def press_key(key)
37
+ { action: :press_key, target: self, key: key }
38
+ end
39
+
15
40
  def to_h
16
41
  {
17
42
  role: role,
@@ -21,6 +46,7 @@ module TansParser
21
46
  checked: checked,
22
47
  focused: focused,
23
48
  fg: fg, bg: bg,
49
+ disabled: disabled,
24
50
  }.compact
25
51
  end
26
52
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module TansParser
6
+ # Scoped view of a terminal screen restricted to an element's bounding box.
7
+ # Created by Selector#within(element).
8
+ #
9
+ # selector.within(dialog) do |scope|
10
+ # scope.buttons # only buttons inside the dialog
11
+ # scope.find_text("OK")
12
+ # end
13
+ class ScopedSelector
14
+ attr_reader :element
15
+
16
+ def initialize(selector, element)
17
+ @selector = selector
18
+ @element = element
19
+ @state = selector.state
20
+ @row_start = element.row
21
+ @row_end = element.row + element.height - 1
22
+ @col_start = element.col
23
+ @col_end = element.col + element.width - 1
24
+ end
25
+
26
+ # Find elements by role, scoped to the bounding box.
27
+ def get_by_role(role, text: nil, checked: nil, disabled: nil)
28
+ @selector.get_by_role(role, text: text, checked: checked, disabled: disabled)
29
+ .select { |e| fully_within?(e) }
30
+ end
31
+
32
+ # Find elements by visible text, scoped to the bounding box.
33
+ def get_by_text(text)
34
+ @selector.get_by_text(text)
35
+ .select { |e| fully_within?(e) }
36
+ end
37
+
38
+ # Search for text, scoped to the bounding box.
39
+ def find_text(pattern, match: :partial)
40
+ results = []
41
+ grid = @state.grid
42
+ max_row = [@row_end, grid.length - 1].min
43
+ max_col = [@col_end, grid[0].length - 1, 0].max
44
+
45
+ (@row_start..max_row).each do |r|
46
+ row = grid[r]
47
+ slice = row[@col_start..max_col]
48
+
49
+ row_text = slice.map { |c| c[:char] }.join
50
+ find_in_row(pattern, match, r, row_text, results)
51
+ end
52
+ results
53
+ end
54
+
55
+ # Convenience plural accessors
56
+ def buttons(**filters) = get_by_role(:button, **filters)
57
+ def checkboxes(**filters) = get_by_role(:checkbox, **filters)
58
+ def dialogs(**filters) = get_by_role(:dialog, **filters)
59
+ def inputs(**filters) = get_by_role(:input, **filters)
60
+ def labels(**filters) = get_by_role(:label, **filters)
61
+ def menus(**filters) = get_by_role(:menu, **filters)
62
+ def tabs(**filters) = get_by_role(:tab, **filters)
63
+ def statusbars(**filters) = get_by_role(:statusbar, **filters)
64
+ def progress_bars(**filters) = get_by_role(:progress, **filters)
65
+
66
+ # Convenience singular accessors
67
+ def button(**filters) = buttons(**filters).first
68
+ def checkbox(**filters) = checkboxes(**filters).first
69
+ def dialog(**filters) = dialogs(**filters).first
70
+ def input(**filters) = inputs(**filters).first
71
+ def label(**filters) = labels(**filters).first
72
+ def menu(**filters) = menus(**filters).first
73
+ def tab(**filters) = tabs(**filters).first
74
+ def statusbar(**filters) = statusbars(**filters).first
75
+ def progress_bar(**filters) = progress_bars(**filters).first
76
+
77
+ private
78
+
79
+ def fully_within?(elem)
80
+ elem.row.between?(@row_start, @row_end) &&
81
+ elem.col >= @col_start &&
82
+ elem.col <= @col_end
83
+ end
84
+
85
+ # rubocop:disable Metrics/CyclomaticComplexity
86
+ def find_in_row(pattern, match, row_idx, row_text, results)
87
+ case match
88
+ when :partial
89
+ compiled = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern.to_s))
90
+ find_regex_in_text(compiled, row_idx, row_text, results)
91
+ when :exact
92
+ pattern_str = pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
93
+ return unless row_text.strip == pattern_str
94
+
95
+ results << { row: row_idx, col: @col_start, text: row_text.rstrip, full_line: row_text }
96
+ when :regex
97
+ compiled = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
98
+ find_regex_in_text(compiled, row_idx, row_text, results)
99
+ else
100
+ raise ArgumentError, "unknown match mode: #{match.inspect}. Use :partial, :exact, or :regex"
101
+ end
102
+ end
103
+ # rubocop:enable Metrics/CyclomaticComplexity
104
+
105
+ def find_regex_in_text(compiled, row_idx, text, results)
106
+ pos = 0
107
+ begin
108
+ Timeout.timeout(State::TEXT_SEARCH_TIMEOUT) do
109
+ while (m = text.match(compiled, pos))
110
+ results << { row: row_idx, col: @col_start + m.begin(0), text: m[0], full_line: text }
111
+ pos = m.begin(0) + 1
112
+ end
113
+ end
114
+ rescue Timeout::Error
115
+ # Stop processing on timeout — return partial results
116
+ end
117
+ end
118
+ end
119
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength
4
+
3
5
  module TansParser
4
6
  # Scans terminal state for recognized UI elements.
5
7
  #
@@ -8,9 +10,10 @@ module TansParser
8
10
  # selector.get_by_role(:button) # => [Element, ...]
9
11
  # selector.buttons # => [Element, ...]
10
12
  # selector.dialogs # => [Element, ...]
13
+ # selector.button(text: "OK") # => Element or nil
11
14
  #
12
15
  class Selector
13
- TOP_LEFT_CORNERS = /[┌┏┎┍]/
16
+ TOP_LEFT_CORNERS = /[┌┏┎┍╭╔╓╒]/
14
17
  BOTTOM_LEFT_CORNERS = %w[└ ┗ ┖ ┕ ╰ ╚].freeze
15
18
  BOTTOM_RIGHT_CORNERS = %w[┘ ┛ ┚ ┙ ╯ ╝].freeze
16
19
 
@@ -26,30 +29,99 @@ module TansParser
26
29
  @elements.select { |e| e.text&.include?(text) }
27
30
  end
28
31
 
29
- # Find elements by role.
30
- def get_by_role(role)
31
- @elements.select { |e| e.role == role.to_sym }
32
+ # Find elements by role with optional filters.
33
+ # rubocop:disable Metrics/CyclomaticComplexity
34
+ def get_by_role(role, text: nil, checked: nil, disabled: nil)
35
+ results = @elements.select { |e| e.role == role.to_sym }
36
+ results = results.select { |e| e.text.to_s.include?(text.to_s) } if text
37
+ results = results.select { |e| e.checked == checked } unless checked.nil?
38
+ results = results.select { |e| e.disabled == disabled } unless disabled.nil?
39
+ results
40
+ end
41
+ # rubocop:enable Metrics/CyclomaticComplexity
42
+
43
+ # Convenience accessors (plural — return arrays)
44
+ def buttons(**filters)
45
+ get_by_role(:button, **filters)
46
+ end
47
+
48
+ def checkboxes(**filters)
49
+ get_by_role(:checkbox, **filters)
50
+ end
51
+
52
+ def dialogs(**filters)
53
+ get_by_role(:dialog, **filters)
54
+ end
55
+
56
+ def inputs(**filters)
57
+ get_by_role(:input, **filters)
58
+ end
59
+
60
+ def labels(**filters)
61
+ get_by_role(:label, **filters)
32
62
  end
33
63
 
34
- # Convenience accessors
35
- def buttons
36
- get_by_role(:button)
64
+ def menus(**filters)
65
+ get_by_role(:menu, **filters)
37
66
  end
38
67
 
39
- def checkboxes
40
- get_by_role(:checkbox)
68
+ def tabs(**filters)
69
+ get_by_role(:tab, **filters)
41
70
  end
42
71
 
43
- def dialogs
44
- get_by_role(:dialog)
72
+ def statusbars(**filters)
73
+ get_by_role(:statusbar, **filters)
45
74
  end
46
75
 
47
- def statusbars
48
- get_by_role(:statusbar)
76
+ def progress_bars(**filters)
77
+ get_by_role(:progress, **filters)
49
78
  end
50
79
 
51
- def progress_bars
52
- get_by_role(:progress)
80
+ # Convenience accessors (singular — return first element or nil)
81
+ def button(**filters)
82
+ buttons(**filters).first
83
+ end
84
+
85
+ def checkbox(**filters)
86
+ checkboxes(**filters).first
87
+ end
88
+
89
+ def dialog(**filters)
90
+ dialogs(**filters).first
91
+ end
92
+
93
+ def input(**filters)
94
+ inputs(**filters).first
95
+ end
96
+
97
+ def label(**filters)
98
+ labels(**filters).first
99
+ end
100
+
101
+ def menu(**filters)
102
+ menus(**filters).first
103
+ end
104
+
105
+ def tab(**filters)
106
+ tabs(**filters).first
107
+ end
108
+
109
+ def statusbar(**filters)
110
+ statusbars(**filters).first
111
+ end
112
+
113
+ def progress_bar(**filters)
114
+ progress_bars(**filters).first
115
+ end
116
+
117
+ # Scope subsequent searches to a specific element's bounding box.
118
+ def within(element, &block)
119
+ scoped = ScopedSelector.new(self, element)
120
+ if block
121
+ yield scoped
122
+ else
123
+ scoped
124
+ end
53
125
  end
54
126
 
55
127
  private
@@ -60,16 +132,27 @@ module TansParser
60
132
 
61
133
  def scan
62
134
  results = []
135
+ results.concat(detect_tabs)
136
+ results.concat(detect_inputs)
63
137
  results.concat(detect_buttons)
64
138
  results.concat(detect_checkboxes)
65
139
  results.concat(detect_dialogs)
140
+ results.concat(detect_labels)
141
+ results.concat(detect_menus)
66
142
  results.concat(detect_statusbars)
67
143
  results.concat(detect_progress_bars)
144
+ results.concat(detect_annotations)
68
145
  results
69
146
  end
70
147
 
148
+ # Detects annotations: manually annotated roles from State#annotate_role
149
+ def detect_annotations
150
+ @state.annotations.map { |a| Element.new(a) }
151
+ end
152
+
71
153
  # Detects buttons: [ OK ], (Cancel), <Submit>
72
- # rubocop:disable Metrics/AbcSize
154
+ # Skips underscore-only brackets (those are inputs).
155
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
73
156
  def detect_buttons
74
157
  buttons = []
75
158
  grid.each_with_index do |row, r|
@@ -78,6 +161,8 @@ module TansParser
78
161
  scan_match.each do
79
162
  text = (::Regexp.last_match(1) || ::Regexp.last_match(2) || ::Regexp.last_match(3)).to_s.strip
80
163
  next if text.empty?
164
+ next if text.match?(/^_+$/)
165
+ next if text.match?(/^[ xX*]$/) # skip checkbox markers
81
166
 
82
167
  col = ::Regexp.last_match.begin(0)
83
168
  buttons << Element.new(
@@ -92,7 +177,7 @@ module TansParser
92
177
  end
93
178
  buttons
94
179
  end
95
- # rubocop:enable Metrics/AbcSize
180
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
181
 
97
182
  # Detects checkboxes: [x], [*], [ ] at start of lines
98
183
  def detect_checkboxes
@@ -103,13 +188,13 @@ module TansParser
103
188
  next unless match
104
189
 
105
190
  checked = match[2] != " "
106
- label = match[3].strip
191
+ label_text = match[3].strip
107
192
  col = match.begin(3)
108
193
  checkboxes << Element.new(
109
194
  role: :checkbox,
110
- text: label,
195
+ text: label_text,
111
196
  row: r, col: col,
112
- width: label.length, height: 1,
197
+ width: label_text.length, height: 1,
113
198
  checked: checked,
114
199
  )
115
200
  end
@@ -149,10 +234,13 @@ module TansParser
149
234
  # Returns the width of a dialog top border if valid, nil otherwise
150
235
  def dialog_top_width(line, tl_idx)
151
236
  top_row = line[tl_idx..]
152
- tr_match = top_row.match(/^[┌┏┎┍]([─━]*)([┐┓┒┑])/)
153
- return nil unless tr_match
237
+ # Find first top-right corner anywhere after the top-left corner.
238
+ # Allows text/titles in the top border (e.g. ╭─ Commands ─╮).
239
+ tr_idx = top_row.index(/[┐┓┒┑╮╗╖╕]/)
240
+ return nil unless tr_idx
241
+ return nil if tr_idx < 2 # minimum dialog width (corner + at least 1 char + corner)
154
242
 
155
- tr_match[0].length
243
+ tr_idx + 1
156
244
  end
157
245
 
158
246
  # Returns the row index of a matching bottom border, nil if not found
@@ -182,29 +270,69 @@ module TansParser
182
270
  lines.join(" ").strip
183
271
  end
184
272
 
185
- # Detects statusbar: bottom row with reversed/inverse colors
273
+ # Detects statusbar: bottom rows with reversed/inverse colors,
274
+ # or last row with substantial content even without background info.
275
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
186
276
  def detect_statusbars
187
277
  bars = []
188
278
  return bars if grid.empty?
189
279
 
190
- last_row_idx = grid.length - 1
191
- last_row = grid[last_row_idx]
280
+ # Check last 2 rows for non-default background
281
+ [grid.length - 1, grid.length - 2].uniq.each do |row_idx|
282
+ next if row_idx.negative?
283
+
284
+ row = grid[row_idx]
285
+ non_default = row.reject { |c| c[:bg] == "default" }
286
+ text = row.map { |c| c[:char] }.join.strip
287
+ next if non_default.length < 3 || text.empty?
192
288
 
193
- non_default = last_row.reject { |c| c[:bg] == "default" }
194
- return bars if non_default.length < 3
289
+ bars << Element.new(
290
+ role: :statusbar, text: text,
291
+ row: row_idx, col: 0,
292
+ width: row.length, height: 1,
293
+ bg: non_default.first[:bg],
294
+ )
295
+ return bars
296
+ end
195
297
 
298
+ # Fallback: last row with substantial content (≥30 chars) but no bg info
299
+ last_row = grid[-1]
196
300
  text = last_row.map { |c| c[:char] }.join.strip
197
- return bars if text.empty?
198
-
199
- bars << Element.new(
200
- role: :statusbar,
201
- text: text,
202
- row: last_row_idx, col: 0,
203
- width: last_row.length, height: 1,
204
- bg: non_default.first[:bg],
205
- )
301
+ if text.length >= 30
302
+ bars << Element.new(
303
+ role: :statusbar, text: text,
304
+ row: grid.length - 1, col: 0,
305
+ width: last_row.length, height: 1,
306
+ )
307
+ return bars
308
+ end
309
+
310
+ # Scan all rows for separator-preceded footers (Karat-style)
311
+ # Footer row follows a row of mostly ─/━/═ characters
312
+ grid.each_with_index do |row, r|
313
+ next if r.zero?
314
+
315
+ prev_chars = grid[r - 1].map { |c| c[:char] }.join
316
+ non_space = prev_chars.gsub(" ", "")
317
+ next if non_space.empty?
318
+
319
+ sep_ratio = non_space.count("─━═").to_f / non_space.length
320
+ next if sep_ratio < 0.8
321
+
322
+ text = row.map { |c| c[:char] }.join.strip
323
+ next if text.empty?
324
+
325
+ bars << Element.new(
326
+ role: :statusbar, text: text,
327
+ row: r, col: 0,
328
+ width: row.length, height: 1,
329
+ )
330
+ return bars
331
+ end
332
+
206
333
  bars
207
334
  end
335
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
208
336
 
209
337
  # Detects progress bars: [#### ] or [=====> ] patterns
210
338
  def detect_progress_bars
@@ -227,5 +355,116 @@ module TansParser
227
355
  end
228
356
  bars
229
357
  end
358
+
359
+ # Detects text inputs: [____] underscore-filled brackets
360
+ def detect_inputs
361
+ inputs = []
362
+ grid.each_with_index do |row, r|
363
+ line = row.map { |c| c[:char] }.join
364
+ line.enum_for(:scan, /\[(_+)\]/).each do
365
+ m = ::Regexp.last_match
366
+ col = m.begin(0)
367
+ inputs << Element.new(
368
+ role: :input,
369
+ text: "",
370
+ row: r, col: col,
371
+ width: m[0].length, height: 1,
372
+ )
373
+ end
374
+ end
375
+ inputs
376
+ end
377
+
378
+ # Detects labels: text followed by colon separator
379
+ def detect_labels
380
+ labels = []
381
+ grid.each_with_index do |row, r|
382
+ line = row.map { |c| c[:char] }.join
383
+ match = line.match(/\b([A-Za-z]\w*(?:\s+\w+)*\s*:)/)
384
+ next unless match
385
+
386
+ label_text = match[1].strip.sub(/:$/, "").strip
387
+ next if label_text.empty? || label_text.length < 2
388
+
389
+ col = match.begin(1)
390
+ labels << Element.new(
391
+ role: :label,
392
+ text: label_text,
393
+ row: r, col: col,
394
+ width: match[1].length, height: 1,
395
+ )
396
+ end
397
+ labels
398
+ end
399
+
400
+ # Detects menus: top-row menu bars and > dropdown items
401
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
402
+ def detect_menus
403
+ menus = []
404
+ grid.each_with_index do |row, r|
405
+ line = row.map { |c| c[:char] }.join
406
+ stripped = line.strip
407
+ next if stripped.empty?
408
+
409
+ # Menu bar on first two rows: words separated by 2+ spaces
410
+ if r <= 1
411
+ items = stripped.split(/\s{2,}/)
412
+ if items.length >= 2 && items.all? { |i| i.match?(/^[A-Za-z]/) }
413
+ col = line.index(stripped)
414
+ menus << Element.new(
415
+ role: :menu,
416
+ text: items.join(" | "),
417
+ row: r, col: col || 0,
418
+ width: line.length, height: 1,
419
+ )
420
+ end
421
+ end
422
+
423
+ # Dropdown item: > prefix
424
+ line.enum_for(:scan, /(>\s*[A-Za-z][\w\s]*)/).each do
425
+ m = ::Regexp.last_match
426
+ menus << Element.new(
427
+ role: :menu,
428
+ text: m[0].sub(/^>\s*/, "").strip,
429
+ row: r, col: m.begin(0),
430
+ width: m[0].length, height: 1,
431
+ )
432
+ end
433
+ end
434
+ menus
435
+ end
436
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
437
+
438
+ # Detects tabs: multiple closely-spaced [bracketed] items on one row
439
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
440
+ def detect_tabs
441
+ tabs = []
442
+ grid.each_with_index do |row, r|
443
+ line = row.map { |c| c[:char] }.join
444
+ matches = line.enum_for(:scan, /\[([^\]]+)\]/).map { ::Regexp.last_match }
445
+ next if matches.length < 2
446
+
447
+ gaps_close = matches.each_cons(2).all? { |a, b| b.begin(0) - a.end(0) <= 3 }
448
+ next unless gaps_close
449
+
450
+ matches.each do |m|
451
+ tab_text = m[1].strip
452
+ next if tab_text.empty? || tab_text.match?(/^_+$/)
453
+
454
+ cell = row[m.begin(0)]
455
+ focused = cell[:underline] || cell[:bg] != "default"
456
+ tabs << Element.new(
457
+ role: :tab,
458
+ text: tab_text,
459
+ row: r, col: m.begin(0),
460
+ width: m[0].length, height: 1,
461
+ focused: focused,
462
+ )
463
+ end
464
+ end
465
+ tabs
466
+ end
467
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
230
468
  end
231
469
  end
470
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength
@@ -8,7 +8,7 @@ module TansParser
8
8
  # Represents the parsed state of a terminal screen.
9
9
  # Provides high-level query methods for AI consumption.
10
10
  class State
11
- attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
11
+ attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format, :annotations
12
12
 
13
13
  def initialize(data)
14
14
  raise ArgumentError, "State data must include :size key" unless data[:size]
@@ -25,7 +25,18 @@ module TansParser
25
25
 
26
26
  @mouse_mode = data[:mouse_mode] || :none
27
27
  @mouse_format = data[:mouse_format] || :normal
28
+
29
+ @annotations = data[:annotations] || []
30
+ end
31
+
32
+ # Annotate a region of the terminal with a semantic role.
33
+ # These annotations are picked up by Selector during element recognition.
34
+ # rubocop:disable Metrics/ParameterLists
35
+ def annotate_role(role, row:, col:, width: 1, height: 1, text: nil, **extra)
36
+ @annotations << { role: role.to_sym, row: row, col: col,
37
+ width: width, height: height, text: text, }.merge(extra)
28
38
  end
39
+ # rubocop:enable Metrics/ParameterLists
29
40
 
30
41
  # Get plain text of the entire terminal (no ANSI)
31
42
  def plain_text
@@ -41,32 +52,29 @@ module TansParser
41
52
 
42
53
  # Search for text across the entire terminal.
43
54
  # For regex patterns, matching is bounded by a timeout to prevent ReDoS.
55
+ #
56
+ # state.find_text("hello") # partial match (default)
57
+ # state.find_text("hello", match: :exact) # exact row match
58
+ # state.find_text("\\d+", match: :regex) # regex from string
59
+ # state.find_text(/\\d{3}/) # Regexp object (partial mode)
60
+ #
61
+ # Returns [{ row:, col:, text:, full_line: }, ...].
62
+ # +text+ is the actual matched substring (for Regexp/:regex mode)
63
+ # or the pattern string (for :partial/:exact with String).
44
64
  TEXT_SEARCH_TIMEOUT = 5
45
65
 
46
- def find_text(pattern)
47
- results = []
48
- is_regex = pattern.is_a?(Regexp)
66
+ def find_text(pattern, match: :partial)
67
+ unless %i[partial exact regex].include?(match)
68
+ raise ArgumentError, "unknown match mode: #{match.inspect}. Use :partial, :exact, or :regex"
69
+ end
49
70
 
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
71
+ results = []
72
+ case match
73
+ when :exact
74
+ find_text_exact(pattern, results)
75
+ else
76
+ compiled = compile_pattern(pattern, match)
77
+ find_text_with_regex(compiled, results)
70
78
  end
71
79
  results
72
80
  end
@@ -115,6 +123,32 @@ module TansParser
115
123
  }
116
124
  end
117
125
 
126
+ DEFAULT_CELL = { char: " ", fg: "default", bg: "default",
127
+ bold: false, italic: false, underline: false, blink: false, }.freeze
128
+
129
+ # Compare this state with another State and return cell-level differences.
130
+ # With chars_only: true, only differences in the :char key are reported.
131
+ # Use ignore_rows: to skip specific rows (e.g. cursor/prompt lines).
132
+ def diff(other_state, chars_only: false, ignore_rows: [])
133
+ other = other_state.is_a?(State) ? other_state : State.new(other_state)
134
+ max_rows = [@rows, other.rows].max
135
+ max_cols = [@cols, other.cols].max
136
+ results = []
137
+
138
+ (0...max_rows).each do |r|
139
+ next if ignore_rows.include?(r)
140
+
141
+ (0...max_cols).each do |c|
142
+ a = cell_at(r, c)
143
+ b = other.send(:cell_at, r, c)
144
+ next if chars_only ? a[:char] == b[:char] : a == b
145
+
146
+ results << { row: r, col: c, before: a, after: b }
147
+ end
148
+ end
149
+ results
150
+ end
151
+
118
152
  private
119
153
 
120
154
  def extract_highlights
@@ -143,6 +177,48 @@ module TansParser
143
177
  end
144
178
  highlights
145
179
  end
180
+
181
+ def compile_pattern(pattern, match)
182
+ return pattern if pattern.is_a?(Regexp)
183
+
184
+ source = match == :regex ? pattern.to_s : Regexp.escape(pattern.to_s)
185
+ Regexp.new(source)
186
+ end
187
+
188
+ def find_text_exact(pattern, results)
189
+ pattern_str = pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
190
+ @grid.each_with_index do |row, ri|
191
+ row_text = row.map { |c| c[:char] }.join.rstrip
192
+ next unless row_text == pattern_str
193
+
194
+ results << { row: ri, col: 0, text: row_text, full_line: row_text }
195
+ end
196
+ end
197
+
198
+ def find_text_with_regex(compiled, results)
199
+ @grid.each_with_index do |row, ri|
200
+ text = row.map { |c| c[:char] }.join
201
+ pos = 0
202
+ begin
203
+ Timeout.timeout(TEXT_SEARCH_TIMEOUT) do
204
+ while (m = text.match(compiled, pos))
205
+ results << { row: ri, col: m.begin(0), text: m[0], full_line: text }
206
+ pos = m.begin(0) + 1
207
+ end
208
+ end
209
+ rescue Timeout::Error
210
+ # Stop processing on timeout — return partial results
211
+ end
212
+ end
213
+ end
214
+
215
+ def cell_at(row, col)
216
+ if row < @rows && col < @cols
217
+ @grid[row][col]
218
+ else
219
+ DEFAULT_CELL
220
+ end
221
+ end
146
222
  end
147
223
  end
148
224
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TansParser
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tans-parser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -139,6 +139,7 @@ files:
139
139
  - lib/tans_parser/ansi_parser.rb
140
140
  - lib/tans_parser/ansi_utils.rb
141
141
  - lib/tans_parser/element.rb
142
+ - lib/tans_parser/scoped_selector.rb
142
143
  - lib/tans_parser/selector.rb
143
144
  - lib/tans_parser/state.rb
144
145
  - lib/tans_parser/version.rb