Skip to content

Commit f3cbe9b

Browse files
Ark0Nclaude
andcommitted
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>
1 parent a11bcb0 commit f3cbe9b

File tree

8 files changed

+234
-21
lines changed

8 files changed

+234
-21
lines changed

src/web/public/app.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,23 @@ const _SSE_HANDLER_MAP = [
251251
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
252252
];
253253

254+
255+
// ═══════════════════════════════════════════════════════════════
256+
// Session Name Prefix Parser
257+
// ═══════════════════════════════════════════════════════════════
258+
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
259+
// Returns { prefix, suffix } or null if name does not match the pattern.
260+
function parseSessionPrefix(name) {
261+
if (!name) return null;
262+
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
263+
if (!m) return null;
264+
const prefix = m[1];
265+
const rest = name.slice(prefix.length);
266+
if (rest === "") return { prefix, suffix: "" };
267+
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
268+
return null;
269+
}
270+
254271
// ═══════════════════════════════════════════════════════════════
255272
// CodemanApp Class — constructor and global state
256273
// ═══════════════════════════════════════════════════════════════
@@ -615,10 +632,8 @@ class CodemanApp {
615632
// shift? (require Shift), action }.
616633
const SHORTCUTS = [
617634
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
618-
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
619635
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
620636
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
621-
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
622637
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
623638
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
624639
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
@@ -637,6 +652,16 @@ class CodemanApp {
637652
this.closeHelp();
638653
}
639654

655+
// Alt+1-9: switch to Codeman session by index
656+
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
657+
const idx = parseInt(e.key) - 1;
658+
if (idx < this.sessionOrder.length) {
659+
e.preventDefault();
660+
this.selectSession(this.sessionOrder[idx]);
661+
}
662+
return;
663+
}
664+
640665
// Match against shortcut table
641666
for (const s of SHORTCUTS) {
642667
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
@@ -1712,7 +1737,12 @@ class CodemanApp {
17121737
// Update name if changed
17131738
const nameEl = tab.querySelector('.tab-name');
17141739
if (nameEl && nameEl.textContent !== name) {
1715-
nameEl.textContent = name;
1740+
const _p = parseSessionPrefix(name);
1741+
if (_p && _p.suffix) {
1742+
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
1743+
} else {
1744+
nameEl.textContent = name;
1745+
}
17161746
}
17171747

17181748
// Update task badge
@@ -1820,7 +1850,7 @@ class CodemanApp {
18201850
<span class="tab-info">
18211851
<span class="tab-name-row">
18221852
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
1823-
<span class="tab-name" data-session-id="${id}">${escapeHtml(name)}</span>
1853+
<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>
18241854
</span>
18251855
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
18261856
</span>

src/web/public/index.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,10 +477,9 @@ <h3>Keyboard Shortcuts</h3>
477477
</div>
478478
<div class="modal-body">
479479
<div class="shortcuts-grid">
480-
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Run Claude</div>
481480
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
482481
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
483-
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Tmux</div>
482+
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
484483
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
485484
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
486485
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
@@ -677,7 +676,10 @@ <h3>Session Options</h3>
677676
<div class="form-section-header">Appearance</div>
678677
<div class="form-row">
679678
<label>Session Name</label>
680-
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
679+
<div style="display: flex; align-items: center; gap: 4px;">
680+
<span id="modalSessionPrefix" style="color: var(--text-muted); white-space: nowrap; display: none;"></span>
681+
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()" style="flex: 1;">
682+
</div>
681683
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
682684
</div>
683685
<div class="form-row">

src/web/public/panels-ui.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
23182318

23192319
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
23202320

2321+
const downloadBtn = !isDir
2322+
? `<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>`
2323+
: '';
2324+
23212325
html.push(`
23222326
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
23232327
${expandIcon}
23242328
<span class="file-tree-icon">${icon}</span>
23252329
<span class="${nameClass}">${escapeHtml(node.name)}</span>
23262330
${sizeStr}
2331+
${downloadBtn}
23272332
</div>
23282333
`);
23292334

src/web/public/session-ui.js

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
298298
// Find the highest existing w-number for THIS case to avoid duplicates
299299
let startNumber = 1;
300300
for (const [, session] of this.sessions) {
301-
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
301+
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
302302
if (match && match[2] === caseName) {
303303
const num = parseInt(match[1]);
304304
if (num >= startNumber) {
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
432432
// Find the highest existing s-number for THIS case to avoid duplicates
433433
let startNumber = 1;
434434
for (const [, session] of this.sessions) {
435-
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
435+
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
436436
if (match && match[2] === caseName) {
437437
const num = parseInt(match[1]);
438438
if (num >= startNumber) {
@@ -596,8 +596,20 @@ Object.assign(CodemanApp.prototype, {
596596
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
597597
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
598598

599-
// Populate session name input
600-
document.getElementById('modalSessionName').value = session.name || '';
599+
// Populate session name input with prefix/suffix split
600+
const _modalParsed = parseSessionPrefix(session.name);
601+
const _prefixEl = document.getElementById('modalSessionPrefix');
602+
if (_modalParsed) {
603+
_prefixEl.textContent = _modalParsed.prefix + ': ';
604+
_prefixEl.style.display = '';
605+
document.getElementById('modalSessionName').value = _modalParsed.suffix;
606+
document.getElementById('modalSessionName').placeholder = 'Add description...';
607+
} else {
608+
_prefixEl.style.display = 'none';
609+
_prefixEl.textContent = '';
610+
document.getElementById('modalSessionName').value = session.name || '';
611+
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
612+
}
601613

602614
// Initialize color picker with current session color
603615
const currentColor = session.color || 'default';
@@ -644,7 +656,15 @@ Object.assign(CodemanApp.prototype, {
644656

645657
async saveSessionName() {
646658
if (!this.editingSessionId) return;
647-
const name = document.getElementById('modalSessionName').value.trim();
659+
const session = this.sessions.get(this.editingSessionId);
660+
const parsed = session ? parseSessionPrefix(session.name) : null;
661+
const inputVal = document.getElementById('modalSessionName').value.trim();
662+
let name;
663+
if (parsed) {
664+
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
665+
} else {
666+
name = inputVal;
667+
}
648668
try {
649669
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
650670
} catch (err) {
@@ -864,29 +884,46 @@ Object.assign(CodemanApp.prototype, {
864884
if (!tabName) return;
865885

866886
const currentName = this.getSessionName(session);
887+
const parsed = parseSessionPrefix(session.name);
888+
const originalContent = tabName.textContent;
889+
tabName.textContent = '';
890+
tabName.innerHTML = '';
891+
892+
// If prefix detected, show it as non-editable label
893+
if (parsed) {
894+
const prefixLabel = document.createElement('span');
895+
prefixLabel.textContent = parsed.prefix + ': ';
896+
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
897+
tabName.appendChild(prefixLabel);
898+
}
899+
867900
const input = document.createElement('input');
868901
input.type = 'text';
869-
input.value = session.name || '';
870-
input.placeholder = currentName;
902+
input.value = parsed ? parsed.suffix : (session.name || '');
903+
input.placeholder = parsed ? 'Add description...' : currentName;
871904
input.className = 'tab-rename-input';
872905
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;';
873906

874-
const originalContent = tabName.textContent;
875-
tabName.textContent = '';
876907
tabName.appendChild(input);
877908
input.focus();
878909
input.select();
879910

880911
const finishRename = async () => {
881-
const newName = input.value.trim();
882-
tabName.textContent = newName || originalContent;
912+
const suffix = input.value.trim();
913+
let fullName;
914+
if (parsed) {
915+
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
916+
} else {
917+
fullName = suffix;
918+
}
919+
tabName.textContent = fullName || originalContent;
883920

884-
if (newName && newName !== session.name) {
921+
if (fullName !== session.name) {
885922
try {
886923
await fetch(`/api/sessions/${sessionId}/name`, {
887924
method: 'PUT',
888925
headers: { 'Content-Type': 'application/json' },
889-
body: JSON.stringify({ name: newName })
926+
body: JSON.stringify({ name: fullName })
890927
});
891928
} catch (err) {
892929
tabName.textContent = originalContent;

src/web/public/styles.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,15 @@ body {
330330
text-overflow: ellipsis;
331331
}
332332

333+
.session-tab .tab-prefix {
334+
color: var(--text-muted);
335+
}
336+
337+
.session-tab .tab-suffix {
338+
color: var(--text);
339+
font-weight: 500;
340+
}
341+
333342
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
334343
.session-tab .tab-folder {
335344
font-size: 0.6rem;
@@ -6579,6 +6588,22 @@ kbd {
65796588
padding-left: 0.5rem;
65806589
}
65816590

6591+
.file-tree-download {
6592+
display: none;
6593+
margin-left: auto;
6594+
padding: 0 4px;
6595+
color: #888;
6596+
text-decoration: none;
6597+
font-size: 12px;
6598+
flex-shrink: 0;
6599+
}
6600+
.file-tree-item:hover .file-tree-download {
6601+
display: inline;
6602+
}
6603+
.file-tree-download:hover {
6604+
color: #4fc3f7;
6605+
}
6606+
65826607
/* File tree indent levels */
65836608
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
65846609
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }

src/web/public/terminal-ui.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,72 @@ Object.assign(CodemanApp.prototype, {
7777
// during composition, causing duplicate or garbled input.
7878
this.terminal.attachCustomKeyEventHandler((ev) => {
7979
if (ev.isComposing || ev.keyCode === 229) return false;
80+
81+
// Let Alt+digit pass through to browser (tab switching)
82+
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
83+
84+
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
85+
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
86+
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
87+
// which the inner application recognizes as "insert newline" vs carriage return.
88+
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
89+
if (this.activeSessionId) {
90+
if (this._localEchoEnabled) {
91+
const text = this._localEchoOverlay?.pendingText || '';
92+
this._localEchoOverlay?.clear();
93+
this._localEchoOverlay?.suppressBufferDetection();
94+
this._flushedOffsets?.delete(this.activeSessionId);
95+
this._flushedTexts?.delete(this.activeSessionId);
96+
if (text) {
97+
this._pendingInput += text;
98+
flushInput();
99+
}
100+
setTimeout(() => {
101+
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
102+
method: 'POST',
103+
headers: { 'Content-Type': 'application/json' },
104+
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
105+
});
106+
}, text ? 80 : 0);
107+
} else {
108+
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json' },
111+
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
112+
});
113+
}
114+
}
115+
return false;
116+
}
117+
80118
return true;
81119
});
82120

121+
// Android virtual keyboard fix: catch non-composition input events.
122+
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
123+
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
124+
// The custom key handler above returns false for keyCode 229, telling xterm
125+
// to ignore the keydown. This listener catches those orphaned input events.
126+
{
127+
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
128+
if (xtermTextarea && MobileDetection.isTouchDevice()) {
129+
let composing = false;
130+
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
131+
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
132+
xtermTextarea.addEventListener('input', (e) => {
133+
if (composing || e.isComposing) return;
134+
if (e.inputType !== 'insertText' || !e.data) return;
135+
const data = e.data;
136+
Promise.resolve().then(() => {
137+
const val = xtermTextarea.value;
138+
if (!val || val.trim() === '') return;
139+
this.terminal._core.coreService.triggerDataEvent(data, true);
140+
xtermTextarea.value = '';
141+
});
142+
});
143+
}
144+
}
145+
83146
// WebGL renderer for GPU-accelerated terminal rendering.
84147
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
85148
// but the 48KB/frame flush cap in flushPendingWrites() now prevents

src/web/routes/file-routes.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
239239
// Serve raw file content (for images/binary files)
240240
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
241241
const { id } = req.params as { id: string };
242-
const { path: filePath } = req.query as { path?: string };
242+
const { path: filePath, download } = req.query as { path?: string; download?: string };
243243
const session = findSessionOrFail(ctx, id);
244244

245245
if (!filePath) {
@@ -292,6 +292,16 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
292292
};
293293

294294
const content = await fs.readFile(resolvedPath);
295+
if (download === 'true') {
296+
const basename = filePath!.split('/').pop() || 'download';
297+
reply.raw.writeHead(200, {
298+
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
299+
'Content-Disposition': `attachment; filename="${basename}"`,
300+
'Content-Length': content.length,
301+
});
302+
reply.raw.end(content);
303+
return;
304+
}
295305
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
296306
reply.send(content);
297307
} catch (err) {

0 commit comments

Comments
 (0)