Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.7 ab7c502c2a chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:13:26 +02:00
Ark0N 546bbcbe7c Merge pull request #68 from aakhter/feat/clipboard-api
feat: add clipboard API for remote browser clipboard access
2026-04-18 04:10:31 +02:00
Ark0N 774d5ff321 Merge pull request #67 from aakhter/feat/tab-badges
feat: improve active tab visibility and add Alt+N number badges
2026-04-18 04:08:09 +02:00
Ark0N 98ceb5da1d Merge pull request #66 from aakhter/feat/tab-reorder-shortcuts
feat: add Ctrl+Shift+{/} to reorder session tabs
2026-04-18 04:07:01 +02:00
Ark0N 34fb5e49f8 Merge pull request #65 from aakhter/fix/android-shift-double-char
fix: prevent double character input on Android Shift+key
2026-04-18 04:05:21 +02:00
Aamer Akhter 9b4aab2502 feat: add clipboard API for remote browser clipboard access
POST /api/clipboard with { text } broadcasts to all connected browsers
via SSE. Browser attempts navigator.clipboard.writeText, falling back
to a modal with manual copy button if blocked.

Enables remote clipboard workflows: CLI tools can push text to the
user's browser clipboard across the network.
2026-04-12 10:07:15 -04:00
Aamer Akhter 829c797726 feat: improve active tab visibility and add Alt+N number badges
- Active tab: bright green border with color-matched glow per session color
- Tab number badges (1-9) showing Alt+N shortcut hints
- Badges update on tab reorder and re-render
2026-04-12 10:04:34 -04:00
Aamer Akhter 85da3bb898 feat: add Ctrl+Shift+{/} keyboard shortcuts to reorder session tabs
Matches WezTerm/terminal emulator tab reorder conventions. Uses the
existing sessionOrder array and saveSessionOrder() for persistence.
2026-04-12 10:02:12 -04:00
Aamer Akhter 29b2653801 fix: prevent double character input on Android Shift+key
On Android tablets, pressing Shift+A produces "AA" because the input
event listener re-sends characters that xterm already processed via
its keydown handler. Track keydown timestamps and skip input events
that fire within 50ms of a handled keydown.

