aihand 0.0.1 → 0.1.1

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.
Files changed (113) hide show
  1. package/README.md +152 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-3J7EYI6G.cjs +651 -0
  25. package/dist/cli-FIJLKAGI.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. package/index.js +0 -2
@@ -0,0 +1,348 @@
1
+ /// <reference lib="dom" />
2
+ // DOM 命中的纯判定原语 —— aihand write/read 路的命中地基。
3
+ //
4
+ // 全是浏览器原生 DOM API(document/getBoundingClientRect/elementFromPoint),零依赖。
5
+ // 这 64 行同时被 read 侧(/screen 列 knobs、/click text= 匹配、miss 时的可点击清单)和
6
+ // write 侧(doClick 的 elementFromPoint 命中测试)消费 —— 同进程内读写必须共用一份,否则
7
+ // 立刻产生 read-says-clickable / write-can't 的不一致(reachable 注释里钉死的 bug)。
8
+ //
9
+ // teact 的 domHitSuite 也有一份逻辑等价的 candidates(测试侧,jsdom 采集命中轨迹)。那是
10
+ // 跨仓的两份、不同进程、不同时演化,没有运行时耦合 —— 故各持一份,而非跨包运行时依赖
11
+ // (后者会把 aihand 发布后强行拉一个会撞 npm 名的 teact,必炸)。改这里时记得 teact 那份。
12
+
13
+ export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
14
+
15
+ // aria-labelledby: an element names itself by POINTING at other elements (one or more IDs),
16
+ // whose visible text, concatenated, is the name. This is the TOP signal in the W3C accessible-
17
+ // name algorithm — higher than aria-label — and is how real third-party frontends name custom
18
+ // widgets (a role=textbox whose label is a separate visible <div id> above it). Zero app
19
+ // cooperation: the association is already in the DOM. We resolve each id to its element's text
20
+ // (getDirectText first, then full textContent), join with a space, skipping dangling ids.
21
+ function labelledByText(el: Element): string {
22
+ const ids = (el.getAttribute('aria-labelledby') || '').trim()
23
+ if (!ids)
24
+ return ''
25
+ const out: string[] = []
26
+ // aria-labelledby is a space-separated ID token list (HTML spec). split on whitespace,
27
+ // drop empties — no regex on structured text, this is a flat token list, not syntax.
28
+ for (const id of ids.split(' ').map(s => s.trim()).filter(Boolean)) {
29
+ const ref = el.ownerDocument?.getElementById(id)
30
+ if (!ref)
31
+ continue
32
+ const t = (getDirectText(ref) || (ref.textContent || '').trim()).trim()
33
+ if (t)
34
+ out.push(t)
35
+ }
36
+ return out.join(' ')
37
+ }
38
+
39
+ // A form control names itself by its associated <label> — `<label for=id>` pointing at it OR a
40
+ // wrapping <label>. Native, standard, present without app cooperation (how every accessible form
41
+ // works). HTMLInputElement/Textarea/Select expose `.labels` (a live NodeList of BOTH kinds), so
42
+ // we read it directly — no id-into-selector escaping, no regex. Concatenate the labels' visible
43
+ // text. Returns '' if the control type has no `.labels` (e.g. a contenteditable div).
44
+ function associatedLabelText(el: Element): string {
45
+ const labels = (el as HTMLInputElement).labels
46
+ if (!labels || labels.length === 0)
47
+ return ''
48
+ const out: string[] = []
49
+ for (const lab of Array.from(labels)) {
50
+ const t = (getDirectText(lab) || (lab.textContent || '').trim()).trim()
51
+ if (t)
52
+ out.push(t)
53
+ }
54
+ return out.join(' ')
55
+ }
56
+
57
+ // An icon-only button carries no text/aria/title, but its icon's identity IS its meaning, and
58
+ // every icon library stamps that identity into the <svg> class — lucide emits `lucide ellipsis`
59
+ // → class "lucide lucide-ellipsis", feather "feather feather-trash", heroicons similar. The
60
+ // fingerprint that distinguishes an icon-name token from a utility class (w-3.5, text-gray-500)
61
+ // is the PAIR: a bare library token (`lucide`) AND a `lucide-<name>` token co-occur on the same
62
+ // svg. Utility classes never form that prefix/prefix-name pair. So we find any token of the form
63
+ // `<p>-<name>` whose prefix `<p>` is itself a standalone token on the element, and read `<name>`
64
+ // (kebab→space). This is a browser-native, zero-app-cooperation signal — the icon library emits
65
+ // it for everyone. It is the WEAKEST label (an icon name is cruder than a real word), so it sits
66
+ // at the very bottom of elementLabel's chain, below every accname signal: a title-bearing icon
67
+ // button still shows its title, never "search". Returns '' if no icon-library class is present.
68
+ function iconNameOf(el: Element): string {
69
+ const svg = el.tagName === 'svg' ? el : el.querySelector('svg')
70
+ if (!svg)
71
+ return ''
72
+ const tokens = (svg.getAttribute('class') || '').split(' ').map(s => s.trim()).filter(Boolean)
73
+ const bare = new Set(tokens.filter(t => !t.includes('-')))
74
+ for (const t of tokens) {
75
+ const dash = t.indexOf('-')
76
+ if (dash <= 0)
77
+ continue
78
+ const prefix = t.slice(0, dash)
79
+ if (bare.has(prefix)) // `lucide` + `lucide-ellipsis` → the icon-library fingerprint
80
+ return t.slice(dash + 1).split('-').join(' ') // trash-2 → "trash 2", chevron-down → "chevron down"
81
+ }
82
+ return ''
83
+ }
84
+
85
+ // Direct text children only — skip nested element text (a button's icon-span <title>,
86
+ // a badge count) so the label is the element's own words, not its descendants'.
87
+ export function getDirectText(el: Element): string {
88
+ let text = ''
89
+ for (const node of el.childNodes) {
90
+ if (node.nodeType === 3) { // TEXT_NODE
91
+ const t = node.textContent?.trim()
92
+ if (t)
93
+ text += (text ? ' ' : '') + t
94
+ }
95
+ }
96
+ return text
97
+ }
98
+
99
+ // The ONE label of an element — what /screen lists it as AND what /click matches text= on.
100
+ // Both reading (knobs) and writing (find target) go through this, so the projection can
101
+ // never name a control the click can't find (the read-says-clickable, write-can't bug).
102
+ //
103
+ // The fallback chain is the W3C accessible-name computation (accname) — the SAME algorithm
104
+ // every screen reader uses to name an element — restricted to its load-bearing signals. This
105
+ // is what makes aihand universal: it extracts names from browser-native, standardized signals
106
+ // that arbitrary third-party frontends already emit for accessibility, with ZERO cooperation
107
+ // from the app under test. The order is accname's priority order:
108
+ // 1. aria-labelledby — element points at other elements whose text is its name (TOP signal,
109
+ // how custom widgets like a role=textbox name themselves by a visible sibling label).
110
+ // 2. aria-label — the inline name.
111
+ // 3. getDirectText — the element's own words (a button's label text).
112
+ // 4. title — icon buttons carry their word here, not in text.
113
+ // 5. textContent — full subtree text when own text is empty.
114
+ // If still empty and it's a form control, fall to its native associations:
115
+ // 6. <label for> / wrapping <label> — the standard way a form field is named.
116
+ // 7. placeholder — the visible gray-text name of an input.
117
+ // 8. value (safeValue) — last resort; the control's content is its only human-readable word
118
+ // (textContent is always empty for form controls). Through safeValue so a password/token
119
+ // field's label is its redaction, never the plaintext secret.
120
+ // A clickable CONTAINER with empty own text often wraps the control holding its name (a sidebar
121
+ // row is a div wrapping a pointer-events-none <input value=…>), so we descend to the first form
122
+ // control and run the same native-association chain on it.
123
+ // 9. icon name (iconNameOf) — the FINAL, weakest fallback: an icon-only button with no accname
124
+ // signal at all (a `<MoreHorizontal/>` menu trigger) takes its name from the icon library's
125
+ // svg class (lucide-ellipsis → "ellipsis"). Below everything else so a real name always wins.
126
+ // 空白归一化:HTML 把 `74&nbsp;comments` 渲成含 U+00A0(不间断空格)的文本,但人看到的、人输入
127
+ // 到 text= 的是普通空格 `74 comments`。JS 的 `\s` 不含 U+00A0,故 includes 直接漏匹配(live 实测
128
+ // HN 评论链接 `no element for 74 comments`,而 clickable 列表里明明列着它)。把所有 Unicode 空白
129
+ // (nbsp/各种宽度空格/制表/换行)折叠成单个普通空格 + trim,读侧(label 显示)写侧(text= 匹配)同源:
130
+ // /screen 列 `74 comments`、pickByText 比较 `74 comments`,普通空格直接命中。这是空白字符的字节
131
+ // 归一(非对结构化语法做 pattern match),正则在此正当。\s 加   补 JS 的盲点。
132
+ function normWs(s: string): string {
133
+ return s.replace(/[\s ]+/g, ' ').trim()
134
+ }
135
+
136
+ export function elementLabel(el: Element): string {
137
+ return normWs(rawLabel(el))
138
+ }
139
+
140
+ function rawLabel(el: Element): string {
141
+ const text = (labelledByText(el) || el.getAttribute('aria-label') || getDirectText(el) || el.getAttribute('title') || (el.textContent || '').trim()).trim()
142
+ if (text)
143
+ return text
144
+ const tag = el.tagName
145
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
146
+ return controlLabel(el)
147
+ const ctrl = el.querySelector('input, textarea, select')
148
+ if (ctrl) {
149
+ const cl = controlLabel(ctrl)
150
+ if (cl)
151
+ return cl
152
+ }
153
+ return iconNameOf(el)
154
+ }
155
+
156
+ // The name of a form control reached either as the element itself or by descending into a
157
+ // clickable container that wraps it. Full accname order on the control: aria-labelledby →
158
+ // aria-label → <label> → title → placeholder → value. (own/textContent are always empty for
159
+ // form controls, so they're skipped.) Through safeValue so a secret field's name is its
160
+ // redaction, never the plaintext value.
161
+ function controlLabel(el: Element): string {
162
+ return (labelledByText(el) || el.getAttribute('aria-label') || associatedLabelText(el) || el.getAttribute('title') || el.getAttribute('placeholder') || safeValue(el)).trim()
163
+ }
164
+
165
+ export function isVisible(el: Element): boolean {
166
+ const r = el.getBoundingClientRect()
167
+ return r.width > 0 && r.height > 0
168
+ }
169
+
170
+ // A name that marks a value as a secret to never echo in plaintext: API keys, tokens,
171
+ // passwords. The substring signals (no regex) that gate redaction. This is the ONE predicate
172
+ // over a *name string* — shared by the DOM-element reader (isSensitive, action.ts: input's
173
+ // type/name/aria) AND the domain-field reader (collectScreen/diffScreen: a `store.field` key).
174
+ // Both routes echo values; the redaction must hang off this single key-level predicate or a
175
+ // different endpoint leaks what another redacts (sessionToken was plaintext in /screen's domain
176
+ // block because only the DOM route was guarded — same hole, second consumer).
177
+ // Substrings that mark a name (form-field name/aria, domain key, OR an HTTP header name) as
178
+ // secret-bearing. `authorization`/`cookie`/`set-cookie` carry credentials but contain none of
179
+ // the token/key/secret words, so they're listed explicitly — a request-header dump (/network
180
+ // ?full) leaks `Authorization: Bearer sk-…` exactly the way the domain block leaked sessionToken.
181
+ const SECRET_HINTS = ['password', 'api key', 'apikey', 'api-key', 'api_key', 'secret', 'token', 'private key', 'authorization', 'cookie']
182
+
183
+ // Substrings that look secret-bearing by `token` but are plainly a COUNT, not a credential. A
184
+ // credential is a single `token` (authToken/githubToken/sessionToken/syncToken); the plural
185
+ // `tokens` and `tokenCount` are LLM token quantities (maxTokensK, showTokenCount). Without this,
186
+ // `'maxtokensk'.includes('token')` redacts a harmless number — and a redacted config value is
187
+ // strictly worse for a reader (it can't see the setting) than an exposed count is risky (it isn't).
188
+ const SECRET_EXEMPTIONS = ['tokens', 'tokencount']
189
+
190
+ export function isSecretKey(name: string): boolean {
191
+ const lower = name.toLowerCase()
192
+ if (SECRET_EXEMPTIONS.some(e => lower.includes(e)))
193
+ return false
194
+ return SECRET_HINTS.some(h => lower.includes(h))
195
+ }
196
+
197
+ // A domain field that names an in-flight operation — generating, loading, a pending request.
198
+ // While such a field is truthy the app is mid-async; the DOM may fall silent (waiting on the
199
+ // network for the first token) yet the state machine has NOT settled. settle uses this to tell a
200
+ // real resting state from a mid-flight lull: "DOM quiet" ∧ "no busy field truthy" = actually done.
201
+ // This is the dual of isSecretKey — a key-level semantic classifier, generic over any app's
202
+ // __AIPEEK_SCREEN__ (it never hard-codes a field like 流式中). Bilingual: an app may name the
203
+ // field in Chinese (流式中/加载中/思考中/请求中) or English (isLoading/streaming/pending/busy).
204
+ // Strong markers: unambiguous in-progress words. A key containing one is busy no matter what — they
205
+ // never collide with a neutral noun, so no exemption applies to them.
206
+ const BUSY_STRONG = ['loading', 'streaming', 'pending', 'busy', 'fetching', 'inflight', 'in-flight', 'generating', 'waiting', '加载中', '生成中', '思考中', '流式中', '请求中', '发送中']
207
+
208
+ // Weak markers fire only after exemptions clear. '中' and bare '-ing' are real in-progress signals
209
+ // (…中 / streaming) but also land inside neutral nouns (中文 / setting), so the exemptions below
210
+ // suppress the false hits. Strong markers above bypass this gate entirely.
211
+ const BUSY_WEAK = ['中', 'ing']
212
+ const BUSY_EXEMPTIONS = ['中文', '中间', '居中', '集中', 'setting', 'heading', 'string', 'rating', 'meeting', 'missing', 'training', 'ring', 'ceiling', 'sibling']
213
+
214
+ export function isBusyKey(name: string): boolean {
215
+ const lower = name.toLowerCase()
216
+ if (BUSY_STRONG.some(h => lower.includes(h)))
217
+ return true
218
+ if (BUSY_EXEMPTIONS.some(e => lower.includes(e)))
219
+ return false
220
+ return BUSY_WEAK.some(h => lower.includes(h))
221
+ }
222
+
223
+ // True when a domain entry asserts work is in flight: an in-progress-named key holding a NON-EMPTY
224
+ // value. (`流式中: true` yes; `流式中: false` no; `模型: "Sonnet"` no — not an in-progress key.)
225
+ // The empty-collection gate is load-bearing, not cosmetic: a busy-named key can hold a container
226
+ // whose EMPTINESS is the rest state — `typing: {}` (nobody typing), `replyingTo: {}` (not replying),
227
+ // `pendingReqs: []` (none pending). An empty {} / [] is truthy in JS, so without this gate every
228
+ // such key reads as perpetually busy and settle never terminates. The value, not the name, is the
229
+ // honest in-flight signal; the gate makes "named busy but holding nothing" collapse to rest.
230
+ export function isBusyState(key: string, value: unknown): boolean {
231
+ if (!isBusyKey(key))
232
+ return false
233
+ if (value === false || value === null || value === undefined || value === 0 || value === '')
234
+ return false
235
+ if (Array.isArray(value))
236
+ return value.length > 0
237
+ if (typeof value === 'object')
238
+ return Object.keys(value as object).length > 0
239
+ return true
240
+ }
241
+
242
+ // Length-preserving redaction of a value (so "empty vs set / roughly how long" stays
243
+ // observable without exposing the secret). The single masking shape, shared by every reader
244
+ // that echoes a value under a secret-named key — DOM input, domain field, HTTP header.
245
+ export function redactSecretValue(value: string): string {
246
+ return value ? `‹redacted ${value.length} chars›` : value
247
+ }
248
+
249
+ // A field whose live value must never be echoed: API keys, tokens, passwords. `.value`
250
+ // returns the real string even for type=password (masking is visual only), so reading it
251
+ // out in /dom, /query, /screen or as a label would leak the secret in plaintext. We redact
252
+ // instead. Signals (substring, no regex): type=password, autocomplete=*-password /
253
+ // one-time-code, or a name/placeholder/aria-label hinting a secret. Lives here next to the
254
+ // redaction primitives because elementLabel (this file) now reads value too — both the label
255
+ // and the state suffix must pass through the same one chokepoint or one re-leaks the other.
256
+ export function isSensitive(el: Element): boolean {
257
+ if ((el as HTMLInputElement).type === 'password')
258
+ return true
259
+ const ac = (el.getAttribute('autocomplete') || '').toLowerCase()
260
+ if (ac.includes('password') || ac === 'one-time-code')
261
+ return true
262
+ const hint = `${el.getAttribute('name') || ''} ${el.getAttribute('placeholder') || ''} ${el.getAttribute('aria-label') || ''}`
263
+ return isSecretKey(hint)
264
+ }
265
+
266
+ // The value to show for a field: real value, or a length-preserving redaction for secrets
267
+ // (so "is it empty / roughly how long" stays observable without exposing the secret).
268
+ export function safeValue(el: Element): string {
269
+ const v = (el as HTMLInputElement).value ?? ''
270
+ return isSensitive(el) ? redactSecretValue(v) : v
271
+ }
272
+
273
+ // text → element is a RANKING problem, not a filter. The old `filter(includes).find(reachable)`
274
+ // collapsed three orthogonal axes into "substring + DOM order", and each lost axis revived a
275
+ // real miss class seen in live driving:
276
+ // 1. EXACTNESS — a tab button labelled exactly "外观" vs the whole nav strip whose text
277
+ // *contains* "外观". Pure substring + DOM-order let the giant container win. So an exact
278
+ // label beats a prefix/suffix beats a mere substring.
279
+ // 2. REACHABILITY — a knob that's clickable now beats one merely boxed (covered/off-screen).
280
+ // 3. TIGHTNESS — at equal exactness, the SMALLEST element wins: the leaf <button> over the
281
+ // <div> wrapping it, the icon control over its tooltip-portal ancestor. Drop this and a
282
+ // substring match hands the click to the biggest enclosing box.
283
+ // Removing any one axis brings back a miss class — this 3-tuple is the minimal complete order.
284
+ // matchScore returns null for a non-match; higher tuple (lexicographic, then larger = better)
285
+ // is the better target. Pure & deterministic so teact's domHitSuite can mirror it 1:1.
286
+ interface MatchScore { exact: number, reach: number, tight: number }
287
+
288
+ function matchScore(el: Element, lowerText: string): MatchScore | null {
289
+ if (!isVisible(el))
290
+ return null
291
+ const label = elementLabel(el).toLowerCase()
292
+ if (!label.includes(lowerText))
293
+ return null
294
+ const exact = label === lowerText ? 2 : (label.startsWith(lowerText) || label.endsWith(lowerText)) ? 1 : 0
295
+ const r = el.getBoundingClientRect()
296
+ return { exact, reach: reachable(el) ? 1 : 0, tight: -(r.width * r.height) }
297
+ }
298
+
299
+ // Lexicographic compare of two scores: exact, then reach, then tight (all larger = better).
300
+ function scoreBetter(a: MatchScore, b: MatchScore): boolean {
301
+ if (a.exact !== b.exact) return a.exact > b.exact
302
+ if (a.reach !== b.reach) return a.reach > b.reach
303
+ return a.tight > b.tight
304
+ }
305
+
306
+ // argmax of matchScore over a candidate set — the one element a text= should resolve to.
307
+ export function pickByText(candidates: Iterable<Element>, text: string): Element | null {
308
+ // text= 输入同样归一化:label 已是普通空格,输入侧也折叠(用户粘贴带 nbsp 的文本时两侧仍同源)。
309
+ const lower = normWs(text).toLowerCase()
310
+ let best: Element | null = null
311
+ let bestScore: MatchScore | null = null
312
+ for (const el of candidates) {
313
+ const s = matchScore(el, lower)
314
+ if (s && (!bestScore || scoreBetter(s, bestScore))) {
315
+ best = el
316
+ bestScore = s
317
+ }
318
+ }
319
+ return best
320
+ }
321
+
322
+ // One judgment of "can a real click land on this element *right now*", shared by the read
323
+ // side (/screen knobs, findElement text-match, the miss clickable-list) and the write side
324
+ // (doClick hit-tests via elementFromPoint). They used to disagree: readers only checked
325
+ // isVisible (has a box), but a boxed element can be covered by an overlay, pointer-events:
326
+ // none, or scrolled out of the viewport — so /screen listed knobs that /click then couldn't
327
+ // hit. reachable is the write-side truth: a mouse at the element's center hits this element
328
+ // or something in its own subtree chain. isVisible stays the cheap precondition.
329
+ export function reachable(el: Element): boolean {
330
+ if (!isVisible(el))
331
+ return false
332
+ // No hit-testing available (jsdom) → fall back to the box check; real browsers always
333
+ // have elementFromPoint, so this degrades only in tests, never in the live inspector.
334
+ if (typeof document.elementFromPoint !== 'function')
335
+ return true
336
+ const r = el.getBoundingClientRect()
337
+ const x = r.left + r.width / 2
338
+ const y = r.top + r.height / 2
339
+ // Off-viewport (scrolled away) → elementFromPoint can't reach it → not clickable now.
340
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight)
341
+ return false
342
+ const hit = document.elementFromPoint(x, y)
343
+ if (!hit)
344
+ return false
345
+ // Hit lands inside this element's own chain (it, a descendant, or an ancestor that the
346
+ // click still routes to) — same containment test doClick uses to decide "landed on us".
347
+ return el.contains(hit) || hit.contains(el)
348
+ }
@@ -0,0 +1,305 @@
1
+ /// <reference lib="dom" />
2
+ // 界面 → 等价草图。唯一标准:肉眼在屏幕上看到什么矩形,就在字符网格上画那个矩形 —— 像人手画
3
+ // wireframe。代替视觉喂 AI,不走截图/像素逆向。
4
+ //
5
+ // 这是一台「抽象 UI 渲染引擎」:把真实视口几何降采样到有上限(MAX_COLS×MAX_ROWS)的字符网格。
6
+ // 位置+尺寸就是布局:三栏=三个并排矩形;bot 消息=左侧框,user 消息=右侧框,日期=居中无框;
7
+ // 高消息=高框,矮消息=矮框。
8
+ //
9
+ // 两步正交分离:
10
+ // ① collectCanvas(浏览器,读 DOM)—— 见 client.ts,带副作用、读真实 DOM + 视口可见性 + 几何。
11
+ // 它把每个框降采样成「相对父框内部原点」的整数网格矩形 {col,row,w,h},烘进 Box 树。
12
+ // ② renderCanvas(纯函数,本文件)—— Box 树 → 字符串,零 DOM、零像素坐标,teact 钉死契约。
13
+ // 只消费离散整数坐标,画到可变字符网格。零位置数学。
14
+ //
15
+ // 单递归 + 几何停止:collector 对每个框问「降采样后框宽塞得下文字吗?能 → 填文字前缀(超了 …);
16
+ // 不能且有内部结构 → 递归画子框」。三档(窄=label/中=正文/大=容器)从框宽涌现,不硬编码。
17
+ // 人画草图不给每个词画框 —— 纯文本段 drawBorder=false,只有真矩形(栏/气泡/按钮)画框。
18
+
19
+ const MAX_COLS = 160 // 字符画布列宽上限(画布物理边界)
20
+ const MAX_ROWS = 80 // 字符画布行高上限(cap 防巨页炸满)
21
+
22
+ export type Side = 'left' | 'center' | 'right' // 仅作框内 label 对齐提示;摆位本身靠 col/row
23
+ export type BoxKind = 'frame' | 'bubble' | 'knob' | 'text'
24
+
25
+ // 一个画出来的矩形。collector 已把一切降采样成「相对父框内部原点」的整数网格坐标 —— renderer
26
+ // 零位置数学,只按 col/row/w/h 画框 + 写 label + 递归子框。
27
+ export interface Box {
28
+ kind: BoxKind
29
+ col: number // 相对父框内部原点的 x 字符偏移 (>=0)
30
+ row: number // 相对父框内部原点的 y 行偏移 (>=0)
31
+ w: number // 字符宽,含 2 列边框 (>=1)
32
+ h: number // 行高,含 2 行边框 (>=1)
33
+ label: string // 已截断的文字/图标/landmark 名,或 ''
34
+ morphism: string // ' → store.{ field=to }' / ''
35
+ drawBorder: boolean // true → 画 ┌┐└┘─│;false → 纯 label 块(文本段/日期)
36
+ children: Box[] // 子框,坐标相对本框内部
37
+ }
38
+
39
+ export type Node = Box // 别名,client.ts 的 import 仍解析
40
+
41
+ // @kN 可寻址旋钮 ref:单一权威源。按 renderKnobs 同款栏序遍历(root 直接 frame 子分组、栏内
42
+ // 递归收 knob),给每个 kind==='knob' 的 Box 依次编 @k1 @k2…。四投影都读这一份映射 → 号必一致。
43
+ // 缺省不传 refs 时四投影逐字节同旧(纯增量旁路)。
44
+ export function numberKnobs(root: Box): Map<Box, string> {
45
+ const refs = new Map<Box, string>()
46
+ let n = 0
47
+ const collect = (box: Box, acc: Box[]): void => {
48
+ if (box.kind === 'knob')
49
+ acc.push(box)
50
+ for (const c of box.children)
51
+ collect(c, acc)
52
+ }
53
+ const columns = root.children.filter(c => c.kind === 'frame')
54
+ const cols = columns.length ? columns : [root]
55
+ for (const col of cols) {
56
+ const knobs: Box[] = []
57
+ collect(col, knobs)
58
+ for (const k of knobs)
59
+ refs.set(k, `@k${++n}`)
60
+ }
61
+ return refs
62
+ }
63
+
64
+ // 该框的 ref 前缀(有号则 '@k3 ',否则 '')。
65
+ function refPrefix(box: Box, refs?: Map<Box, string>): string {
66
+ const r = refs?.get(box)
67
+ return r ? `${r} ` : ''
68
+ }
69
+
70
+ // 显示宽度:CJK/全角字符占 2 列,其余 1 列。JS .length 是 code unit 数,CJK 算 1 —— 直接
71
+ // padEnd 会让「群聊」比「AI 对话」视觉更短,列对不齐,AI 读错结构。代替视觉必须按真实占列。
72
+ function displayWidth(s: string): number {
73
+ let w = 0
74
+ for (const ch of s) {
75
+ const c = ch.codePointAt(0)!
76
+ const wide = (c >= 0x1100 && c <= 0x115F) // Hangul Jamo
77
+ || (c >= 0x2E80 && c <= 0xA4CF) // CJK 部首…彝文(含〔〕「」、汉字、假名)
78
+ || (c >= 0xAC00 && c <= 0xD7A3) // Hangul 音节
79
+ || (c >= 0xF900 && c <= 0xFAFF) // CJK 兼容表意
80
+ || (c >= 0xFE30 && c <= 0xFE4F) // CJK 兼容形式
81
+ || (c >= 0xFF00 && c <= 0xFF60) // 全角 ASCII
82
+ || (c >= 0xFFE0 && c <= 0xFFE6) // 全角符号
83
+ || (c >= 0x20000 && c <= 0x3FFFD) // CJK 扩展 B+
84
+ w += wide ? 2 : 1
85
+ }
86
+ return w
87
+ }
88
+
89
+ // 截到恰好 width 显示列:在宽字边界停下(不切半个 CJK),被截则末尾留位放 …。
90
+ // 纯字符遍历,禁正则。width 不足放 … 时退化成尽量塞。
91
+ function clipDisplay(s: string, width: number): string {
92
+ if (displayWidth(s) <= width)
93
+ return s
94
+ if (width <= 1)
95
+ return '…'.slice(0, width)
96
+ let w = 0
97
+ let out = ''
98
+ for (const ch of s) {
99
+ const cw = displayWidth(ch)
100
+ if (w + cw > width - 1) // 留 1 列给 …
101
+ break
102
+ out += ch
103
+ w += cw
104
+ }
105
+ return out + '…'
106
+ }
107
+
108
+ function clampInt(v: number, lo: number, hi: number): number {
109
+ return v < lo ? lo : v > hi ? hi : v
110
+ }
111
+
112
+ // 去尾空格,纯字符扫描(禁正则)。
113
+ function trimEnd(s: string): string {
114
+ let end = s.length
115
+ while (end > 0 && s[end - 1] === ' ')
116
+ end--
117
+ return s.slice(0, end)
118
+ }
119
+
120
+ // 把字符串写进网格,从 (row,col) 起逐字铺。CJK 占两格:左格放字、右格放 '' 哨兵并跳 2,
121
+ // 绝不切半个宽字。越界即停。
122
+ function writeText(grid: string[][], row: number, col: number, s: string): void {
123
+ const cols = grid[0]?.length ?? 0
124
+ if (row < 0 || row >= grid.length)
125
+ return
126
+ let c = col
127
+ for (const ch of s) {
128
+ if (c >= cols)
129
+ break
130
+ if (displayWidth(ch) === 2) {
131
+ if (c + 1 >= cols)
132
+ break
133
+ grid[row][c] = ch
134
+ grid[row][c + 1] = ''
135
+ c += 2
136
+ }
137
+ else {
138
+ grid[row][c] = ch
139
+ c += 1
140
+ }
141
+ }
142
+ }
143
+
144
+ // label 装得下态射就拼上(宽框行内显态射),否则只 label(窄框态射降级到尾部图例)。
145
+ // 有 @kN ref 时前缀 '@k3 ':ref 是寻址锚,比文字更重要 —— 框宽不够时由 clipDisplay 截 label
146
+ // 尾部(前缀在头部天然保留)。
147
+ function composeLabel(box: Box, innerW: number, refs?: Map<Box, string>): string {
148
+ const pre = refPrefix(box, refs)
149
+ if (box.morphism && displayWidth(pre + box.label + box.morphism) <= innerW)
150
+ return pre + box.label + box.morphism
151
+ return pre + box.label
152
+ }
153
+
154
+ // 一个框是否会把态射行内渲出来 —— 与 composeLabel 同判据,collectLegend 据此挑漏网的。
155
+ function morphismShownInline(box: Box): boolean {
156
+ const innerW = box.drawBorder ? box.w - 2 : box.w
157
+ return displayWidth(box.label + box.morphism) <= innerW
158
+ }
159
+
160
+ // 把一个框画进网格。originCol/originRow = 本框左上角的绝对网格坐标。
161
+ function paint(grid: string[][], box: Box, originCol: number, originRow: number, refs?: Map<Box, string>): void {
162
+ const rows = grid.length
163
+ const cols = grid[0]?.length ?? 0
164
+ if (rows === 0 || cols === 0)
165
+ return
166
+ const c0 = clampInt(originCol, 0, cols - 1)
167
+ const r0 = clampInt(originRow, 0, rows - 1)
168
+ const c1 = clampInt(c0 + Math.max(1, box.w) - 1, 0, cols - 1) // 含右边框列
169
+ const r1 = clampInt(r0 + Math.max(1, box.h) - 1, 0, rows - 1) // 含下边框行
170
+
171
+ // 太薄画不成框(内宽<2 或内高<1)→ 降级:不画框,有 label 当裸文本铺。
172
+ const drawable = box.drawBorder && (c1 - c0) >= 2 && (r1 - r0) >= 1
173
+ if (drawable) {
174
+ for (let c = c0; c <= c1; c++) {
175
+ grid[r0][c] = c === c0 ? '┌' : c === c1 ? '┐' : '─'
176
+ grid[r1][c] = c === c0 ? '└' : c === c1 ? '┘' : '─'
177
+ }
178
+ for (let r = r0 + 1; r < r1; r++) {
179
+ grid[r][c0] = '│'
180
+ grid[r][c1] = '│'
181
+ }
182
+ }
183
+
184
+ // 内部原点:有框则缩进 1 让 child.col=0 贴内壁;无框则就在框原点。
185
+ const ic = drawable ? c0 + 1 : c0
186
+ const ir = drawable ? r0 + 1 : r0
187
+ const innerW = drawable ? (c1 - c0 - 1) : (c1 - c0 + 1)
188
+
189
+ // label 行必须落在内部:有框时严格在下边框之上(ir<r1),无框时就在框内(ir<=r1)。
190
+ const labelRowOk = drawable ? ir < r1 : ir <= r1
191
+ if (box.label && innerW >= 1 && labelRowOk)
192
+ writeText(grid, ir, ic, clipDisplay(composeLabel(box, innerW, refs), innerW))
193
+
194
+ for (const child of box.children)
195
+ paint(grid, child, ic + child.col, ir + child.row, refs)
196
+ }
197
+
198
+ // 态射图例:递归全树,拼成尾部块。两类入图例:
199
+ // ① 所有有 ref 的 knob(带 @kN)—— 让「看图例即得全部可寻址号」,即便其框已行内显出。
200
+ // ② 非 knob 但有态射且没被行内渲出的框 —— 窄图标 store 跃迁框太小写不下也不能丢。
201
+ // 缺省无 refs 时退化成旧行为(只挑漏网的)。
202
+ function collectLegend(root: Box, refs?: Map<Box, string>): string {
203
+ const lines: string[] = []
204
+ const walk = (box: Box): void => {
205
+ const ref = refs?.get(box)
206
+ if (ref)
207
+ lines.push(`${ref} ${box.label ? `[${box.label}]` : `<${box.kind}>`}${box.morphism}`)
208
+ else if (box.morphism && !morphismShownInline(box))
209
+ lines.push(`${box.label ? `[${box.label}]` : `<${box.kind}>`}${box.morphism}`)
210
+ for (const c of box.children)
211
+ walk(c)
212
+ }
213
+ walk(root)
214
+ if (lines.length === 0)
215
+ return ''
216
+ return ['┄ 控制面板态射 ┄', ...lines].join('\n')
217
+ }
218
+
219
+ export function renderCanvas(root: Box, refs?: Map<Box, string>): string {
220
+ const rows = Math.min(MAX_ROWS, Math.max(1, root.h))
221
+ const cols = Math.min(MAX_COLS, Math.max(1, root.w))
222
+ const grid: string[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ' '))
223
+ paint(grid, root, 0, 0, refs)
224
+ const body = grid.map(r => trimEnd(r.join(''))).join('\n')
225
+ const legend = collectLegend(root, refs)
226
+ return legend ? `${body}\n\n${legend}` : body
227
+ }
228
+
229
+ // ── 同源派生:下面三个纯函数消费同一棵 Box 树,渲成 B/C/D 三种文本形态。 ───────────────
230
+ // 与 renderCanvas(A=ASCII)同源、零手写。几何全在 Box 的 col/row/w/h 里,各形态只是不同投影:
231
+ // B 语义布局树 —— 几何编码进关系词(左/中/右 + 大/中/小),缩进表嵌套。
232
+ // C 关系式 —— 几何展平成 above/below/left-of/right-of/inside 三元组。
233
+ // D 旋钮表 —— 只投影可操作面(kind=knob)+ 态射,按顶层栏分组。
234
+
235
+ // 一个框相对父内部宽的水平位置档:left/center/right(纯 col/w 算术,不读 DOM)。
236
+ function colBand(box: Box, parentInnerW: number): Side {
237
+ const center = box.col + box.w / 2
238
+ if (center < parentInnerW / 3)
239
+ return 'left'
240
+ if (center > (parentInnerW * 2) / 3)
241
+ return 'right'
242
+ return 'center'
243
+ }
244
+
245
+ // 尺寸档:按字符面积粗分大/中/小(给 AI「视觉体量」直觉,Q7 类题靠这个)。
246
+ function sizeBand(box: Box): string {
247
+ const area = box.w * box.h
248
+ if (area >= 600)
249
+ return '大'
250
+ if (area >= 80)
251
+ return '中'
252
+ return '小'
253
+ }
254
+
255
+ const SIDE_CN: Record<Side, string> = { left: '左', center: '中', right: '右' }
256
+
257
+ // B:语义布局树。每行 = 缩进 + kind 记号 + (@kN) + label + (位置/尺寸/态射)标注。
258
+ export function renderSemanticTree(root: Box, refs?: Map<Box, string>): string {
259
+ const lines: string[] = ['语义布局树(几何→关系词;缩进=嵌套):']
260
+ const walk = (box: Box, depth: number, parentInnerW: number): void => {
261
+ if (depth > 0) {
262
+ const tags: string[] = []
263
+ const band = colBand(box, parentInnerW)
264
+ if (band !== 'center' || box.kind === 'bubble')
265
+ tags.push(SIDE_CN[band])
266
+ if (box.kind === 'frame' || box.kind === 'bubble')
267
+ tags.push(sizeBand(box))
268
+ const mark = box.kind === 'knob' ? '●' : box.kind === 'frame' ? '▢' : box.kind === 'bubble' ? '◷' : '·'
269
+ const text = box.label || `<${box.kind}>`
270
+ const tagStr = tags.length ? ` (${tags.join('/')})` : ''
271
+ lines.push(`${' '.repeat(depth)}${mark} ${refPrefix(box, refs)}${text}${tagStr}${box.morphism}`)
272
+ }
273
+ const innerW = box.drawBorder ? Math.max(1, box.w - 2) : box.w
274
+ for (const c of box.children)
275
+ walk(c, depth + 1, innerW)
276
+ }
277
+ walk(root, 0, Math.max(1, root.w))
278
+ return lines.join('\n')
279
+ }
280
+
281
+ // D:控制论旋钮表。只投影 kind=knob(可操作面)+ 态射,按顶层栏(root 的直接 frame 子)分组。
282
+ // 每行带 @kN(可寻址号);此栏序遍历与 numberKnobs 同款 → 号与编号权威源天然一致。
283
+ export function renderKnobs(root: Box, refs?: Map<Box, string>): string {
284
+ const lines: string[] = ['控制论投影:能拨的旋钮(可点,@kN 可寻址)+ 态射。布局=旋钮所在栏。']
285
+ const collectKnobs = (box: Box, acc: Box[]): void => {
286
+ if (box.kind === 'knob')
287
+ acc.push(box)
288
+ for (const c of box.children)
289
+ collectKnobs(c, acc)
290
+ }
291
+ // 顶层栏 = root 的直接 frame 子(并排栏)。每栏内收集旋钮。
292
+ const columns = root.children.filter(c => c.kind === 'frame')
293
+ const cols = columns.length ? columns : [root]
294
+ for (const col of cols) {
295
+ const knobs: Box[] = []
296
+ collectKnobs(col, knobs)
297
+ if (knobs.length === 0)
298
+ continue
299
+ const colName = col.label || `<${col.kind}>`
300
+ lines.push(`\n[${colName} 栏]`)
301
+ for (const k of knobs)
302
+ lines.push(` ${refPrefix(k, refs)}${k.label || `<${k.kind}>`}${k.morphism}`)
303
+ }
304
+ return lines.join('\n')
305
+ }