/ src / browser / compound.ts
compound.ts
  1  /**
  2   * Compound-component expansion for high-agent-failure form controls.
  3   *
  4   * Agents burn turns on three recurring input categories because the raw
  5   * attribute dump from `browser state` under-specifies them:
  6   *
  7   *   - date / time / datetime-local / month / week — agents type
  8   *     free-form strings and the browser silently ignores mismatched formats.
  9   *   - select — the snapshot caps visible options at ~6; agents don't know
 10   *     the full option set, can't match by label, and waste turns clicking
 11   *     to open the dropdown just to read options.
 12   *   - file — the snapshot shows current filenames but not `accept` or
 13   *     `multiple`; agents re-upload or pick unsupported MIME types.
 14   *
 15   * `compoundInfoOf(el)` returns a structured JSON summary agents can rely
 16   * on. Included in `browser find --css` envelope so the agent gets the
 17   * rich view without extra round-trips.
 18   *
 19   * Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined
 20   * into the generated evaluate scripts under find / snapshot / eval.
 21   */
 22  
 23  export type DateLikeControl = 'date' | 'time' | 'datetime-local' | 'month' | 'week';
 24  
 25  export interface DateCompound {
 26    control: DateLikeControl;
 27    format: string;
 28    current: string;
 29    min?: string;
 30    max?: string;
 31  }
 32  
 33  export interface SelectOption {
 34    label: string;
 35    value: string;
 36    selected: boolean;
 37    disabled?: boolean;
 38  }
 39  
 40  export interface SelectCompound {
 41    control: 'select';
 42    multiple: boolean;
 43    current: string | string[];
 44    options: SelectOption[];
 45    options_total: number;
 46  }
 47  
 48  export interface FileCompound {
 49    control: 'file';
 50    multiple: boolean;
 51    current: string[];
 52    accept?: string;
 53  }
 54  
 55  export type CompoundInfo = DateCompound | SelectCompound | FileCompound;
 56  
 57  /** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */
 58  export const COMPOUND_SELECT_OPTIONS_CAP = 50;
 59  
 60  /** Max characters per option label / file name. */
 61  export const COMPOUND_LABEL_CAP = 80;
 62  
 63  /**
 64   * JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS
 65   * emitted by `buildFindJs` (and any other evaluate script that needs the
 66   * rich compound view). Returns a `CompoundInfo` object or `null`.
 67   */
 68  export const COMPOUND_INFO_JS = `
 69  function compoundInfoOf(el) {
 70    if (!el || !el.tagName) return null;
 71    const tag = el.tagName;
 72    const LABEL_CAP = ${COMPOUND_LABEL_CAP};
 73    const OPTS_CAP = ${COMPOUND_SELECT_OPTIONS_CAP};
 74    if (tag === 'INPUT') {
 75      const type = (el.getAttribute('type') || 'text').toLowerCase();
 76      const FORMATS = {
 77        'date': 'YYYY-MM-DD',
 78        'time': 'HH:MM',
 79        'datetime-local': 'YYYY-MM-DDTHH:MM',
 80        'month': 'YYYY-MM',
 81        'week': 'YYYY-W##',
 82      };
 83      if (FORMATS[type]) {
 84        const info = {
 85          control: type,
 86          format: FORMATS[type],
 87          current: (el.value == null ? '' : String(el.value)),
 88        };
 89        const min = el.getAttribute('min');
 90        if (min) info.min = min;
 91        const max = el.getAttribute('max');
 92        if (max) info.max = max;
 93        return info;
 94      }
 95      if (type === 'file') {
 96        const info = {
 97          control: 'file',
 98          multiple: !!el.multiple,
 99          current: [],
100        };
101        const accept = el.getAttribute('accept');
102        if (accept) info.accept = accept;
103        try {
104          if (el.files && el.files.length) {
105            for (let i = 0; i < el.files.length; i++) {
106              const name = (el.files[i].name || '').slice(0, LABEL_CAP);
107              info.current.push(name);
108            }
109          }
110        } catch (_) {}
111        return info;
112      }
113      return null;
114    }
115    if (tag === 'SELECT') {
116      const multiple = !!el.multiple;
117      const options = [];
118      const selectedLabels = [];
119      let total = 0;
120      try {
121        const opts = el.options || [];
122        total = opts.length;
123        // Walk ALL options so \`current\` reflects selections that sit beyond the
124        // serialization cap. Only the first OPTS_CAP entries get pushed into
125        // options[]; anything past the cap still contributes to selectedLabels
126        // so agents see the true current state of big dropdowns.
127        for (let i = 0; i < opts.length; i++) {
128          const o = opts[i];
129          const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || '');
130          const label = String(labelRaw).trim().slice(0, LABEL_CAP);
131          if (i < OPTS_CAP) {
132            const entry = { label: label, value: o.value, selected: !!o.selected };
133            if (o.disabled) entry.disabled = true;
134            options.push(entry);
135          }
136          if (o.selected) selectedLabels.push(label);
137        }
138      } catch (_) {}
139      return {
140        control: 'select',
141        multiple: multiple,
142        current: multiple ? selectedLabels : (selectedLabels[0] || ''),
143        options: options,
144        options_total: total,
145      };
146    }
147    return null;
148  }
149  `;