agent terminal: multi-line textarea input

swap the single-line <input> for an auto-growing <textarea>. enter
submits, shift+enter newlines, ime composition respected (skip submit
while isComposing). height caps at ~12em then scrolls. submit-hint
updates to '↵ send · ⇧↵ newline'. async-form handler now also clears
textareas on success.
This commit is contained in:
müde 2026-05-15 19:21:00 +02:00
parent fd39226883
commit 85e1f1a8f4
2 changed files with 36 additions and 12 deletions

View file

@ -47,7 +47,7 @@
return;
}
// Clear text inputs the operator typed into (the form value was sent).
f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; });
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
// Re-enable the button — refreshState() often skips re-rendering the
// form (status unchanged), so without this the spinner sticks and
// the operator can't submit again.
@ -165,20 +165,37 @@
action: '/send', method: 'POST',
class: 'sendform-term', 'data-async': '',
});
const ta = el('textarea', {
name: 'body', placeholder: 'message ' + label + '…',
required: '', autocomplete: 'off', rows: '1',
});
// Enter submits, Shift+Enter inserts a newline. Auto-grow up to
// ~8 rows of content, then scroll inside the textarea.
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
const grow = () => {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
};
ta.addEventListener('input', grow);
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
if (ta.value.trim()) form.requestSubmit();
}
});
// Reset height after async submit clears the value.
form.addEventListener('submit', () => setTimeout(grow, 0));
form.append(
el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
el('input', {
name: 'body', placeholder: 'message ' + label + '…',
required: '', autocomplete: 'off',
}),
el('span', { class: 'submit-hint' }, 'enter ↵'),
ta,
el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline'),
);
slot.append(form);
termInputRendered = true;
}
slot.classList.toggle('disabled', !online);
const input = slot.querySelector('input');
if (input) input.disabled = !online;
const ta = slot.querySelector('textarea');
if (ta) ta.disabled = !online;
}
// Track banner activity by reference-counting in-flight turns. A turn