Only affects touch devices (listener gated by isTouchDevice()).
2026-04-12 10:01:29 -04:00
12 changed files with 212 additions and 15 deletions
+10
View File
@@ -1,5 +1,15 @@
# aicodeman
## 0.6.0
### Minor Changes
- Community contributions from @aakhter:
- **feat (#66): Tab reorder shortcuts** — `Ctrl+Shift+{` and `Ctrl+Shift+}` move the active session tab left/right, matching WezTerm convention. Order persists across reloads via `saveSessionOrder()`.
- **feat (#67): Active tab visibility + Alt+N badges** — active tab now has a bright green border with color-matched glow, and the first 9 tabs display number badges hinting at the `Alt+N` switch shortcut. Badges update on reorder/rerender.
- **feat (#68): Clipboard API** — new `POST /api/clipboard` accepting `{text}` broadcasts a `clipboard:write` SSE event; connected browsers attempt `navigator.clipboard.writeText()` with a manual-copy modal fallback when the page isn't focused. Auth-protected via the standard middleware. Useful for pushing snippets from remote sessions to the user's local clipboard.
- **fix (#65): Android Shift+key double character** — pressing `Shift+A` on attached Android keyboards no longer produces "AA". Tracks xterm-handled keydown timestamps and skips the orphaned-input listener for 50ms after a real keydown, while still catching Gboard symbol-keyboard inputs (keyCode 229).
## 0.5.13
### Patch Changes
+1 -1
View File
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.5.13 (must match `package.json`)
**Version**: 0.6.0 (must match `package.json`)
## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.5.13",
"version": "0.6.0",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+37
View File
@@ -249,6 +249,9 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
// Clipboard
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
];
@@ -639,6 +642,8 @@ class CodemanApp {
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
];
// Use capture to handle before terminal
@@ -1757,6 +1762,17 @@ class CodemanApp {
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
// Inject tab-number badge if missing (added after initial render)
if (!tab.querySelector('.tab-number')) {
const idx = this.sessionOrder.indexOf(id);
if (idx >= 0 && idx < 9) {
const numSpan = document.createElement('span');
numSpan.className = 'tab-number';
numSpan.textContent = String(idx + 1);
tab.insertBefore(numSpan, tab.firstChild);
}
}
// Update status indicator
const statusEl = tab.querySelector('.tab-status');
if (statusEl && !statusEl.classList.contains(status)) {
@@ -1849,6 +1865,7 @@ class CodemanApp {
// Reorder to put active tab first
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
}
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
@@ -1875,6 +1892,7 @@ class CodemanApp {
const endedAttr = session._ended ? ' data-ended="1"' : '';
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
@@ -1888,6 +1906,7 @@ class CodemanApp {
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
_tabIdx++;
}
container.innerHTML = parts.join('');
@@ -2106,6 +2125,24 @@ class CodemanApp {
});
}
moveActiveTabLeft() {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx <= 0) return;
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
}
moveActiveTabRight() {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
}
// ═══════════════════════════════════════════════════════════════
// Session Lifecycle — select, close, navigate
// ═══════════════════════════════════════════════════════════════
+3
View File
@@ -292,6 +292,9 @@ const SSE_EVENTS = {
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
// Clipboard
CLIPBOARD_WRITE: 'clipboard:write',
// Cases
CASE_CREATED: 'case:created',
CASE_LINKED: 'case:linked',
+64
View File
@@ -3246,4 +3246,68 @@ Object.assign(CodemanApp.prototype, {
}
}
},
// ─── Clipboard ──────────────────────────────────────────────────────────────
async _onClipboardWrite(data) {
const text = data?.text;
if (typeof text !== 'string') return;
try {
await navigator.clipboard.writeText(text);
this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
} catch {
this._showClipboardFallback(text);
}
},
_showClipboardFallback(text) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center';
const modal = document.createElement('div');
modal.style.cssText = 'background:#1e1e2e;border:1px solid #444;border-radius:8px;padding:16px;max-width:600px;width:90%;max-height:60vh;display:flex;flex-direction:column;gap:12px';
const header = document.createElement('div');
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
const title = document.createElement('span');
title.style.cssText = 'color:#cdd6f4;font-weight:600';
title.textContent = 'Clipboard (browser blocked auto-copy)';
const closeBtn = document.createElement('button');
closeBtn.style.cssText = 'background:none;border:none;color:#cdd6f4;font-size:18px;cursor:pointer';
closeBtn.textContent = '\u00d7';
header.appendChild(title);
header.appendChild(closeBtn);
const textarea = document.createElement('textarea');
textarea.readOnly = true;
textarea.style.cssText = 'background:#181825;color:#cdd6f4;border:1px solid #555;border-radius:4px;padding:8px;font-family:monospace;font-size:13px;resize:none;height:200px;width:100%';
textarea.value = text;
const copyBtn = document.createElement('button');
copyBtn.style.cssText = 'background:#89b4fa;color:#1e1e2e;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;font-weight:600';
copyBtn.textContent = 'Copy to Clipboard';
modal.appendChild(header);
modal.appendChild(textarea);
modal.appendChild(copyBtn);
overlay.appendChild(modal);
document.body.appendChild(overlay);
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(text);
this.showToast('Copied to clipboard', 'success');
overlay.remove();
} catch {
textarea.select();
document.execCommand('copy');
this.showToast('Copied (fallback)', 'success');
overlay.remove();
}
};
const close = () => overlay.remove();
closeBtn.onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
},
});
+36 -12
View File
@@ -258,15 +258,16 @@ body {
}
.session-tab.active {
background: rgba(255, 255, 255, 0.04);
border-color: var(--glass-border);
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
background: rgba(34, 197, 94, 0.15) !important;
border: 2px solid #00FF66 !important;
color: #fff !important;
box-shadow: none !important;
outline: none !important;
}
/* Tab switch feedback: bright green glow on the newly-active tab */
.session-tab.tab-glow {
animation: tab-glow 0.35s ease-out forwards;
animation: tab-glow 0.35s ease-out;
}
@keyframes tab-glow {
@@ -304,13 +305,36 @@ body {
.session-tab[data-color="pink"] { border-left: 3px solid var(--session-pink); }
/* Active tabs with color get subtle background tint */
.session-tab.active[data-color="red"] { background: rgba(239, 68, 68, 0.1); }
.session-tab.active[data-color="orange"] { background: rgba(249, 115, 22, 0.1); }
.session-tab.active[data-color="yellow"] { background: rgba(234, 179, 8, 0.1); }
.session-tab.active[data-color="green"] { background: rgba(34, 197, 94, 0.1); }
.session-tab.active[data-color="blue"] { background: rgba(59, 130, 246, 0.1); }
.session-tab.active[data-color="purple"] { background: rgba(168, 85, 247, 0.1); }
.session-tab.active[data-color="pink"] { background: rgba(236, 72, 153, 0.1); }
.session-tab.active[data-color="red"] { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.5); box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); }
.session-tab.active[data-color="orange"] { background: rgba(249, 115, 22, 0.15); border-color: rgba(249, 115, 22, 0.5); box-shadow: 0 0 10px rgba(249, 115, 22, 0.3); }
.session-tab.active[data-color="yellow"] { background: rgba(234, 179, 8, 0.15); border-color: rgba(234, 179, 8, 0.5); box-shadow: 0 0 10px rgba(234, 179, 8, 0.3); }
.session-tab.active[data-color="green"] { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 10px rgba(34, 197, 94, 0.3); }
.session-tab.active[data-color="blue"] { background: rgba(59, 130, 246, 0.15); border-color: rgba(59, 130, 246, 0.5); box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
.session-tab.active[data-color="purple"] { background: rgba(168, 85, 247, 0.15); border-color: rgba(168, 85, 247, 0.5); box-shadow: 0 0 10px rgba(168, 85, 247, 0.3); }
.session-tab.active[data-color="pink"] { background: rgba(236, 72, 153, 0.15); border-color: rgba(236, 72, 153, 0.5); box-shadow: 0 0 10px rgba(236, 72, 153, 0.3); }
/* Tab number indicator (Alt+N shortcut hint) */
.session-tab .tab-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.35);
color: rgba(255, 255, 255, 0.75);
font-size: 0.6rem;
font-weight: 700;
flex-shrink: 0;
font-family: monospace;
}
.session-tab.active .tab-number {
background: rgba(0, 255, 102, 0.2) !important;
border-color: rgba(0, 255, 102, 0.5) !important;
color: #00FF66 !important;
}
.session-tab .tab-info {
display: flex;
+25 -1
View File
@@ -122,21 +122,45 @@ Object.assign(CodemanApp.prototype, {
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
// The custom key handler above returns false for keyCode 229, telling xterm
// to ignore the keydown. This listener catches those orphaned input events.
// to ignore the keydown. xterm.js expects the character to arrive via
// composition events, but since there's no composition, the character is lost.
// This listener catches those orphaned input events and forwards them to onData.
{
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
if (xtermTextarea && MobileDetection.isTouchDevice()) {
let composing = false;
let lastKeydownHandled = 0;
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
// Track when xterm handles a keydown normally (non-229 keyCode).
// If xterm processed the keydown, it will emit onData itself --
// the input event handler below must NOT re-send the character.
xtermTextarea.addEventListener('keydown', (e) => {
if (!e.isComposing && e.keyCode !== 229) {
lastKeydownHandled = Date.now();
}
});
xtermTextarea.addEventListener('input', (e) => {
// Only handle insertText events outside of composition -- these are
// the ones xterm.js misses on Android virtual keyboards.
if (composing || e.isComposing) return;
if (e.inputType !== 'insertText' || !e.data) return;
// If xterm just handled a keydown (within 50ms), it already sent the
// char via onData. Skip to avoid double-send (e.g., Shift+A => AA).
if (Date.now() - lastKeydownHandled < 50) return;
// xterm.js may have already processed this via its own input handler.
// Check if the textarea was cleared by xterm (value is empty or just
// whitespace) -- if so, xterm handled it and we should not double-send.
// Use a microtask to check after xterm's own handlers have run.
const data = e.data;
Promise.resolve().then(() => {
// If xterm cleared the textarea, it processed the input -- skip.
const val = xtermTextarea.value;
if (!val || val.trim() === '') return;
// xterm didn't process it -- forward to terminal as if typed.
// Emit via onData path by writing to terminal's input handler.
this.terminal._core.coreService.triggerDataEvent(data, true);
// Clear the textarea to prevent xterm from processing it later.
xtermTextarea.value = '';
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* @fileoverview Clipboard routes.
* Accepts text via POST and broadcasts to connected browsers for clipboard write.
*/
import { FastifyInstance } from 'fastify';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
app.post('/api/clipboard', async (req) => {
const body = req.body as { text?: string; sessionId?: string };
const text = body?.text;
if (typeof text !== 'string' || text.length === 0) {
return { success: false, error: 'Missing or empty "text" field' };
}
ctx.broadcast(SseEvent.ClipboardWrite, {
text,
sessionId: body.sessionId ?? null,
timestamp: Date.now(),
});
return { success: true };
});
}
+1
View File
@@ -15,4 +15,5 @@ export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
export { registerPlanRoutes } from './plan-routes.js';
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
export { registerClipboardRoutes } from './clipboard-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+2
View File
@@ -112,6 +112,7 @@ import {
registerRespawnRoutes,
registerRalphRoutes,
registerPlanRoutes,
registerClipboardRoutes,
registerOrchestratorRoutes,
registerWsRoutes,
} from './routes/index.js';
@@ -650,6 +651,7 @@ export class WebServer extends EventEmitter {
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);
registerClipboardRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWsRoutes(this.app, ctx);
}
+8
View File
@@ -319,6 +319,11 @@ export const OrchestratorCompleted = 'orchestrator:completed' as const;
/** Orchestrator error. */
export const OrchestratorError = 'orchestrator:error' as const;
// ─── Clipboard ──────────────────────────────────────────────────────────────
/** Clipboard content pushed to browser. */
export const ClipboardWrite = 'clipboard:write' as const;
// ─── Cases ───────────────────────────────────────────────────────────────────
/** New case directory created. */
@@ -486,6 +491,9 @@ export const SseEvent = {
OrchestratorCompleted,
OrchestratorError,
// Clipboard
ClipboardWrite,
// Cases
CaseCreated,
CaseLinked,