diff --git a/clis/chatwise/ask.js b/clis/chatwise/ask.js
index 7178697ef..71b2ecdab 100644
--- a/clis/chatwise/ask.js
+++ b/clis/chatwise/ask.js
@@ -1,5 +1,11 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
-import { selectorError } from '@jackwener/opencli/errors';
+import { selectorError, TimeoutError } from '@jackwener/opencli/errors';
+import {
+ buildChatwiseInjectTextJs,
+ buildChatwiseMessageCountJs,
+ buildChatwiseResponseAfterJs,
+ normalizeTimeout,
+} from './utils.js';
export const askCommand = cli({
site: 'chatwise',
name: 'ask',
@@ -15,34 +21,11 @@ export const askCommand = cli({
columns: ['Role', 'Text'],
func: async (page, kwargs) => {
const text = kwargs.text;
- const timeout = parseInt(kwargs.timeout, 10) || 30;
+ const timeout = normalizeTimeout(kwargs.timeout);
// Snapshot content length
- const beforeLen = await page.evaluate(`
- (function() {
- const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
- return msgs.length;
- })()
- `);
+ const beforeLen = await page.evaluate(buildChatwiseMessageCountJs());
// Send message
- const injected = await page.evaluate(`
- (function(text) {
- let composer = document.querySelector('textarea');
- if (!composer) {
- const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
- composer = editables.length > 0 ? editables[editables.length - 1] : null;
- }
- if (!composer) return false;
- composer.focus();
- if (composer.tagName === 'TEXTAREA') {
- const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
- setter.call(composer, text);
- composer.dispatchEvent(new Event('input', { bubbles: true }));
- } else {
- document.execCommand('insertText', false, text);
- }
- return true;
- })(${JSON.stringify(text)})
- `);
+ const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
if (!injected)
throw selectorError('ChatWise input element');
await page.wait(0.5);
@@ -53,25 +36,15 @@ export const askCommand = cli({
let response = '';
for (let i = 0; i < maxPolls; i++) {
await page.wait(pollInterval);
- const result = await page.evaluate(`
- (function(prevLen) {
- const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
- if (msgs.length <= prevLen) return null;
- const last = msgs[msgs.length - 1];
- const text = last.innerText || last.textContent;
- return text ? text.trim() : null;
- })(${beforeLen})
- `);
+ const result = await page.evaluate(buildChatwiseResponseAfterJs(beforeLen, text));
if (result) {
- response = result;
- break;
+ const next = String(result).trim();
+ if (next === response) break;
+ response = next;
}
}
if (!response) {
- return [
- { Role: 'User', Text: text },
- { Role: 'System', Text: `No response within ${timeout}s.` },
- ];
+ throw new TimeoutError('ChatWise response', timeout, 'Confirm ChatWise is done generating, then retry with a larger --timeout if needed.');
}
return [
{ Role: 'User', Text: text },
diff --git a/clis/chatwise/composer.test.js b/clis/chatwise/composer.test.js
new file mode 100644
index 000000000..f0a92270b
--- /dev/null
+++ b/clis/chatwise/composer.test.js
@@ -0,0 +1,186 @@
+import { JSDOM } from 'jsdom';
+import { describe, expect, it, vi } from 'vitest';
+import { ArgumentError, TimeoutError } from '@jackwener/opencli/errors';
+import {
+ buildChatwiseInjectTextJs,
+ buildChatwiseMessageCountJs,
+ buildChatwiseResponseAfterJs,
+ normalizeTimeout,
+ scoreChatwiseComposerCandidate,
+ selectBestChatwiseComposer,
+} from './utils.js';
+import { askCommand } from './ask.js';
+
+function candidate(overrides = {}) {
+ return {
+ index: 0,
+ hidden: false,
+ role: 'textbox',
+ classes: 'cm-content cm-lineWrapping',
+ editorClasses: 'cm-editor',
+ ariaLabel: '',
+ placeholder: '',
+ text: '',
+ rect: { y: 0, h: 30 },
+ ...overrides,
+ };
+}
+
+function runBrowserScript(html, script) {
+ const dom = new JSDOM(html, { url: 'app://chatwise.local/', runScripts: 'outside-only' });
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 });
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 32 });
+ dom.window.HTMLElement.prototype.getClientRects = () => [{ length: 1 }];
+ dom.window.document.execCommand = () => false;
+ return { dom, result: dom.window.eval(script) };
+}
+
+function makePage(evaluateResults = []) {
+ const evaluate = vi.fn();
+ for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
+ evaluate.mockResolvedValue(null);
+ return {
+ evaluate,
+ wait: vi.fn().mockResolvedValue(undefined),
+ pressKey: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+describe('chatwise composer selection', () => {
+ it('prefers the main composer over auxiliary contenteditable editors', () => {
+ const mainComposer = candidate({
+ index: 0,
+ placeholder: 'placeholder Enter a message here, press ⏎ to send',
+ rect: { y: 860, h: 32 },
+ });
+ const optionalDescription = candidate({
+ index: 1,
+ placeholder: 'placeholder Optional description',
+ editorClasses: 'cm-editor simple-editor',
+ rect: { y: 400, h: 32 },
+ });
+ const userContext = candidate({
+ index: 2,
+ text: '# User Context Document',
+ editorClasses: 'cm-editor simple-editor',
+ rect: { y: 460, h: 1200 },
+ });
+
+ expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
+ scoreChatwiseComposerCandidate(optionalDescription, 900),
+ );
+ expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
+ scoreChatwiseComposerCandidate(userContext, 900),
+ );
+
+ expect(selectBestChatwiseComposer([
+ optionalDescription,
+ userContext,
+ mainComposer,
+ ], 900)?.index).toBe(0);
+ });
+
+ it('rejects hidden or low-confidence candidates instead of injecting into the wrong editor', () => {
+ expect(selectBestChatwiseComposer([
+ candidate({
+ index: 0,
+ hidden: true,
+ placeholder: 'Enter a message here, press ⏎ to send',
+ rect: { y: 860, h: 32 },
+ }),
+ ], 900)).toBeNull();
+
+ expect(selectBestChatwiseComposer([
+ candidate({
+ index: 1,
+ placeholder: 'Optional description',
+ editorClasses: 'cm-editor simple-editor',
+ rect: { y: 860, h: 32 },
+ }),
+ candidate({
+ index: 2,
+ text: '# User Context Document',
+ editorClasses: 'cm-editor simple-editor',
+ rect: { y: 870, h: 32 },
+ }),
+ ], 900)).toBeNull();
+ });
+
+ it('injects text into the scored main composer instead of the last contenteditable', () => {
+ const html = `
+
+
Optional description
+
+
+
+
Enter a message here, press ⏎ to send
+
+
+
+
# User Context Document
+
+ `;
+
+ const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello'));
+
+ expect(result).toBe(true);
+ expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello');
+ expect(dom.window.document.querySelector('#optional')?.textContent).toBe('');
+ expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document');
+ });
+
+ it('fails injection when only auxiliary editors are present', () => {
+ const html = `
+
+
Optional description
+
+
+
+
# User Context Document
+
+ `;
+
+ const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello'));
+
+ expect(result).toBe(false);
+ expect(dom.window.document.querySelector('#optional')?.textContent).toBe('');
+ expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document');
+ });
+
+ it('reads only real message wrapper content after the previous count', () => {
+ const html = `
+ old message
+ 12:00
+ new assistant answer
+ `;
+
+ expect(runBrowserScript(html, buildChatwiseMessageCountJs()).result).toBe(2);
+ expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBe('new assistant answer');
+ });
+
+ it('does not treat the user prompt wrapper as an assistant response', () => {
+ const html = `
+ old message
+ user prompt
+ `;
+
+ expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBeNull();
+ });
+
+ it('validates timeout explicitly', () => {
+ expect(normalizeTimeout(undefined)).toBe(30);
+ expect(() => normalizeTimeout('0')).toThrow(ArgumentError);
+ expect(() => normalizeTimeout('301')).toThrow(ArgumentError);
+ });
+
+ it('fails fast when ask times out instead of returning a System success row', async () => {
+ const page = makePage([
+ 1,
+ true,
+ null,
+ ]);
+
+ await expect(askCommand.func(page, { text: 'hello', timeout: 1 }))
+ .rejects.toBeInstanceOf(TimeoutError);
+ });
+});
diff --git a/clis/chatwise/send.js b/clis/chatwise/send.js
index 7f7ca9883..f18967b15 100644
--- a/clis/chatwise/send.js
+++ b/clis/chatwise/send.js
@@ -1,5 +1,6 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { selectorError } from '@jackwener/opencli/errors';
+import { buildChatwiseInjectTextJs } from './utils.js';
export const sendCommand = cli({
site: 'chatwise',
name: 'send',
@@ -12,30 +13,7 @@ export const sendCommand = cli({
columns: ['Status', 'InjectedText'],
func: async (page, kwargs) => {
const text = kwargs.text;
- const injected = await page.evaluate(`
- (function(text) {
- // ChatWise input can be textarea or contenteditable
- let composer = document.querySelector('textarea');
- if (!composer) {
- const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
- composer = editables.length > 0 ? editables[editables.length - 1] : null;
- }
-
- if (!composer) return false;
-
- composer.focus();
-
- if (composer.tagName === 'TEXTAREA') {
- // For textarea, set value and dispatch input event
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
- nativeInputValueSetter.call(composer, text);
- composer.dispatchEvent(new Event('input', { bubbles: true }));
- } else {
- document.execCommand('insertText', false, text);
- }
- return true;
- })(${JSON.stringify(text)})
- `);
+ const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
if (!injected)
throw selectorError('ChatWise input element');
await page.wait(0.5);
diff --git a/clis/chatwise/utils.js b/clis/chatwise/utils.js
new file mode 100644
index 000000000..dfa8673d9
--- /dev/null
+++ b/clis/chatwise/utils.js
@@ -0,0 +1,147 @@
+import { ArgumentError } from '@jackwener/opencli/errors';
+
+export const MESSAGE_WRAPPER_SELECTOR = '[class*="group/message"]';
+export const MIN_COMPOSER_SCORE = 120;
+
+export function normalizeTimeout(value, defaultSeconds = 30, maxSeconds = 300) {
+ const raw = value ?? defaultSeconds;
+ const timeout = Number(raw);
+ if (!Number.isInteger(timeout) || timeout <= 0) {
+ throw new ArgumentError('timeout must be a positive integer', `Example: opencli chatwise ask "hello" --timeout ${defaultSeconds}`);
+ }
+ if (timeout > maxSeconds) {
+ throw new ArgumentError(`timeout must be <= ${maxSeconds}`, `Example: opencli chatwise ask "hello" --timeout ${maxSeconds}`);
+ }
+ return timeout;
+}
+
+export function scoreChatwiseComposerCandidate(candidate, viewportHeight = 0) {
+ if (candidate.hidden) return -1000;
+
+ let score = 0;
+ const normalizedRole = String(candidate.role || '').toLowerCase();
+ if (normalizedRole === 'textbox') score += 10;
+
+ const normalizedClasses = `${candidate.classes || ''} ${candidate.editorClasses || ''} ${candidate.ariaLabel || ''}`.toLowerCase();
+ if (normalizedClasses.includes('cm-content')) score += 20;
+ if (normalizedClasses.includes('cm-editor')) score += 30;
+ if (normalizedClasses.includes('simple-editor')) score -= 140;
+
+ const searchableText = `${candidate.placeholder || ''} ${candidate.ariaLabel || ''} ${candidate.text || ''}`.toLowerCase();
+ if (searchableText.includes('enter a message here')) score += 220;
+ if (searchableText.includes('press ⏎ to send')) score += 80;
+ if (searchableText.includes('press enter to send')) score += 80;
+ if (searchableText.includes('message')) score += 20;
+ if (searchableText.includes('optional description')) score -= 140;
+ if (searchableText.includes('user context document')) score -= 220;
+
+ if (viewportHeight > 0 && candidate.rect) {
+ const bottom = candidate.rect.y + candidate.rect.h;
+ const distanceFromBottom = Math.abs(viewportHeight - bottom);
+ score += Math.max(0, 80 - distanceFromBottom / 8);
+ }
+
+ return score;
+}
+
+export function selectBestChatwiseComposer(candidates, viewportHeight = 0, minScore = MIN_COMPOSER_SCORE) {
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
+ const best = [...candidates]
+ .sort((left, right) => {
+ const delta = scoreChatwiseComposerCandidate(right, viewportHeight)
+ - scoreChatwiseComposerCandidate(left, viewportHeight);
+ return delta !== 0 ? delta : left.index - right.index;
+ })[0] ?? null;
+ if (!best || scoreChatwiseComposerCandidate(best, viewportHeight) < minScore) return null;
+ return best;
+}
+
+export function buildChatwiseInjectTextJs(text) {
+ const scoreFn = scoreChatwiseComposerCandidate.toString();
+ const selectFn = selectBestChatwiseComposer.toString();
+ const textJs = JSON.stringify(String(text ?? ''));
+
+ return `
+ (function(text) {
+ const scoreChatwiseComposerCandidate = ${scoreFn};
+ const selectBestChatwiseComposer = ${selectFn};
+ const MIN_COMPOSER_SCORE = ${MIN_COMPOSER_SCORE};
+
+ const composers = Array.from(document.querySelectorAll([
+ 'textarea[aria-label*="message" i]',
+ 'textarea[placeholder*="message" i]',
+ '[contenteditable="true"][role="textbox"]',
+ '[contenteditable="true"]'
+ ].join(',')));
+ const candidates = composers.map((el, index) => {
+ const rect = el.getBoundingClientRect();
+ const editor = el.closest('.cm-editor');
+ const placeholderEl = editor?.querySelector('.cm-placeholder');
+ return {
+ index,
+ hidden: !(el.offsetWidth || el.offsetHeight || el.getClientRects().length),
+ role: el.getAttribute('role'),
+ classes: el.className || '',
+ editorClasses: editor?.className || '',
+ ariaLabel: el.getAttribute('aria-label') || '',
+ placeholder: placeholderEl?.getAttribute('aria-label') || placeholderEl?.textContent || el.getAttribute('placeholder') || '',
+ text: (el.textContent || '').trim(),
+ rect: { y: rect.y, h: rect.height },
+ };
+ });
+
+ const best = selectBestChatwiseComposer(candidates, window.innerHeight, MIN_COMPOSER_SCORE);
+ if (!best) return false;
+
+ const composer = composers[best.index];
+ composer.focus();
+
+ if (composer.tagName === 'TEXTAREA') {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
+ if (setter) setter.call(composer, text);
+ else composer.value = text;
+ composer.dispatchEvent(new Event('input', { bubbles: true }));
+ return true;
+ }
+
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(composer);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const inserted = document.execCommand?.('insertText', false, text);
+ if (!inserted) {
+ composer.textContent = text;
+ }
+ composer.dispatchEvent(new Event('input', { bubbles: true }));
+ return true;
+ })(${textJs})
+ `;
+}
+
+export function buildChatwiseMessageCountJs() {
+ return `
+ (function() {
+ return Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)}))
+ .map(node => (node.innerText || node.textContent || '').trim())
+ .filter(Boolean)
+ .length;
+ })()
+ `;
+}
+
+export function buildChatwiseResponseAfterJs(previousCount, userText) {
+ return `
+ (function(previousCount, userText) {
+ const messages = Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)}))
+ .map(node => (node.innerText || node.textContent || '').trim())
+ .filter(Boolean);
+ if (messages.length <= previousCount) return null;
+ const fresh = messages.slice(previousCount)
+ .filter(text => text && text !== userText);
+ if (fresh.length === 0) return null;
+ return fresh[fresh.length - 1];
+ })(${Number(previousCount) || 0}, ${JSON.stringify(String(userText ?? ''))})
+ `;
+}