Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ function escHtml(s) {
.replace(/'/g, ''');
}

function escJsString(s) {
if (s === null || s === undefined) return '';
return escHtml(String(s)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029'));
}

function showToast(msg) {
const el = document.getElementById('toast');
if (!el) return;
Expand Down Expand Up @@ -849,8 +860,8 @@ function renderCard(s, idx) {
if (s.has_detail) {
var btnTitle = sessionTitles[s.id] ? 'Regenerate AI title' : 'Generate AI title';
var btnIcon = sessionTitles[s.id] ? '↻' : '⚛';
html += '<button class="card-gen-btn" onclick="event.stopPropagation();generateTitle(\'' + s.id + '\',\'' + escHtml(s.project || '').replace(/'/g, "\\'") + '\')" title="' + btnTitle + '">' + btnIcon + '</button>';
html += '<button class="card-expand-btn" onclick="event.stopPropagation();toggleExpand(\'' + s.id + '\',\'' + escHtml(s.project || '').replace(/'/g, "\\'") + '\',this)" title="Preview messages">&#9662;</button>';
html += '<button class="card-gen-btn" onclick="event.stopPropagation();generateTitle(\'' + escJsString(s.id) + '\',\'' + escJsString(s.project || '') + '\')" title="' + btnTitle + '">' + btnIcon + '</button>';
html += '<button class="card-expand-btn" onclick="event.stopPropagation();toggleExpand(\'' + escJsString(s.id) + '\',\'' + escJsString(s.project || '') + '\',this)" title="Preview messages">&#9662;</button>';
}
html += '</div>';
// MCP/Skills footer
Expand Down Expand Up @@ -2042,7 +2053,7 @@ function renderRunningCard(a, s) {
html += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + sid + '\')">Focus</button>';
if (s) {
html += '<button class="launch-btn btn-secondary" onclick="var ss=allSessions.find(function(x){return x.id===\'' + sid + '\'});if(ss)openDetail(ss);">Details</button>';
html += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + sid + '\',\'' + escHtml((s.project || '').replace(/'/g, "\\'")) + '\')">Replay</button>';
html += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + escJsString(sid) + '\',\'' + escJsString(s.project || '') + '\')">Replay</button>';
}
html += '</div>';
html += '</div>';
Expand All @@ -2065,7 +2076,7 @@ function renderDoneCard(s) {
if (s.last_time) html += '<div class="running-stat"><span class="running-stat-val">' + s.last_time.slice(11) + '</span><span class="running-stat-label">ended</span></div>';
html += '</div>';
html += '<div class="running-actions">';
html += '<button class="launch-btn btn-secondary" onclick="openDetail(' + JSON.stringify({id: s.id, project: s.project || '', tool: s.tool || ''}) + ')">Details</button>';
html += '<button class="launch-btn btn-secondary" onclick="openDetail({id:\'' + escJsString(s.id) + '\',project:\'' + escJsString(s.project || '') + '\',tool:\'' + escJsString(s.tool || '') + '\'})">Details</button>';
html += '</div>';
html += '</div>';
return html;
Expand Down
32 changes: 17 additions & 15 deletions src/frontend/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ async function openDetail(s) {
var aiTitle = sessionTitles[s.id];
var sessionName = s.session_name || '';
var displayName = aiTitle || (sessionName ? sessionName.slice(0, 55) : '');
var escProject = escHtml(s.project || '').replace(/'/g, "\\'");
var jsId = escJsString(s.id);
var jsTool = escJsString(s.tool);
var jsProject = escJsString(s.project || '');
if (displayName) {
infoHtml += '<div class="detail-row"><span class="detail-label">Name</span><span style="font-weight:600;flex:1">' + escHtml(displayName) + '</span><button class="toolbar-btn" style="font-size:10px;padding:1px 6px" onclick="generateTitle(\'' + s.id + '\',\'' + escProject + '\')" title="Regenerate by AI">&#8635;</button></div>';
infoHtml += '<div class="detail-row"><span class="detail-label">Name</span><span style="font-weight:600;flex:1">' + escHtml(displayName) + '</span><button class="toolbar-btn" style="font-size:10px;padding:1px 6px" onclick="generateTitle(\'' + jsId + '\',\'' + jsProject + '\')" title="Regenerate by AI">&#8635;</button></div>';
} else if (s.has_detail) {
infoHtml += '<div class="detail-row"><span class="detail-label">Name</span><button class="toolbar-btn" style="font-size:11px;padding:2px 8px" onclick="generateTitle(\'' + s.id + '\',\'' + escProject + '\')">Generate AI Name</button></div>';
infoHtml += '<div class="detail-row"><span class="detail-label">Name</span><button class="toolbar-btn" style="font-size:11px;padding:2px 8px" onclick="generateTitle(\'' + jsId + '\',\'' + jsProject + '\')">Generate AI Name</button></div>';
}
var detailToolLabel = getToolLabel(s.tool);
infoHtml += '<div class="detail-row"><span class="detail-label">Tool</span><span class="tool-badge tool-' + s.tool + '">' + escHtml(detailToolLabel) + '</span></div>';
Expand Down Expand Up @@ -68,28 +70,28 @@ async function openDetail(s) {
infoHtml += '<div class="detail-actions">';
// Tool-specific launch buttons
if (s.tool === 'cursor') {
infoHtml += '<button class="launch-btn" style="background:#4a9eff" onclick="openInCursor(\'' + escHtml(s.project || '') + '\')">Open in Cursor</button>';
infoHtml += '<button class="launch-btn" style="background:#4a9eff" onclick="openInCursor(\'' + jsProject + '\')">Open in Cursor</button>';
} else if (s.tool === 'copilot-chat') {
infoHtml += '<button class="launch-btn" style="background:#1f6feb" onclick="openInVSCode(\'' + escHtml(s.project || '') + '\')">Open in VS Code</button>';
infoHtml += '<button class="launch-btn" style="background:#1f6feb" onclick="openInVSCode(\'' + jsProject + '\')">Open in VS Code</button>';
} else if (activeSessions[s.id]) {
infoHtml += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + s.id + '\')">Focus Terminal</button>';
infoHtml += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + jsId + '\')">Focus Terminal</button>';
} else {
infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + s.id + '\',\'' + escHtml(s.tool) + '\',\'' + escHtml(s.project || '') + '\')">Resume</button>';
infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + jsId + '\',\'' + jsTool + '\',\'' + jsProject + '\')">Resume</button>';
if (s.tool === 'claude') {
infoHtml += '<button class="launch-btn" style="background:var(--accent-orange);color:#000" onclick="launchDangerous(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')" title="--dangerously-skip-permissions">Resume (skip perms)</button>';
infoHtml += '<button class="launch-btn" style="background:var(--accent-orange);color:#000" onclick="launchDangerous(\'' + jsId + '\',\'' + jsProject + '\')" title="--dangerously-skip-permissions">Resume (skip perms)</button>';
}
}
infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + s.id + '\',\'' + escHtml(s.tool) + '\')">Copy Command</button>';
infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + jsId + '\',\'' + jsTool + '\')">Copy Command</button>';
if (s.has_detail) {
infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Replay</button>';
infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + jsId + '\',\'' + jsProject + '\')">Replay</button>';
infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + jsId + '\',\'' + jsProject + '\')">Export MD</button>';
getConvertTargets(s.tool).forEach(function(target) {
infoHtml += '<button class="launch-btn btn-secondary" onclick="convertTo(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\',\'' + target + '\')">Convert to ' + getToolLabel(target) + '</button>';
infoHtml += '<button class="launch-btn btn-secondary" onclick="convertTo(\'' + jsId + '\',\'' + jsProject + '\',\'' + escJsString(target) + '\')">Convert to ' + getToolLabel(target) + '</button>';
});
infoHtml += '<button class="launch-btn btn-secondary" onclick="downloadHandoff(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Handoff</button>';
infoHtml += '<button class="launch-btn btn-secondary" onclick="downloadHandoff(\'' + jsId + '\',\'' + jsProject + '\')">Handoff</button>';
}
infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + s.id + '\')">&#9733; ' + (isStarred ? 'Starred' : 'Star') + '</button>';
infoHtml += '<button class="launch-btn btn-delete" onclick="showDeleteConfirm(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Delete</button>';
infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + jsId + '\')">&#9733; ' + (isStarred ? 'Starred' : 'Star') + '</button>';
infoHtml += '<button class="launch-btn btn-delete" onclick="showDeleteConfirm(\'' + jsId + '\',\'' + jsProject + '\')">Delete</button>';
infoHtml += '</div>';

body.innerHTML = infoHtml + '<div class="detail-messages"><div class="loading">Loading messages...</div></div><div class="detail-commits"></div>';
Expand Down
94 changes: 68 additions & 26 deletions src/terminals.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,41 +192,70 @@ function termLog(tag, msg) {
console.log(` ${color}${ts} [${tag}]\x1b[0m ${msg}`);
}

function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
const skipPerms = flags.includes('skip-permissions');
function buildAgentCommand(sessionId, tool, flags, mode) {
const skipPerms = (flags || []).includes('skip-permissions');
const fresh = mode === 'fresh';
let cmd;

if (fresh) {
// Start a brand new session in projectDir (no --resume). We map known
// tools to their CLI entry point; unrecognised tools fall back to claude
// so a misconfigured UI doesn't silently open the wrong agent.
switch (tool) {
case 'codex': cmd = 'codex'; break;
case 'qwen': cmd = 'qwen'; break;
case 'kilo': cmd = 'kilo'; break;
case 'kiro': cmd = 'kiro-cli'; break;
case 'opencode': cmd = 'opencode'; break;
case 'cursor': cmd = 'cursor-agent'; break;
case 'codex': return 'codex';
case 'qwen': return 'qwen';
case 'kilo': return 'kilo';
case 'kiro': return 'kiro-cli';
case 'opencode': return 'opencode';
case 'cursor': return 'cursor-agent';
case 'copilot':
case 'copilot-chat': cmd = 'gh copilot suggest'; break;
case 'copilot-chat': return 'gh copilot suggest';
default:
cmd = 'claude';
if (skipPerms) cmd += ' --dangerously-skip-permissions';
return 'claude' + (skipPerms ? ' --dangerously-skip-permissions' : '');
}
} else if (tool === 'codex') {
cmd = `codex resume ${sessionId}`;
} else if (tool === 'qwen') {
cmd = `qwen -r ${sessionId}`;
} else if (tool === 'kilo') {
cmd = `kilo resume ${sessionId}`;
} else {
cmd = `claude --resume ${sessionId}`;
if (skipPerms) cmd += ' --dangerously-skip-permissions';
}

if (tool === 'codex') return `codex resume ${sessionId}`;
if (tool === 'qwen') return `qwen -r ${sessionId}`;
if (tool === 'kilo') return `kilo resume ${sessionId}`;
return `claude --resume ${sessionId}` + (skipPerms ? ' --dangerously-skip-permissions' : '');
}

function buildPosixShellCommand(cmd, projectDir) {
const cdPart = projectDir ? `cd ${JSON.stringify(projectDir)} && ` : '';
const fullCmd = cdPart + cmd;
return cdPart + cmd;
}

function quotePowerShellSingle(value) {
return "'" + String(value).replace(/'/g, "''") + "'";
}

function buildStartProcessArgs(filePath, argList, workingDirectory) {
const script = [
`Start-Process -FilePath ${quotePowerShellSingle(filePath)}`,
workingDirectory ? `-WorkingDirectory ${quotePowerShellSingle(workingDirectory)}` : '',
`-ArgumentList @(${argList.map(quotePowerShellSingle).join(',')})`,
].filter(Boolean).join(' ');
return ['-NoProfile', '-Command', script];
}

function buildWindowsCmdStartArgs(cmd, projectDir) {
return buildStartProcessArgs('cmd.exe', ['/k', cmd], projectDir);
}

function buildWindowsPowerShellStartArgs(cmd, projectDir) {
return buildStartProcessArgs('powershell.exe', ['-NoExit', '-NoProfile', '-Command', cmd], projectDir);
}

function buildWindowsTerminalArgs(cmd, projectDir) {
const args = ['new-tab'];
if (projectDir) args.push('--startingDirectory', projectDir);
args.push('cmd.exe', '/k', cmd);
return args;
}

function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
const cmd = buildAgentCommand(sessionId, tool, flags, mode);
const fullCmd = buildPosixShellCommand(cmd, projectDir);
const escapedCmd = fullCmd.replace(/"/g, '\\"');
termLog('TERM', `openInTerminal: terminal=${terminalId || 'default'} tool=${tool} cmd="${fullCmd}"`);

Expand Down Expand Up @@ -407,13 +436,13 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
} else {
switch (terminalId) {
case 'powershell':
exec(`start powershell -NoExit -Command "${fullCmd}"`);
execFileSafe('powershell.exe', buildWindowsPowerShellStartArgs(cmd, projectDir));
break;
case 'windows-terminal':
exec(`wt new-tab cmd /k "${fullCmd}"`);
execFileSafe('wt.exe', buildWindowsTerminalArgs(cmd, projectDir));
break;
default:
exec(`start cmd /k "${fullCmd}"`);
execFileSafe('powershell.exe', buildWindowsCmdStartArgs(cmd, projectDir));
break;
}
}
Expand Down Expand Up @@ -1019,4 +1048,17 @@ function focusTerminalByPid(pid, sessionId) {
return { ok: false };
}

module.exports = { detectTerminals, openInTerminal, focusTerminalByPid, isWSL };
module.exports = {
detectTerminals,
openInTerminal,
focusTerminalByPid,
isWSL,
__test: {
buildAgentCommand,
buildPosixShellCommand,
buildWindowsCmdStartArgs,
buildWindowsPowerShellStartArgs,
buildWindowsTerminalArgs,
quotePowerShellSingle,
},
};
30 changes: 30 additions & 0 deletions test/frontend-escaping.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('vm');

function loadEscapers() {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8');
const match = source.match(/function escHtml[\s\S]*?\nfunction showToast/);
assert.ok(match, 'escaper block should be present');
const block = match[0].replace(/\nfunction showToast[\s\S]*$/, '');
const ctx = {};
vm.runInNewContext(block + '\nthis.escJsString = escJsString;', ctx);
return ctx;
}

test('escJsString preserves Windows backslashes for inline onclick arguments', () => {
const { escJsString } = loadEscapers();
assert.equal(escJsString('C:\\projects'), 'C:\\\\projects');
assert.equal(escJsString("C:\\Users\\O'Neil\\repo"), 'C:\\\\Users\\\\O\\&#39;Neil\\\\repo');
});

test('detail resume button uses JS-string escaping for project paths', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8');
assert.match(source, /var jsProject = escJsString\(s\.project \|\| ''\);/);
assert.ok(
source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"),
'Resume onclick should pass jsProject, not raw escHtml(project)'
);
});
35 changes: 35 additions & 0 deletions test/terminals-windows-launch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const { __test } = require('../src/terminals');

test('Windows cmd launcher uses WorkingDirectory instead of nested cd quoting', () => {
const cmd = __test.buildAgentCommand('019e2039-7e0d-7582-9116-39d0d2713e36', 'codex', [], 'resume');
const args = __test.buildWindowsCmdStartArgs(cmd, 'C:\\projects');
const script = args[2];

assert.equal(cmd, 'codex resume 019e2039-7e0d-7582-9116-39d0d2713e36');
assert.deepEqual(args.slice(0, 2), ['-NoProfile', '-Command']);
assert.match(script, /Start-Process -FilePath 'cmd\.exe'/);
assert.match(script, /-WorkingDirectory 'C:\\projects'/);
assert.match(script, /-ArgumentList @\('\/k','codex resume 019e2039-7e0d-7582-9116-39d0d2713e36'\)/);
assert.equal(script.includes('cd "C:\\projects" &&'), false);
});

test('Windows Terminal launcher passes cwd as startingDirectory', () => {
const cmd = __test.buildAgentCommand('abc123', 'codex', [], 'resume');
assert.deepEqual(
__test.buildWindowsTerminalArgs(cmd, 'C:\\projects\\codbash'),
['new-tab', '--startingDirectory', 'C:\\projects\\codbash', 'cmd.exe', '/k', 'codex resume abc123']
);
});

test('Windows PowerShell launcher also uses WorkingDirectory', () => {
const cmd = __test.buildAgentCommand('abc123', 'claude', ['skip-permissions'], 'resume');
const script = __test.buildWindowsPowerShellStartArgs(cmd, 'C:\\projects\\codbash')[2];

assert.match(script, /Start-Process -FilePath 'powershell\.exe'/);
assert.match(script, /-WorkingDirectory 'C:\\projects\\codbash'/);
assert.match(script, /'-NoExit','-NoProfile','-Command','claude --resume abc123 --dangerously-skip-permissions'/);
assert.equal(script.includes('&&'), false);
});