Skip to content
Open
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
119 changes: 94 additions & 25 deletions packages/agent-connector/src/adapters/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,45 @@ class CodexAdapter extends BaseAdapter {
return null;
}

_findNodeBin() {
const home = os.homedir();
if (process.execPath && fs.existsSync(process.execPath)) return process.execPath;

const candidates = IS_WINDOWS
? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
: [
path.join(home, '.openagents', 'nodejs', 'node'),
path.join(home, '.openagents', 'nodejs', 'bin', 'node'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return 'node';
}

_resolveToNodeCmd(binPath) {
const nodeBin = this._findNodeBin();
if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
const cmdDir = path.dirname(path.resolve(binPath));
const cmdContent = fs.readFileSync(binPath, 'utf-8');
const jsMatch = cmdContent.match(/%dp0%\\([^\s"*?]+\.m?js)/i);
if (jsMatch) {
return [nodeBin, path.resolve(cmdDir, jsMatch[1])];
}
} else {
try {
let target = binPath;
if (fs.lstatSync(binPath).isSymbolicLink()) {
target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
}
if (target.endsWith('.js') || target.endsWith('.mjs')) {
return [nodeBin, target];
}
} catch {}
}
return null;
}

_buildSystemContext(channelName) {
return buildOpenclawSystemPrompt({
agentName: this.agentName,
Expand All @@ -179,6 +218,14 @@ class CodexAdapter extends BaseAdapter {
});
}

_getSpawnCwd() {
if (!this.workingDir) return undefined;
const normalized = path.normalize(this.workingDir);
if (fs.existsSync(normalized)) return normalized;
this._log(`Working directory not found, using current directory: ${this.workingDir}`);
return undefined;
}

// ------------------------------------------------------------------
// Process management
// ------------------------------------------------------------------
Expand Down Expand Up @@ -304,14 +351,38 @@ class CodexAdapter extends BaseAdapter {

async _spawnCodex(cmd, env, msgChannel, prompt) {
return new Promise((resolve, reject) => {
const proc = spawn(cmd[0], cmd.slice(1), {
stdio: ['pipe', 'pipe', 'pipe'],
env,
cwd: this.workingDir,
detached: !IS_WINDOWS,
windowsHide: true,
shell: IS_WINDOWS,
});
const resolved = this._resolveToNodeCmd(cmd[0]);
if (resolved) {
cmd = [resolved[0], resolved[1], ...cmd.slice(1)];
} else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
cmd = ['cmd.exe', '/c', ...cmd];
}

let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
delete this._channelProcesses[msgChannel];
reject(err);
};

// Passing the prompt as an argument avoids Windows/Node 22 stdin pipe
// ENOTCONN crashes seen when launching npm .cmd shims.
cmd.push(prompt || '');

let proc;
try {
proc = spawn(cmd[0], cmd.slice(1), {
stdio: ['ignore', 'pipe', 'pipe'],
env,
cwd: this._getSpawnCwd(),
detached: !IS_WINDOWS,
windowsHide: true,
});
} catch (err) {
fail(err);
return;
}
this._channelProcesses[msgChannel] = proc;

const responseTexts = [];
Expand All @@ -321,14 +392,10 @@ class CodexAdapter extends BaseAdapter {
let _pendingLines = Promise.resolve();

if (proc.stderr) {
proc.stderr.on('error', fail);
proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
}

if (proc.stdin) {
proc.stdin.write(prompt || '', 'utf-8');
proc.stdin.end();
}

const processLine = async (line) => {
line = line.trim();
if (!line) return;
Expand Down Expand Up @@ -378,16 +445,20 @@ class CodexAdapter extends BaseAdapter {
}
};

proc.stdout.on('data', (chunk) => {
lineBuffer += chunk.toString('utf-8');
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop();
for (const line of lines) {
_pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
}
});
if (proc.stdout) {
proc.stdout.on('error', fail);
proc.stdout.on('data', (chunk) => {
lineBuffer += chunk.toString('utf-8');
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop();
for (const line of lines) {
_pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
}
});
}

proc.on('exit', async (code) => {
if (settled) return;
// Wait for all in-flight processLine calls
try { await _pendingLines; } catch {}

Expand All @@ -405,17 +476,15 @@ class CodexAdapter extends BaseAdapter {
}
}

settled = true;
resolve({
responseText: responseTexts.join('\n').trim(),
exitCode: code,
stderr: stderrBuf,
});
});

proc.on('error', (err) => {
delete this._channelProcesses[msgChannel];
reject(err);
});
proc.on('error', fail);
});
}

Expand Down
16 changes: 12 additions & 4 deletions workspace/frontend/components/chat/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ interface Attachment {
}

function isPreviewable(contentType: string, filename: string): boolean {
if (contentType?.startsWith('image/')) return true;
if (contentType === 'text/html' || /\.html?$/i.test(filename)) return true;
if (contentType === 'text/markdown' || /\.mdx?$/i.test(filename)) return true;
if (contentType?.startsWith('text/') || /\.(json|js|ts|tsx|jsx|py|rs|go|java|rb|sh|yaml|yml)$/i.test(filename)) return true;
const type = contentType?.split(';')[0].trim().toLowerCase() || '';
if (type.startsWith('image/')) return true;
if (type === 'application/pdf' || /\.pdf$/i.test(filename)) return true;
if (type === 'text/html' || /\.html?$/i.test(filename)) return true;
if (type === 'text/markdown' || /\.mdx?$/i.test(filename)) return true;
if (
type === 'text/csv' ||
type === 'application/csv' ||
type === 'application/vnd.ms-excel' ||
/\.csv$/i.test(filename)
) return true;
if (type.startsWith('text/') || /\.(json|js|ts|tsx|jsx|py|rs|go|java|rb|sh|yaml|yml)$/i.test(filename)) return true;
return false;
}

Expand Down
123 changes: 119 additions & 4 deletions workspace/frontend/components/files/file-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,24 @@ function isMarkdownFile(contentType: string, filename: string): boolean {
return contentType === 'text/markdown' || /\.mdx?$/i.test(filename);
}

function isPdfFile(contentType: string, filename: string): boolean {
const type = contentType.split(';')[0].trim().toLowerCase();
return type === 'application/pdf' || /\.pdf$/i.test(filename);
}

function isCsvFile(contentType: string, filename: string): boolean {
const type = contentType.split(';')[0].trim().toLowerCase();
return (
type === 'text/csv' ||
type === 'application/csv' ||
type === 'application/vnd.ms-excel' ||
/\.csv$/i.test(filename)
);
}

function isTextFile(contentType: string, filename: string): boolean {
if (isHtmlFile(contentType, filename)) return false; // HTML is handled separately
if (isCsvFile(contentType, filename)) return false; // CSV is handled separately
return (
contentType.startsWith('text/') ||
contentType === 'application/json' ||
Expand All @@ -39,6 +55,53 @@ function isTextFile(contentType: string, filename: string): boolean {
);
}

function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;

for (let i = 0; i < text.length; i++) {
const char = text[i];

if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += char;
}
continue;
}

if (char === '"') {
inQuotes = true;
} else if (char === ',') {
row.push(field);
field = '';
} else if (char === '\n' || char === '\r') {
row.push(field);
rows.push(row);
row = [];
field = '';
if (char === '\r' && text[i + 1] === '\n') i++;
} else {
field += char;
}
}

if (field !== '' || row.length > 0) {
row.push(field);
rows.push(row);
}

return rows;
}

export function FilePreview() {
const { files, selectedFileId, deleteFile, setSelectedFileId } = useWorkspace();
const { isMobile, openMobileList } = useLayout();
Expand Down Expand Up @@ -67,9 +130,11 @@ export function FilePreview() {
const fn = file.filename || '';
const isHtml = isHtmlFile(ct, fn);
const isImage = isImageFile(ct);
const isPdf = isPdfFile(ct, fn);
const isCsv = isCsvFile(ct, fn);
const isText = isTextFile(ct, fn);

// HTML and images use the direct URL no fetch needed
// HTML uses the direct URL - no fetch needed
if (isHtml) {
setContent(null);
const url = workspaceApi.getFileUrl(file.id);
Expand All @@ -79,7 +144,7 @@ export function FilePreview() {
return;
}

if (!isText && !isImage) {
if (!isText && !isImage && !isCsv && !isPdf) {
setContent(null);
setBlobUrl(null);
return;
Expand All @@ -98,11 +163,12 @@ export function FilePreview() {
if (cancelled) return;
if (!res.ok) throw new Error(`HTTP ${res.status}`);

if (isImage) {
if (isImage || isPdf) {
const blob = await res.blob();
if (!cancelled) {
if (blobUrl) URL.revokeObjectURL(blobUrl);
setBlobUrl(URL.createObjectURL(blob));
const previewBlob = isPdf ? new Blob([blob], { type: 'application/pdf' }) : blob;
setBlobUrl(URL.createObjectURL(previewBlob));
setContent(null);
}
} else {
Expand Down Expand Up @@ -201,6 +267,12 @@ export function FilePreview() {
className="w-full h-full border-0"
sandbox="allow-scripts allow-same-origin"
/>
) : isPdfFile(file.contentType || '', file.filename) && blobUrl ? (
<iframe
src={blobUrl}
title={file.filename}
className="w-full h-full border-0"
/>
) : blobUrl && isImageFile(file.contentType || '') ? (
<div className="flex items-center justify-center p-4 h-full">
<img
Expand All @@ -213,6 +285,8 @@ export function FilePreview() {
<div className="p-5 max-w-3xl mx-auto text-sm">
<MarkdownContent content={content} agentNames={[]} />
</div>
) : content !== null && isCsvFile(file.contentType || '', file.filename) ? (
<CsvTable rows={parseCsv(content)} />
) : content !== null ? (
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-foreground">
{content}
Expand All @@ -233,3 +307,44 @@ export function FilePreview() {
</div>
);
}

function CsvTable({ rows }: { rows: string[][] }) {
const visibleRows = rows.slice(0, 1000);
const header = visibleRows[0] || [];
const bodyRows = visibleRows.slice(1);
const truncated = rows.length > visibleRows.length;

return (
<div className="p-4 text-xs">
{truncated && (
<p className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200">
Showing first 1000 rows of {rows.length}. Download the file to view the rest.
</p>
)}
<div className="overflow-x-auto rounded border">
<table className="min-w-full border-collapse">
<thead className="bg-muted">
<tr>
{header.map((cell, index) => (
<th key={index} className="border-b border-r px-3 py-2 text-left font-semibold last:border-r-0">
{cell}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIndex) => (
<tr key={rowIndex} className="odd:bg-background even:bg-muted/30">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="border-b border-r px-3 py-2 align-top last:border-r-0">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Loading