feat: cherry-pick keyboard UX and file download from community PRs

Cherry-picked from PR #60 (keyboard UX) and PR #61 (file download):

- Alt+1-9 session switching
- Disable Ctrl+K (too easy to trigger accidentally)
- Session rename with prefix preservation (w1-case: description)
- Shift+Enter / Ctrl+Enter multiline input via tmux send-keys -H
- Android virtual keyboard fix for non-composition input
- File download button in browser file explorer (?download=true)

Dropped from PR #60: stale package-lock.json, upload popup (missing upload.html)
Dropped from PR #61: standalone /api/download endpoint (arbitrary fs access)
Fixed from PR #60: execFileSync replaced with async execFile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-04-11 06:59:50 +02:00
co-authored by Claude Opus 4.6
parent a11bcb0029
commit f3cbe9bca6
8 changed files with 234 additions and 21 deletions
+34 -4
View File
@@ -251,6 +251,23 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
];
// ═══════════════════════════════════════════════════════════════
// Session Name Prefix Parser
// ═══════════════════════════════════════════════════════════════
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
// Returns { prefix, suffix } or null if name does not match the pattern.
function parseSessionPrefix(name) {
if (!name) return null;
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
if (!m) return null;
const prefix = m[1];
const rest = name.slice(prefix.length);
if (rest === "") return { prefix, suffix: "" };
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
return null;
}
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
@@ -615,10 +632,8 @@ class CodemanApp {
// shift? (require Shift), action }.
const SHORTCUTS = [
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
@@ -637,6 +652,16 @@ class CodemanApp {
this.closeHelp();
}
// Alt+1-9: switch to Codeman session by index
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < this.sessionOrder.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
}
return;
}
// Match against shortcut table
for (const s of SHORTCUTS) {
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
@@ -1712,7 +1737,12 @@ class CodemanApp {
// Update name if changed
const nameEl = tab.querySelector('.tab-name');
if (nameEl && nameEl.textContent !== name) {
nameEl.textContent = name;
const _p = parseSessionPrefix(name);
if (_p && _p.suffix) {
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
} else {
nameEl.textContent = name;
}
}
// Update task badge
@@ -1820,7 +1850,7 @@ class CodemanApp {
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(name)}</span>
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
</span>
+5 -3
View File
@@ -477,10 +477,9 @@
</div>
<div class="modal-body">
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Run Claude</div>
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Tmux</div>
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
@@ -677,7 +676,10 @@
<div class="form-section-header">Appearance</div>
<div class="form-row">
<label>Session Name</label>
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
<div style="display: flex; align-items: center; gap: 4px;">
<span id="modalSessionPrefix" style="color: var(--text-muted); white-space: nowrap; display: none;"></span>
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()" style="flex: 1;">
</div>
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
</div>
<div class="form-row">
+5
View File
@@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
html.push(`
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
${expandIcon}
<span class="file-tree-icon">${icon}</span>
<span class="${nameClass}">${escapeHtml(node.name)}</span>
${sizeStr}
${downloadBtn}
</div>
`);
+50 -13
View File
@@ -298,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
// Find the highest existing w-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
// Find the highest existing s-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
@@ -596,8 +596,20 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input
document.getElementById('modalSessionName').value = session.name || '';
// Populate session name input with prefix/suffix split
const _modalParsed = parseSessionPrefix(session.name);
const _prefixEl = document.getElementById('modalSessionPrefix');
if (_modalParsed) {
_prefixEl.textContent = _modalParsed.prefix + ': ';
_prefixEl.style.display = '';
document.getElementById('modalSessionName').value = _modalParsed.suffix;
document.getElementById('modalSessionName').placeholder = 'Add description...';
} else {
_prefixEl.style.display = 'none';
_prefixEl.textContent = '';
document.getElementById('modalSessionName').value = session.name || '';
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
}
// Initialize color picker with current session color
const currentColor = session.color || 'default';
@@ -644,7 +656,15 @@ Object.assign(CodemanApp.prototype, {
async saveSessionName() {
if (!this.editingSessionId) return;
const name = document.getElementById('modalSessionName').value.trim();
const session = this.sessions.get(this.editingSessionId);
const parsed = session ? parseSessionPrefix(session.name) : null;
const inputVal = document.getElementById('modalSessionName').value.trim();
let name;
if (parsed) {
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
} else {
name = inputVal;
}
try {
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
} catch (err) {
@@ -864,29 +884,46 @@ Object.assign(CodemanApp.prototype, {
if (!tabName) return;
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
const originalContent = tabName.textContent;
tabName.textContent = '';
tabName.innerHTML = '';
// If prefix detected, show it as non-editable label
if (parsed) {
const prefixLabel = document.createElement('span');
prefixLabel.textContent = parsed.prefix + ': ';
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
tabName.appendChild(prefixLabel);
}
const input = document.createElement('input');
input.type = 'text';
input.value = session.name || '';
input.placeholder = currentName;
input.value = parsed ? parsed.suffix : (session.name || '');
input.placeholder = parsed ? 'Add description...' : currentName;
input.className = 'tab-rename-input';
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
const originalContent = tabName.textContent;
tabName.textContent = '';
tabName.appendChild(input);
input.focus();
input.select();
const finishRename = async () => {
const newName = input.value.trim();
tabName.textContent = newName || originalContent;
const suffix = input.value.trim();
let fullName;
if (parsed) {
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
} else {
fullName = suffix;
}
tabName.textContent = fullName || originalContent;
if (newName && newName !== session.name) {
if (fullName !== session.name) {
try {
await fetch(`/api/sessions/${sessionId}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
body: JSON.stringify({ name: fullName })
});
} catch (err) {
tabName.textContent = originalContent;
+25
View File
@@ -330,6 +330,15 @@ body {
text-overflow: ellipsis;
}
.session-tab .tab-prefix {
color: var(--text-muted);
}
.session-tab .tab-suffix {
color: var(--text);
font-weight: 500;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
.session-tab .tab-folder {
font-size: 0.6rem;
@@ -6579,6 +6588,22 @@ kbd {
padding-left: 0.5rem;
}
.file-tree-download {
display: none;
margin-left: auto;
padding: 0 4px;
color: #888;
text-decoration: none;
font-size: 12px;
flex-shrink: 0;
}
.file-tree-item:hover .file-tree-download {
display: inline;
}
.file-tree-download:hover {
color: #4fc3f7;
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
+63
View File
@@ -77,9 +77,72 @@ Object.assign(CodemanApp.prototype, {
// during composition, causing duplicate or garbled input.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
if (this.activeSessionId) {
if (this._localEchoEnabled) {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
this._localEchoOverlay?.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (text) {
this._pendingInput += text;
flushInput();
}
setTimeout(() => {
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
});
}, text ? 80 : 0);
} else {
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
});
}
}
return false;
}
return true;
});
// Android virtual keyboard fix: catch non-composition input events.
// 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.
{
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
if (xtermTextarea && MobileDetection.isTouchDevice()) {
let composing = false;
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
xtermTextarea.addEventListener('input', (e) => {
if (composing || e.isComposing) return;
if (e.inputType !== 'insertText' || !e.data) return;
const data = e.data;
Promise.resolve().then(() => {
const val = xtermTextarea.value;
if (!val || val.trim() === '') return;
this.terminal._core.coreService.triggerDataEvent(data, true);
xtermTextarea.value = '';
});
});
}
}
// WebGL renderer for GPU-accelerated terminal rendering.
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
+11 -1
View File
@@ -239,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const { path: filePath, download } = req.query as { path?: string; download?: string };
const session = findSessionOrFail(ctx, id);
if (!filePath) {
@@ -292,6 +292,16 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
};
const content = await fs.readFile(resolvedPath);
if (download === 'true') {
const basename = filePath!.split('/').pop() || 'download';
reply.raw.writeHead(200, {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${basename}"`,
'Content-Length': content.length,
});
reply.raw.end(content);
return;
}
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
+41
View File
@@ -524,6 +524,47 @@ export function registerSessionRoutes(
return { success: true };
});
// ========== Send Named Key (tmux send-keys -H) ==========
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
const session = findSessionOrFail(ctx, id);
const muxName = (session as any)._muxSession?.muxName;
if (!muxName) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
try {
const { execFile } = await import('child_process');
await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
if (err) reject(err);
else resolve();
});
});
} catch (err) {
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
}
return { success: true };
});
// ========== Resize Terminal ==========
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {