Files
Codeman/src/web/public/keyboard-accessory.js
T
Ark0NandClaude Opus 4.6 2ee9ad72e8 refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods

Replace ~80 inline addListener closures in connectSSE() with a
declarative _SSE_HANDLER_MAP array that drives registration in a
single loop. Each handler is now a named _on* method on CodemanApp,
making them individually addressable for LLM navigation.

Add SSE_EVENTS constant object in constants.js to eliminate magic
event-type strings scattered across the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix inaccuracies in CLAUDE.md

- Fix types barrel path: src/types.ts → src/types/index.ts
- Update app.js line count: ~12K → ~11.5K
- Correct route handler counts (113 → 111, per-group fixes)
- Add code style, ESM gotcha, env vars, route test, lifecycle log docs
- Add Node 22 CI note, test teardown timeout, port range

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add mobile screenshots and QR auth security writeup to README

Add 3 mobile screenshots (landing, idle, active) and expand the
mobile section with QR auth security design details and a
touch-optimized interface subsection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook

Build and postinstall now bundle the local xterm-zerolag-input package
as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay
shim. Add git pre-commit hook that runs prettier --check on staged .ts
files to catch format issues before CI.

Also bump constants.js and app.js cache-bust versions to 0.3.0 and add
tunnel upload URL display row in settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add cloudflared install support and interactive launch menu

- Add optional cloudflared dependency detection and installation
  across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE)
- Add tunnel systemd service setup helper
- Replace post-install instructions with interactive launch menu
  (run now / systemd service / skip)
- Uninstall now cleans up both codeman-web and codeman-tunnel services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: gitignore readme-preview.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression

- Migrate broadcast() string literals → SseEvent.* typed constants
- Add @fileoverview with cross-domain references to all 13 type domain files
- Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.)
- Add section dividers to route files for LLM scanability
- Compress CLAUDE.md: flat file list → domain table, fix counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: optimize codebase for LLM context window efficiency

CLAUDE.md: 456 → 309 lines (32% reduction)
- Merge Commands into compact table, remove redundant bash block
- Convert Security section to dense table format
- Merge Performance + Resource Limits, Debugging + Troubleshooting
- Compress Tunnel, Memory Leak, Scripts, Screenshots sections
- Remove Key Patterns that duplicate @fileoverview in source files

Backend @fileoverview enhancements (10 priority files):
- session.ts: key methods, events, cross-domain refs
- respawn-controller.ts: state machine, idle detection layers
- ralph-tracker.ts: exports, circuit breaker, events
- ralph-loop.ts: lifecycle, persistence, events
- subagent-watcher.ts: watched patterns, teammate detection
- server.ts: coordination list, port interfaces
- state-store.ts: dual-file persistence, migration
- session-manager.ts: lifecycle methods, mutex guard
- hooks-config.ts: hook events list, categories
- sse-events.ts: category breakdown (~90 events, 17 categories)

Frontend app.js: add 6 section dividers, update @fileoverview line refs

Fix: escape glob `*/` in JSDoc that broke ESLint parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR #29 review bugs

- server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant
- install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first)
- install.sh: replace Arch pacman (AUR-only) with direct binary download
- index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0
- mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js)
- types/push.ts: fix layer number (4, not 5)
- subagent-watcher.ts: fix watched pattern path to include {session} segment
- constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:40:18 +01:00

303 lines
11 KiB
JavaScript

