mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-26 02:49:33 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7c502c2a | ||
|
|
546bbcbe7c | ||
|
|
774d5ff321 | ||
|
|
98ceb5da1d | ||
|
|
34fb5e49f8 | ||
|
|
002cf81b1e | ||
|
|
9b4aab2502 | ||
|
|
829c797726 | ||
|
|
85da3bb898 | ||
|
|
29b2653801 |
No files matched your search
@@ -1,5 +1,23 @@
|
|||||||
# aicodeman
|
# 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
|
||||||
|
|
||||||
|
- Fix "Case path not found" error in Quick Start when `~/codeman-cases/` does not exist (issue #64). Two bugs in `session-ui.js`:
|
||||||
|
- `runClaude()` auto-create read `createCaseData.case`, but `POST /api/cases` returns `{ success, data: { case } }` — corrected to `createCaseData.data.case`.
|
||||||
|
- `runShell()` had no auto-create logic and would immediately throw on a missing case directory — now mirrors `runClaude()`'s create-on-demand flow.
|
||||||
|
|
||||||
## 0.5.12
|
## 0.5.12
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -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`
|
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`
|
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.12 (must match `package.json`)
|
**Version**: 0.6.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.5.12",
|
"version": "0.6.0",
|
||||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ const _SSE_HANDLER_MAP = [
|
|||||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
[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: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
{ 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
|
// 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 (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'); }
|
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
|
// Update status indicator
|
||||||
const statusEl = tab.querySelector('.tab-status');
|
const statusEl = tab.querySelector('.tab-status');
|
||||||
if (statusEl && !statusEl.classList.contains(status)) {
|
if (statusEl && !statusEl.classList.contains(status)) {
|
||||||
@@ -1849,6 +1865,7 @@ class CodemanApp {
|
|||||||
// Reorder to put active tab first
|
// Reorder to put active tab first
|
||||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||||
}
|
}
|
||||||
|
let _tabIdx = 0;
|
||||||
for (const id of tabOrder) {
|
for (const id of tabOrder) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
if (!session) continue; // Skip if session was removed
|
if (!session) continue; // Skip if session was removed
|
||||||
@@ -1875,6 +1892,7 @@ class CodemanApp {
|
|||||||
|
|
||||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
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)}"` : ''}>
|
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-status ${status}" aria-hidden="true"></span>
|
||||||
<span class="tab-info">
|
<span class="tab-info">
|
||||||
<span class="tab-name-row">
|
<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">⚙</span>
|
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||||
</div>`);
|
</div>`);
|
||||||
|
_tabIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = parts.join('');
|
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
|
// Session Lifecycle — select, close, navigate
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ const SSE_EVENTS = {
|
|||||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
CLIPBOARD_WRITE: 'clipboard:write',
|
||||||
|
|
||||||
// Cases
|
// Cases
|
||||||
CASE_CREATED: 'case:created',
|
CASE_CREATED: 'case:created',
|
||||||
CASE_LINKED: 'case:linked',
|
CASE_LINKED: 'case:linked',
|
||||||
|
|||||||
@@ -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(); };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -287,8 +287,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
const createCaseData = await createCaseRes.json();
|
const createCaseData = await createCaseRes.json();
|
||||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||||
// Use the newly created case data (API returns { success, case: { name, path } })
|
// API returns { success, data: { case: { name, path } } }
|
||||||
caseData = createCaseData.case;
|
caseData = createCaseData.data.case;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workingDir = caseData.path;
|
const workingDir = caseData.path;
|
||||||
@@ -425,7 +425,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
try {
|
try {
|
||||||
// Get the case path
|
// Get the case path
|
||||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||||
const caseData = await caseRes.json();
|
let caseData = await caseRes.json();
|
||||||
|
|
||||||
|
// Create the case if it doesn't exist
|
||||||
|
if (!caseData.path) {
|
||||||
|
const createCaseRes = await fetch('/api/cases', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: caseName, description: '' })
|
||||||
|
});
|
||||||
|
const createCaseData = await createCaseRes.json();
|
||||||
|
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||||
|
// API returns { success, data: { case: { name, path } } }
|
||||||
|
caseData = createCaseData.data.case;
|
||||||
|
}
|
||||||
|
|
||||||
const workingDir = caseData.path;
|
const workingDir = caseData.path;
|
||||||
if (!workingDir) throw new Error('Case path not found');
|
if (!workingDir) throw new Error('Case path not found');
|
||||||
|
|
||||||
|
|||||||
+36
-12
@@ -258,15 +258,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-tab.active {
|
.session-tab.active {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(34, 197, 94, 0.15) !important;
|
||||||
border-color: var(--glass-border);
|
border: 2px solid #00FF66 !important;
|
||||||
color: var(--text);
|
color: #fff !important;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||||
.session-tab.tab-glow {
|
.session-tab.tab-glow {
|
||||||
animation: tab-glow 0.35s ease-out forwards;
|
animation: tab-glow 0.35s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tab-glow {
|
@keyframes tab-glow {
|
||||||
@@ -304,13 +305,36 @@ body {
|
|||||||
.session-tab[data-color="pink"] { border-left: 3px solid var(--session-pink); }
|
.session-tab[data-color="pink"] { border-left: 3px solid var(--session-pink); }
|
||||||
|
|
||||||
/* Active tabs with color get subtle background tint */
|
/* 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="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.1); }
|
.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.1); }
|
.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.1); }
|
.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.1); }
|
.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.1); }
|
.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.1); }
|
.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 {
|
.session-tab .tab-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -122,21 +122,45 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
||||||
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
||||||
// The custom key handler above returns false for keyCode 229, telling xterm
|
// 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');
|
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
|
||||||
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
||||||
let composing = false;
|
let composing = false;
|
||||||
|
let lastKeydownHandled = 0;
|
||||||
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
||||||
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
|
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) => {
|
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 (composing || e.isComposing) return;
|
||||||
if (e.inputType !== 'insertText' || !e.data) 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;
|
const data = e.data;
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
|
// If xterm cleared the textarea, it processed the input -- skip.
|
||||||
const val = xtermTextarea.value;
|
const val = xtermTextarea.value;
|
||||||
if (!val || val.trim() === '') return;
|
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);
|
this.terminal._core.coreService.triggerDataEvent(data, true);
|
||||||
|
// Clear the textarea to prevent xterm from processing it later.
|
||||||
xtermTextarea.value = '';
|
xtermTextarea.value = '';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,4 +15,5 @@ export { registerRespawnRoutes } from './respawn-routes.js';
|
|||||||
export { registerRalphRoutes } from './ralph-routes.js';
|
export { registerRalphRoutes } from './ralph-routes.js';
|
||||||
export { registerPlanRoutes } from './plan-routes.js';
|
export { registerPlanRoutes } from './plan-routes.js';
|
||||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||||
|
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||||
export { registerWsRoutes } from './ws-routes.js';
|
export { registerWsRoutes } from './ws-routes.js';
|
||||||
@@ -112,6 +112,7 @@ import {
|
|||||||
registerRespawnRoutes,
|
registerRespawnRoutes,
|
||||||
registerRalphRoutes,
|
registerRalphRoutes,
|
||||||
registerPlanRoutes,
|
registerPlanRoutes,
|
||||||
|
registerClipboardRoutes,
|
||||||
registerOrchestratorRoutes,
|
registerOrchestratorRoutes,
|
||||||
registerWsRoutes,
|
registerWsRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
@@ -650,6 +651,7 @@ export class WebServer extends EventEmitter {
|
|||||||
registerRespawnRoutes(this.app, ctx);
|
registerRespawnRoutes(this.app, ctx);
|
||||||
registerRalphRoutes(this.app, ctx);
|
registerRalphRoutes(this.app, ctx);
|
||||||
registerPlanRoutes(this.app, ctx);
|
registerPlanRoutes(this.app, ctx);
|
||||||
|
registerClipboardRoutes(this.app, ctx);
|
||||||
registerOrchestratorRoutes(this.app, ctx);
|
registerOrchestratorRoutes(this.app, ctx);
|
||||||
registerWsRoutes(this.app, ctx);
|
registerWsRoutes(this.app, ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,11 @@ export const OrchestratorCompleted = 'orchestrator:completed' as const;
|
|||||||
/** Orchestrator error. */
|
/** Orchestrator error. */
|
||||||
export const OrchestratorError = 'orchestrator:error' as const;
|
export const OrchestratorError = 'orchestrator:error' as const;
|
||||||
|
|
||||||
|
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Clipboard content pushed to browser. */
|
||||||
|
export const ClipboardWrite = 'clipboard:write' as const;
|
||||||
|
|
||||||
// ─── Cases ───────────────────────────────────────────────────────────────────
|
// ─── Cases ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** New case directory created. */
|
/** New case directory created. */
|
||||||
@@ -486,6 +491,9 @@ export const SseEvent = {
|
|||||||
OrchestratorCompleted,
|
OrchestratorCompleted,
|
||||||
OrchestratorError,
|
OrchestratorError,
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
ClipboardWrite,
|
||||||
|
|
||||||
// Cases
|
// Cases
|
||||||
CaseCreated,
|
CaseCreated,
|
||||||
CaseLinked,
|
CaseLinked,
|
||||||
|
|||||||
Reference in new issue
Block a user