Merge pull request #444 from DodgyBadger/feat/mobile-prompt-composer

feat(mobile): add manual prompt composer
This commit is contained in:
Codeman maintainer
2026-09-21 04:23:15 +02:00
9 changed files with 787 additions and 27 deletions
+1
View File
@@ -6851,6 +6851,7 @@ class CodemanApp {
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.discardComposerDraft?.(sessionId);
// Drop any durably-queued input for a session that's actually gone (deleted/
// exited). Not a lost prompt — the target no longer exists. Only reached on
// real session removal, never on a tab switch.
+8 -4
View File
@@ -124,12 +124,15 @@ Object.assign(CodemanApp.prototype, {
// 20 photos don't crawl through serially.
_uploadConcurrency: 3,
async _uploadAndInsertImages(fileList) {
/** Upload a batch and normally insert its paths into the active terminal.
* The prompt composer passes `{ insert: false }` so it can put those paths
* into its textarea instead. Returns successful paths in selection order. */
async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
if (!sessionId) return [];
let files = Array.from(fileList || []);
if (files.length === 0) return;
if (files.length === 0) return [];
// Cap the batch and tell the user what got dropped (no silent truncation).
let capped = false;
@@ -175,7 +178,7 @@ Object.assign(CodemanApp.prototype, {
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
const paths = results.filter(Boolean);
if (paths.length > 0) {
if (paths.length > 0 && options.insert !== false) {
// Insert all paths in one shot, space-separated, in selection order.
await this.sendInput(paths.join(' '));
}
@@ -187,6 +190,7 @@ Object.assign(CodemanApp.prototype, {
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
return paths;
},
async _uploadPasteImage(sessionId, file) {
+261 -14
View File
@@ -4,13 +4,14 @@
* Defines three exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
* keyboard on mobile: arrow up/down, /init, Tab, Compose, Esc, and dismiss (the extended
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
* prompt text to the PTY before sending \t, so completion applies to what was typed.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Agent bars expose a Compose dialog with an autocorrect-aware multiline textarea,
* per-session in-memory drafts and image attach. Shell bars keep the direct Paste
* dialog because shell input is not an agent prompt.
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
@@ -648,8 +649,16 @@ const KeyboardAccessoryBar = {
_baseMode: 'simple',
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
_ctrlArmed: false,
// Prompt drafts intentionally stay in memory: prompts routinely contain secrets,
// so persistence would need the same treatment as the 0600 intent store.
_composerDrafts: new Map(),
_composerUploads: new Map(),
_composerOverlay: null,
// Leave room for both six-character bracketed-paste markers under the
// WebSocket input frame's 64 KiB character limit.
_composerMaxLength: 65524,
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
_simpleButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@@ -665,10 +674,10 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
@@ -740,10 +749,10 @@ const KeyboardAccessoryBar = {
<path d="M9 5l7 7-7 7"/>
</svg>
</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>
</button>
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">&#x1F4C1; Path</button>
@@ -781,6 +790,7 @@ const KeyboardAccessoryBar = {
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
// session's mode instead of a setting.
this.syncCodexKeys();
this._syncComposerDraftIndicator();
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
@@ -823,8 +833,12 @@ const KeyboardAccessoryBar = {
* the next one. */
refreshForActiveSession() {
this.clearCtrl();
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== app.activeSessionId) {
this._composerOverlay._closeComposer?.({ restoreFocus: false });
}
this._applyLayout(this._resolveMode());
this.syncCodexKeys();
this._syncComposerDraftIndicator();
},
/** Which layout the current state calls for. */
@@ -852,6 +866,20 @@ const KeyboardAccessoryBar = {
this.clearCtrl();
this.element.innerHTML =
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
this._syncComposerDraftIndicator();
},
/** Show when the active session has a prompt parked in memory. The marker
* keeps non-Send closes visible without copying the draft back into the PTY
* and creating a second source of truth. */
_syncComposerDraftIndicator() {
const button = this.element?.querySelector('[data-action="compose"]');
if (!button) return;
const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId));
button.classList.toggle('has-draft', hasDraft);
button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt';
button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt');
},
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
@@ -977,6 +1005,9 @@ const KeyboardAccessoryBar = {
case 'paste':
this.pasteFromClipboard();
break;
case 'compose':
this.composePrompt();
break;
case 'pick-path':
this.pickPath();
break;
@@ -1130,6 +1161,222 @@ const KeyboardAccessoryBar = {
});
},
/** Move the whole editable terminal prompt into the composer. Pending text
* exists only in the overlay; flushed text already reached the PTY, so erase
* that prefix before making the textarea authoritative. */
_takePendingLocalEcho(sessionId) {
if (!app._localEchoEnabled || !app._localEchoOverlay) return '';
const pending = app._localEchoOverlay.pendingText || '';
const overlayFlushed = app._localEchoOverlay.getFlushed?.() || {};
const flushedText = overlayFlushed.text || app._flushedTexts?.get(sessionId) || '';
const flushedLength = Array.from(flushedText).length;
app._localEchoOverlay.clear();
app._localEchoOverlay.suppressBufferDetection?.();
app._flushedOffsets?.delete(sessionId);
app._flushedTexts?.delete(sessionId);
if (flushedLength > 0) {
app._sendInputAsync(sessionId, '\x7f'.repeat(flushedLength), { useMux: true });
}
return flushedText + pending;
},
/** Insert text at the textarea selection, adding one separating space when
* an attachment path would otherwise run into neighboring prompt text. */
_insertComposerText(textarea, text) {
if (!textarea || !text) return;
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
const before = textarea.value.slice(0, start);
const after = textarea.value.slice(end);
const prefix = before && !/\s$/.test(before) ? ' ' : '';
const suffix = after && !/^\s/.test(after) ? ' ' : '';
const inserted = `${prefix}${text}${suffix}`;
textarea.setRangeText(inserted, start, end, 'end');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
},
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
* its DECSET 2004 mirror after terminal replay, even though the CLI still
* expects bracketed input, so build the byte-identical sequence directly on
* the durable session-bound path. Enter stays a separate delayed write
* because Codex drops keys sharing a PTY read with a bracketed paste. */
_sendComposedPrompt(sessionId, text) {
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
app._predictiveEcho?.clearPredictions();
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
// the single-line input transport and is what terminal.paste() emitted.
const pasteText = text.replace(/\r?\n/g, '\r');
const payload = `\x1b[200~${pasteText}\x1b[201~`;
if (payload.length > 65536) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
}
app._sendInputAsync(sessionId, payload);
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
/** Forget a draft when its target session no longer exists. */
discardComposerDraft(sessionId) {
this._composerDrafts.delete(sessionId);
this._composerUploads.delete(sessionId);
this._syncComposerDraftIndicator();
if (this._composerOverlay?.dataset.sessionId === sessionId) {
this._composerOverlay._closeComposer?.({ preserveDraft: false });
}
},
/** Open the manual agent prompt composer. Enter remains a newline; only the
* Send button submits. Cancel/backdrop/Escape preserve the per-session draft. */
composePrompt() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
if (this._isShellSession()) {
this.pasteFromClipboard();
return;
}
const sessionId = app.activeSessionId;
const pending = this._takePendingLocalEcho(sessionId);
const saved = this._composerDrafts.get(sessionId) || '';
const initial = saved + pending;
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
const overlay = document.createElement('div');
overlay.className = 'paste-overlay prompt-composer-overlay';
overlay.dataset.sessionId = sessionId;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Compose prompt');
overlay.innerHTML = `
<div class="paste-dialog prompt-composer-dialog">
<div class="prompt-composer-header">
<strong>Compose prompt</strong>
<span>Enter adds a new line</span>
</div>
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
</div>
`;
this._composerOverlay = overlay;
const textarea = overlay.querySelector('.prompt-composer-textarea');
const fileInput = overlay.querySelector('.paste-file-input');
const imageButton = overlay.querySelector('.paste-image');
const sendButton = overlay.querySelector('.paste-send');
const focusTrap = new FocusTrap(overlay);
textarea.value = initial;
if (initial) this._composerDrafts.set(sessionId, initial);
this._syncComposerDraftIndicator();
const initialUploads = this._composerUploads.get(sessionId) || 0;
imageButton.disabled = initialUploads > 0;
sendButton.disabled = initialUploads > 0;
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
const saveDraft = () => {
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
else this._composerDrafts.delete(sessionId);
this._syncComposerDraftIndicator();
};
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
if (preserveDraft) saveDraft();
focusTrap.deactivate({ restoreFocus });
overlay.remove();
if (this._composerOverlay === overlay) this._composerOverlay = null;
if (focusTerminal) app.terminal?.focus();
};
overlay._closeComposer = close;
const send = () => {
const text = textarea.value;
if (!text || !this._sendComposedPrompt(sessionId, text)) return;
app._echoPassthroughSessions?.delete(sessionId);
this._composerDrafts.delete(sessionId);
this._syncComposerDraftIndicator();
close({ preserveDraft: false });
};
const handleImages = async (files) => {
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
saveDraft();
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
const syncUploadUi = () => {
const currentOverlay =
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
? this._composerOverlay
: null;
const count = this._composerUploads.get(sessionId) || 0;
const currentImageButton = currentOverlay?.querySelector('.paste-image');
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
};
syncUploadUi();
try {
const paths = await app._uploadAndInsertImages(images, { insert: false });
if (paths?.length) {
const currentOverlay =
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
? this._composerOverlay
: null;
const currentTextarea = currentOverlay?.querySelector('.prompt-composer-textarea');
if (currentTextarea) this._insertComposerText(currentTextarea, paths.join(' '));
else if (app.sessions?.has(sessionId)) {
const draft = this._composerDrafts.get(sessionId) || '';
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
this._syncComposerDraftIndicator();
}
}
} finally {
const remaining = Math.max(0, (this._composerUploads.get(sessionId) || 1) - 1);
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
else this._composerUploads.delete(sessionId);
syncUploadUi();
if (overlay.isConnected) fileInput.value = '';
}
};
textarea.addEventListener('input', saveDraft);
textarea.addEventListener('paste', (event) => {
const items = event.clipboardData?.items;
if (!items) return;
const images = Array.from(items)
.filter((item) => item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (images.length > 0) {
event.preventDefault();
void handleImages(images);
}
});
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
}
});
imageButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
sendButton.addEventListener('click', send);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
document.body.appendChild(overlay);
focusTrap.activate();
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
},
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
@@ -1259,9 +1506,9 @@ class FocusTrap {
});
}
deactivate() {
deactivate({ restoreFocus = true } = {}) {
this.element.removeEventListener('keydown', this.boundHandleKeydown);
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
if (restoreFocus && this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
this.previouslyFocused.focus();
}
}
+63
View File
@@ -13653,6 +13653,14 @@ body.touch-device.cjk-input-visible .main {
background: var(--control-bg-hover);
}
.accessory-btn-compose.has-draft::after {
width: 6px;
height: 6px;
background: var(--yellow);
border-radius: 50%;
content: '';
}
.accessory-btn svg {
width: 14px;
height: 14px;
@@ -13773,6 +13781,61 @@ body.touch-device.cjk-input-visible .main {
font-weight: 600;
}
.prompt-composer-overlay {
overflow-y: auto;
padding: min(15dvh, 72px) 0 calc(12px + env(safe-area-inset-bottom));
}
.prompt-composer-dialog {
max-width: 560px;
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom));
overflow-y: auto;
}
.prompt-composer-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin: 2px 2px 10px;
color: var(--text);
}
.prompt-composer-header span {
color: var(--text-dim);
font-size: 12px;
}
.prompt-composer-textarea {
min-height: min(34dvh, 240px);
max-height: 50dvh;
resize: vertical;
}
.prompt-composer-actions {
flex-wrap: wrap;
}
.prompt-composer-actions button {
min-height: var(--touch-target-min);
}
.prompt-composer-terminal {
flex: 1 0 100%;
padding: 8px 12px;
color: var(--text-dim);
font-size: 14px;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.prompt-composer-terminal:active {
color: var(--text);
background: var(--bg-input);
}
/* Shared lazy filesystem path picker (case linking + mobile input). */
.path-input-group {
display: flex;
+3 -2
View File
@@ -4075,8 +4075,9 @@ Object.assign(CodemanApp.prototype, {
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (flushed.count > 0) {
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
const flushedLength = Array.from(flushed.text || '').length;
if (flushedLength > 0) {
this.sendInput('\x7f'.repeat(flushedLength)).catch(() => {});
}
} else {
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
+39 -3
View File
@@ -17,7 +17,9 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
interface TrapListener {
(e: Record<string, unknown>): void;
@@ -91,8 +93,7 @@ function loadPasteHarness(): Harness {
});
vm.runInContext('class CodemanApp {}', context);
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
vm.runInContext(src, context, { filename: 'image-input.js' });
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
@@ -133,6 +134,20 @@ function loadPasteHarness(): Harness {
};
}
function loadImageInputApp() {
const context = vm.createContext({ console, window: {}, document: {} });
vm.runInContext('class CodemanApp {}', context);
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const app = new CodemanApp();
app.activeSessionId = 'session-1';
app.showToast = vi.fn();
app.sendInput = vi.fn(async () => {});
app._normalizeImageForUpload = vi.fn(async (file) => file);
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
return app as Record<string, any>;
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text to the terminal once for a single paste event', () => {
const h = loadPasteHarness();
@@ -174,3 +189,24 @@ describe('Ctrl+V paste trap', () => {
expect(h.attachedTraps()).toBe(0);
});
});
describe('image upload insertion policy', () => {
it('returns ordered paths without terminal insertion when requested by the composer', async () => {
const app = loadImageInputApp();
const files = [{ path: '/tmp/first.png' }, { path: '/tmp/second.png' }];
const paths = await app._uploadAndInsertImages(files, { insert: false });
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
expect(app.sendInput).not.toHaveBeenCalled();
});
it('preserves terminal insertion by default', async () => {
const app = loadImageInputApp();
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
});
});
+408
View File
@@ -0,0 +1,408 @@
/**
* @fileoverview CI-visible coverage for the manual mobile prompt composer.
*
* The Playwright mobile suite is excluded from the CI gate, so the behaviors
* most likely to regress live here against the real browser module: native
* textarea replacement, local-echo adoption, per-session drafts, bracketed
* multiline delivery and image-path insertion.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
type Timer = { callback: () => void; delay: number };
function loadComposer(sessionId = 'session-1') {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const window = dom.window;
const timers: Timer[] = [];
const localEcho = {
pendingText: '',
clear: vi.fn(() => {
localEcho.pendingText = '';
}),
suppressBufferDetection: vi.fn(),
};
const app = {
activeSessionId: sessionId,
sessions: new Map([
['session-1', { mode: 'claude' }],
['session-2', { mode: 'claude' }],
]),
terminal: { paste: vi.fn(), focus: vi.fn(), modes: { bracketedPasteMode: true } },
_localEchoEnabled: true,
_localEchoOverlay: localEcho,
_flushedOffsets: new Map<string, number>(),
_flushedTexts: new Map<string, string>(),
_echoPassthroughSessions: new Set<string>(),
_predictiveEcho: { clearPredictions: vi.fn() },
_sendInputAsync: vi.fn(),
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
showToast: vi.fn(),
};
const schedule = (callback: () => void, delay = 0) => {
timers.push({ callback, delay });
return timers.length;
};
const factory = new Function(
'window',
'document',
'Event',
'app',
'MobileDetection',
'URLSearchParams',
'fetch',
'setTimeout',
'clearTimeout',
'requestAnimationFrame',
`${accessorySource}\nreturn KeyboardAccessoryBar;`
);
const bar = factory(
window,
window.document,
window.Event,
app,
{ isTouchDevice: () => true },
window.URLSearchParams,
vi.fn(),
schedule,
vi.fn(),
(callback: FrameRequestCallback) => {
callback(0);
return 1;
}
);
return {
app,
bar,
document: window.document,
localEcho,
timers,
runTimers() {
for (const timer of timers.splice(0)) timer.callback();
},
};
}
function textarea(document: Document): HTMLTextAreaElement {
return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement;
}
function mountComposeButton(bar: any, document: Document): HTMLButtonElement {
bar.element = document.createElement('div');
bar.element.innerHTML = bar._simpleButtons;
document.body.appendChild(bar.element);
bar._syncComposerDraftIndicator();
return bar.element.querySelector('[data-action="compose"]') as HTMLButtonElement;
}
describe('mobile prompt composer', () => {
beforeEach(() => vi.restoreAllMocks());
it('replaces Paste with Compose on agent bars while shell keeps direct Paste', () => {
const template = (name: string) => accessorySource.match(new RegExp(name + '\\s*:\\s*`([\\s\\S]*?)`'))?.[1] ?? '';
expect(template('_simpleButtons')).toContain('data-action="compose"');
expect(template('_simpleButtons')).not.toContain('data-action="paste"');
expect(template('_extendedButtons')).toContain('data-action="compose"');
expect(template('_extendedButtons')).not.toContain('data-action="paste"');
expect(template('_shellButtons')).toContain('data-action="paste"');
expect(template('_shellButtons')).not.toContain('data-action="compose"');
});
it('uses a compact accessible icon for Compose in both agent layouts', () => {
const { bar, document } = loadComposer();
for (const markup of [bar._simpleButtons, bar._extendedButtons]) {
const wrapper = document.createElement('div');
wrapper.innerHTML = markup;
const button = wrapper.querySelector('[data-action="compose"]') as HTMLButtonElement;
expect(button.getAttribute('aria-label')).toBe('Compose prompt');
expect(button.getAttribute('title')).toBe('Compose prompt');
expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
expect(button.textContent?.trim()).toBe('');
}
});
it('uses a native autocorrect-aware textarea and stores replacement text exactly once', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const input = textarea(document);
expect(input.getAttribute('autocorrect')).toBe('on');
expect(input.getAttribute('autocapitalize')).toBe('sentences');
expect(input.getAttribute('spellcheck')).toBe('true');
input.value = 'Please fix teh bug';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
input.value = 'Please fix the bug';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
bar.composePrompt();
expect(textarea(document).value).toBe('Please fix the bug');
expect(app.terminal.paste).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('adopts and clears locally-buffered terminal input on open', () => {
const { app, bar, document, localEcho } = loadComposer();
localEcho.pendingText = '-written prompt';
app._flushedOffsets.set('session-1', 4);
app._flushedTexts.set('session-1', 'half');
bar.composePrompt();
expect(textarea(document).value).toBe('half-written prompt');
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(4), { useMux: true });
expect(localEcho.clear).toHaveBeenCalledOnce();
expect(localEcho.suppressBufferDetection).toHaveBeenCalledOnce();
expect(app._flushedOffsets.has('session-1')).toBe(false);
expect(app._flushedTexts.has('session-1')).toBe(false);
});
it('uses Unicode code points when erasing flushed text', () => {
const { app, bar, document } = loadComposer();
app._flushedOffsets.set('session-1', 3);
app._flushedTexts.set('session-1', 'a😀');
bar.composePrompt();
expect(textarea(document).value).toBe('a😀');
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(2), { useMux: true });
});
it('closes on tab switch and keeps drafts isolated by session', () => {
const { app, bar, document } = loadComposer();
const composeButton = mountComposeButton(bar, document);
bar.composePrompt();
textarea(document).value = 'first session draft';
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
expect(composeButton.classList.contains('has-draft')).toBe(true);
app.activeSessionId = 'session-2';
bar.refreshForActiveSession();
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
expect(composeButton.classList.contains('has-draft')).toBe(false);
expect(() => bar.refreshForActiveSession()).not.toThrow();
bar.composePrompt();
expect(textarea(document).value).toBe('');
textarea(document).value = 'second session draft';
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
app.activeSessionId = 'session-1';
bar.refreshForActiveSession();
expect(composeButton.classList.contains('has-draft')).toBe(true);
expect(composeButton.getAttribute('aria-label')).toBe('Compose prompt, draft saved');
bar.composePrompt();
expect(textarea(document).value).toBe('first session draft');
});
it('drops a draft and closes its composer when the session is deleted', () => {
const { bar, document } = loadComposer();
const composeButton = mountComposeButton(bar, document);
bar.composePrompt();
textarea(document).value = 'temporary secret';
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
expect(composeButton.classList.contains('has-draft')).toBe(true);
bar.discardComposerDraft('session-1');
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
expect(composeButton.classList.contains('has-draft')).toBe(false);
bar.composePrompt();
expect(textarea(document).value).toBe('');
});
it('wires session cleanup to composer draft cleanup', () => {
const cleanupStart = appSource.indexOf(' _cleanupSessionData(sessionId) {');
const cleanup = appSource.slice(cleanupStart, cleanupStart + 1200);
expect(cleanup).toContain('KeyboardAccessoryBar.discardComposerDraft?.(sessionId)');
});
it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => {
const { app, bar, document, timers, runTimers } = loadComposer();
const composeButton = mountComposeButton(bar, document);
bar.composePrompt();
const input = textarea(document);
input.value = 'first line\nsecond line';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
input.dispatchEvent(new document.defaultView!.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(app.terminal.paste).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
(document.querySelector('.paste-send') as HTMLButtonElement).click();
expect(app.terminal.paste).not.toHaveBeenCalled();
expect(app._sendInputAsync).toHaveBeenCalledOnce();
expect(app._sendInputAsync).toHaveBeenNthCalledWith(1, 'session-1', '\x1b[200~first line\rsecond line\x1b[201~');
expect(composeButton.classList.contains('has-draft')).toBe(false);
expect(timers).toContainEqual(expect.objectContaining({ delay: 120 }));
runTimers();
expect(app._sendInputAsync).toHaveBeenNthCalledWith(2, 'session-1', '\r', { useMux: true });
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
bar.composePrompt();
expect(textarea(document).value).toBe('');
});
it('sends after replay resets xterm’s mirrored bracketed-paste mode', () => {
const { app, bar, document, runTimers } = loadComposer();
app.terminal.modes.bracketedPasteMode = false;
bar.composePrompt();
textarea(document).value = 'still\nmultiline';
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.paste-send') as HTMLButtonElement).click();
expect(app.terminal.paste).not.toHaveBeenCalled();
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~still\rmultiline\x1b[201~');
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
expect(app.showToast).not.toHaveBeenCalled();
runTimers();
expect(app._sendInputAsync).toHaveBeenLastCalledWith('session-1', '\r', { useMux: true });
});
it('releases echo passthrough after a composed prompt is queued', () => {
const { app, bar, document } = loadComposer();
app._echoPassthroughSessions.add('session-1');
app._echoPassthroughSessions.add('session-2');
bar.composePrompt();
textarea(document).value = 'send from composer';
(document.querySelector('.paste-send') as HTMLButtonElement).click();
expect(app._echoPassthroughSessions.has('session-1')).toBe(false);
expect(app._echoPassthroughSessions.has('session-2')).toBe(true);
});
it('keeps an oversized prompt as a draft instead of queueing a rejected frame', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const input = textarea(document);
input.value = 'x'.repeat(65525);
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.paste-send') as HTMLButtonElement).click();
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('too long'), 'error');
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
expect(bar._composerDrafts.get('session-1')).toHaveLength(65525);
});
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
const { app, bar, document, localEcho, runTimers } = loadComposer();
const composeButton = mountComposeButton(bar, document);
bar.composePrompt();
textarea(document).value = 'keep this';
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(composeButton.classList.contains('has-draft')).toBe(true);
expect(composeButton.title).toBe('Resume saved prompt draft');
runTimers();
localEcho.pendingText = '; then continue';
bar.composePrompt();
expect(textarea(document).value).toBe('keep this; then continue');
});
it('uploads images without writing into the PTY and inserts their paths into the draft', async () => {
const { app, bar, document } = loadComposer();
let finishUpload!: (paths: string[]) => void;
app._uploadAndInsertImages.mockImplementation(
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
);
bar.composePrompt();
const input = textarea(document);
input.value = 'review';
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect((document.querySelector('.paste-image') as HTMLButtonElement).disabled).toBe(true);
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
finishUpload(['/tmp/image-one.png']);
await vi.waitFor(() => expect(input.value).toBe('review /tmp/image-one.png'));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { insert: false });
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
expect(app.terminal.paste).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('finishes an upload into a reopened composer without restoring a deleted session draft', async () => {
const { app, bar, document } = loadComposer();
let finishUpload!: (paths: string[]) => void;
app._uploadAndInsertImages.mockImplementation(
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
);
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
bar.composePrompt();
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
finishUpload(['/tmp/late-image.png']);
await vi.waitFor(() => expect(textarea(document).value).toBe('/tmp/late-image.png'));
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
let finishDeletedUpload!: (paths: string[]) => void;
app._uploadAndInsertImages.mockImplementation(
() => new Promise<string[]>((resolveUpload) => (finishDeletedUpload = resolveUpload))
);
const reopenedInput = document.querySelector('.paste-file-input') as HTMLInputElement;
Object.defineProperty(reopenedInput, 'files', { configurable: true, value: [image] });
reopenedInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
app.sessions.delete('session-1');
bar.discardComposerDraft('session-1');
finishDeletedUpload(['/tmp/deleted-session.png']);
await Promise.resolve();
await Promise.resolve();
expect(bar._composerDrafts.has('session-1')).toBe(false);
});
it('keeps Send disabled until every concurrent image upload finishes', async () => {
const { app, bar, document } = loadComposer();
const finishUploads: Array<(paths: string[]) => void> = [];
app._uploadAndInsertImages.mockImplementation(
() => new Promise<string[]>((resolveUpload) => finishUploads.push(resolveUpload))
);
bar.composePrompt();
const input = textarea(document);
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
const pasteImage = () => {
const item = { type: 'image/png', getAsFile: () => image };
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
input.dispatchEvent(event);
};
pasteImage();
pasteImage();
expect(finishUploads).toHaveLength(2);
finishUploads[0](['/tmp/first.png']);
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png'));
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
finishUploads[1](['/tmp/second.png']);
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png /tmp/second.png'));
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
});
});
+1 -1
View File
@@ -478,7 +478,7 @@ describe('Virtual Keyboard', () => {
'tab',
'shift-left',
'shift-right',
'paste',
'compose',
'readmymind',
'esc',
'dismiss',
+3 -3
View File
@@ -200,12 +200,12 @@ describe('mobile filesystem picker actions', () => {
_pendingInput: 'pending text',
_localEchoEnabled: true,
_localEchoOverlay: {
getFlushed: () => ({ count: 4, text: 'sent' }),
getFlushed: () => ({ count: 4, text: 'a😀b' }),
clear,
suppressBufferDetection,
},
_flushedOffsets: new Map([['session-1', 4]]),
_flushedTexts: new Map([['session-1', 'sent']]),
_flushedTexts: new Map([['session-1', 'a😀b']]),
sendInput,
showToast,
terminal: { focus },
@@ -216,7 +216,7 @@ describe('mobile filesystem picker actions', () => {
expect(app._pendingInput).toBe('');
expect(clear).toHaveBeenCalledOnce();
expect(suppressBufferDetection).toHaveBeenCalledOnce();
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(3));
expect(sendInput).not.toHaveBeenCalledWith('/clear');
expect(app._flushedOffsets.size).toBe(0);
expect(app._flushedTexts.size).toBe(0);