/**
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
*
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
* Destructive actions (/clear, /compact) 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).
*
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
* and other modal dialogs.
*
* @globals {object} KeyboardAccessoryBar
* @globals {class} FocusTrap
*
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
* @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal)
* @loadorder 5 of 9 — loaded after notification-manager.js, before app.js
*/
// Codeman — Keyboard accessory bar and focus trap for modals
// Loaded after mobile-handlers.js, before app.js
// ═══════════════════════════════════════════════════════════════
// Mobile Keyboard Accessory Bar
// ═══════════════════════════════════════════════════════════════
/**
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
*/
const KeyboardAccessoryBar = {
element: null,
/** Create and inject the accessory bar */
init() {
// Only on mobile
if (!MobileDetection.isTouchDevice()) return;
// Create accessory bar element
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = `
<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">
<path d="M5 15l7-7 7 7"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</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"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
`;
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
const btn = e.target.closest('.accessory-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const action = btn.dataset.action;
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
if ((action === 'scroll-up' || action === 'scroll-down') ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
}
});
// Insert before toolbar
const toolbar = document.querySelector('.toolbar');
if (toolbar && toolbar.parentNode) {
toolbar.parentNode.insertBefore(this.element, toolbar);
}
},
_confirmTimer: null,
_confirmAction: null,
/** Handle accessory button actions */
handleAction(action, btn) {
if (typeof app === 'undefined' || !app.activeSessionId) return;
switch (action) {
case 'scroll-up':
this.sendKey('\x1b[A');
break;
case 'scroll-down':
this.sendKey('\x1b[B');
break;
case 'init':
this.sendCommand('/init');
break;
case 'clear':
case 'compact': {
// Require double-tap: first tap turns amber, second tap within 2s sends
const cmd = action === 'clear' ? '/clear' : '/compact';
if (this._confirmAction === action && this._confirmTimer) {
this.clearConfirm();
this.sendCommand(cmd);
} else {
this.setConfirm(action, btn);
}
break;
}
case 'paste':
this.pasteFromClipboard();
break;
case 'dismiss':
// Blur active element to dismiss keyboard
document.activeElement?.blur();
break;
}
},
/** Enter confirm state: button turns amber for 2s waiting for second tap */
setConfirm(action, btn) {
this.clearConfirm();
this._confirmAction = action;
if (btn) {
btn.classList.add('confirming');
btn.dataset.origHtml = btn.innerHTML;
btn.textContent = 'Tap again';
}
this._confirmTimer = setTimeout(() => this.clearConfirm(), 2000);
},
/** Reset confirm state */
clearConfirm() {
if (this._confirmTimer) {
clearTimeout(this._confirmTimer);
this._confirmTimer = null;
}
if (this._confirmAction && this.element) {
const btn = this.element.querySelector(`[data-action="${this._confirmAction}"]`);
if (btn && btn.dataset.origHtml) {
btn.innerHTML = btn.dataset.origHtml;
delete btn.dataset.origHtml;
}
if (btn) btn.classList.remove('confirming');
}
this._confirmAction = null;
},
/** Send a slash command to the active session.
* Sends text and Enter separately so Ink processes them as distinct events. */
sendCommand(command) {
if (!app.activeSessionId) return;
// Send command text first (without Enter)
app.sendInput(command);
// Send Enter separately after a brief delay so Ink has time to process the text.
setTimeout(() => app.sendInput('\r'), 120);
},
/** Send a special key (arrow, escape, etc.) directly to the PTY.
* Bypasses tmux send-keys -l (literal mode) since escape sequences
* must be written raw to be interpreted as key presses by Ink. */
sendKey(escapeSequence) {
if (!app.activeSessionId) return;
fetch(`/api/sessions/${app.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: escapeSequence })
}).catch(() => {});
},
/** Read clipboard and send contents as input */
/** Show a paste overlay with a textarea for iOS compatibility */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
<div class="paste-actions">
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
</div>
`;
const textarea = overlay.querySelector('.paste-textarea');
const send = () => {
const text = textarea.value;
overlay.remove();
if (text) app.sendInput(text);
};
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.paste-send').addEventListener('click', send);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
textarea.focus();
},
/** Show the accessory bar */
show() {
if (this.element) {
this.element.classList.add('visible');
}
},
/** Hide the accessory bar */
hide() {
if (this.element) {
this.element.classList.remove('visible');
}
}
};
// ═══════════════════════════════════════════════════════════════
// Accessibility: Focus Trap for Modals
// ═══════════════════════════════════════════════════════════════
/**
* FocusTrap - Traps keyboard focus within an element (typically a modal).
* Saves the previously focused element and restores focus when deactivated.
*/
class FocusTrap {
constructor(element) {
this.element = element;
this.previouslyFocused = null;
this.boundHandleKeydown = this.handleKeydown.bind(this);
}
activate() {
this.previouslyFocused = document.activeElement;
this.element.addEventListener('keydown', this.boundHandleKeydown);
// Focus first focusable element after a brief delay (for CSS transitions)
requestAnimationFrame(() => {
const focusable = this.getFocusableElements();
if (focusable.length) {
focusable[0].focus();
}
});
}
deactivate() {
this.element.removeEventListener('keydown', this.boundHandleKeydown);
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
this.previouslyFocused.focus();
}
}
getFocusableElements() {
const selector = [
'button:not([disabled]):not([tabindex="-1"])',
'input:not([disabled]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'a[href]:not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"]):not([disabled])'
].join(', ');
return [...this.element.querySelectorAll(selector)].filter(
el => el.offsetParent !== null // Exclude hidden elements
);
}
handleKeydown(e) {
if (e.key !== 'Tab') return;
const focusable = this.getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}