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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ out/
*.un~

# --- Project-Specific / Local Settings ---
website/

scratch/
.worktrees/
docs/superpowers/
Expand Down
415 changes: 413 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"tsup": "^8.3.0",
"turbo": "^2.9.12",
"typescript": "^5.7.0",
"vite-plugin-node-polyfills": "^0.28.0",
"vitest": "^4.1.8"
},
"packageManager": "bun@1.3.14",
Expand All @@ -45,4 +46,3 @@
"dotenv": "^16.0.0"
}
}

11 changes: 11 additions & 0 deletions packages/core/src/terminal/__snapshots__/snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ exports[`terminal snapshot rendering > captures wide/unicode characters consiste
"你好,世界 🌍
🏳️‍🌈 multi-codepoint"
`;

exports[`terminal snapshot rendering captures a simple ASCII frame 1`] = `
"Hello, TermUI!

Line 2 content"
`;

exports[`terminal snapshot rendering captures wide/unicode characters consistently 1`] = `
"你好,世界 🌍
🏳️‍🌈 multi-codepoint"
`;
2 changes: 1 addition & 1 deletion packages/jsx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export type {
// ── Render ──
export { render, renderApp } from './render.js';
export type { RenderOptions } from './render.js';
export { getCurrentApp } from './runtime.js';
export { getCurrentApp, setCurrentApp } from './runtime.js';

// ── Reconciler (internal, but useful for testing) ──
export { reconcile, reRenderComponent, unmountAll } from './reconciler.js';
Expand Down
1 change: 0 additions & 1 deletion scripts/install-theme.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env node
// ─────────────────────────────────────────────────────
// install-theme — Install a TermUI theme (SECURED VERSION)
// ─────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion website/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
20 changes: 19 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.0.0"
"@types/prismjs": "^1.26.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.0.0",
"vite-plugin-node-polyfills": "^0.28.0"
},
"dependencies": {
"@babel/standalone": "^8.0.2",
"@termuijs/core": "^0.1.6",
"@termuijs/jsx": "^0.1.6",
"@termuijs/testing": "^0.1.6",
"@termuijs/widgets": "^0.1.6",
"prismjs": "^1.30.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-simple-code-editor": "^0.14.1",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
}
}
285 changes: 285 additions & 0 deletions website/src/components/Playground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor from 'react-simple-code-editor';
import Prism from 'prismjs';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
import 'prismjs/themes/prism-tomorrow.css';
import * as Babel from '@babel/standalone';
import * as termuijsWidgets from '@termuijs/widgets';
import * as termuijsCore from '@termuijs/core';
import * as termuijsJsx from '@termuijs/jsx';
import { EXAMPLES } from '../examples';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';

// Fix for React inside the eval scope
const globalReact = React;

export function Playground() {
const [activeExample, setActiveExample] = useState('dashboard');
const [code, setCode] = useState(EXAMPLES['dashboard'].code);
const [error, setError] = useState<string | null>(null);

const terminalContainerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const termuiAppRef = useRef<termuijsCore.App | null>(null);

const handleExampleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const key = e.target.value;
setActiveExample(key);
setCode(EXAMPLES[key].code);
};

const handleCopy = () => {
navigator.clipboard.writeText(code);
alert('Code copied to clipboard!');
};

const handleInstall = () => {
const cmd = `npx create-termui-app add ${EXAMPLES[activeExample].name.toLowerCase().replace(/\s+/g, '-')}`;
navigator.clipboard.writeText(cmd);
alert(`Copied install command: ${cmd}`);
};

// Initialize xterm.js once
useEffect(() => {
if (terminalContainerRef.current && !xtermRef.current) {
const xterm = new XTerm({
cols: 80,
rows: 24,
theme: {
background: '#0c0c0c',
foreground: '#cccccc',
},
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(terminalContainerRef.current);
fitAddon.fit();

xtermRef.current = xterm;
fitAddonRef.current = fitAddon;

const handleResize = () => {
if (fitAddonRef.current) fitAddonRef.current.fit();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}
}, []);

// Compile and run the code whenever it changes
useEffect(() => {
let unmounted = false;

if (termuiAppRef.current) {
try {
termuiAppRef.current.unmount();
} catch (e) {
// ignore
}
termuiAppRef.current = null;
}

let dataDisposable: any = null;

try {
// Prepare evaluation scope
const customRequire = (moduleName: string) => {
if (moduleName === '@termuijs/widgets') return termuijsWidgets;
if (moduleName === '@termuijs/core') return termuijsCore;
if (moduleName === '@termuijs/jsx') return termuijsJsx;
if (moduleName === 'react') return globalReact;
throw new Error(`Cannot find module '${moduleName}'`);
};

const exports = {};
const module = { exports };

// Transpile TSX to JS for TermUI
const termuiTranspiled = Babel.transform(code, {
presets: ['env', 'typescript'],
plugins: [
'transform-modules-commonjs',
['transform-react-jsx', {
runtime: 'classic',
pragma: 'TermUI.createElement',
pragmaFrag: 'TermUI.Fragment'
}]
],
filename: 'code.tsx',
}).code;

if (!termuiTranspiled) throw new Error('Compilation failed.');

// Provide TermUI scope
const scope = {
exports,
module,
require: customRequire,
TermUI: termuijsJsx,
};

// Execute code
const fn = new Function(...Object.keys(scope), termuiTranspiled);
fn(...Object.values(scope));
Comment on lines +128 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n website/src/components/Playground.tsx | head -150 | tail -50

Repository: Karanjot786/TermUI

Length of output: 2028


🏁 Script executed:

wc -l website/src/components/Playground.tsx

Repository: Karanjot786/TermUI

Length of output: 104


🏁 Script executed:

cat -n website/src/components/Playground.tsx | head -130 | tail -40

Repository: Karanjot786/TermUI

Length of output: 1590


🏁 Script executed:

rg -n "customRequire" website/src/components/Playground.tsx -B 5 -A 2

Repository: Karanjot786/TermUI

Length of output: 521


🏁 Script executed:

rg -n "code" website/src/components/Playground.tsx | head -20

Repository: Karanjot786/TermUI

Length of output: 496


🏁 Script executed:

cat -n website/src/components/Playground.tsx | head -90

Repository: Karanjot786/TermUI

Length of output: 3486


🏁 Script executed:

rg -n "interface\|type.*Props\|export.*Playground" website/src/components/Playground.tsx

Repository: Karanjot786/TermUI

Length of output: 44


🏁 Script executed:

cat -n website/src/examples.ts 2>/dev/null || cat -n website/src/examples.tsx 2>/dev/null || find website/src -name "*example*" -type f

Repository: Karanjot786/TermUI

Length of output: 7562


🏁 Script executed:

cat -n website/src/components/Playground.tsx | sed -n '240,260p'

Repository: Karanjot786/TermUI

Length of output: 1331


Isolate evaluated code from the website origin.

new Function executes editor input in the main page context, giving evaluated snippets direct access to DOM, storage, and network on the docs origin. While the whitelisted customRequire limits module imports to TermUI libraries and React, it does not prevent access to browser APIs. Use an isolated sandbox boundary (e.g., sandboxed iframe/worker + message bridge) to properly contain executed code.

🧰 Tools
🪛 OpenGrep (1.22.0)

[ERROR] 127-127: new Function() with dynamic input can execute arbitrary code. Avoid dynamic code evaluation entirely, or use a safe alternative.

(coderabbit.code-injection.new-function-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/src/components/Playground.tsx` around lines 127 - 128, The code
execution in the Function constructor at lines where `new Function` is
instantiated and `fn(...Object.values(scope))` is called executes editor input
directly in the main page context, exposing DOM, storage, and network access to
the docs origin. Refactor this by creating an isolated sandbox boundary using a
sandboxed iframe or Web Worker, then establish a message bridge to communicate
between the main Playground component and the sandbox. Move the Function
creation and execution logic to run inside the sandbox instead of the main
context, passing the termuiTranspiled code and scope data through the message
bridge, and receive execution results back through the same bridge to maintain
the component's functionality while preventing access to browser APIs and
sensitive resources.

Source: Linters/SAST tools


// Get Default Export
const AppComponent = (module.exports as any).default;
if (typeof AppComponent !== 'function') {
throw new Error('Default export must be a function/component.');
}

// We need to construct a custom App because termuijsJsx.render doesn't allow custom stdout
const element = termuijsJsx.createElement(AppComponent, null);
let rootWidget = termuijsJsx.reconcile(element);

const rootBox = new termuijsWidgets.Box({
flexDirection: 'column',
width: '100%',
height: '100%',
});
rootBox.addChild(rootWidget);

// Create a mock WriteStream that pipes to xterm
const mockStdout = Object.assign(new termuijsCore.EventEmitter(), {
write: (chunk: string) => {
if (!unmounted && xtermRef.current) {
xtermRef.current.write(chunk);
}
return true;
},
columns: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
isTTY: true,
});

// Create a mock ReadStream for stdin
const mockStdin = Object.assign(new termuijsCore.EventEmitter(), {
isTTY: true,
setRawMode: () => {},
resume: () => {},
pause: () => {},
});

// Listen for xterm inputs to feed into stdin
if (xtermRef.current) {
dataDisposable = xtermRef.current.onData(data => {
if (!unmounted) {
mockStdin.emit('data', Buffer.from(data));
}
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const app = new termuijsCore.App(rootBox, {
fullscreen: true,
stdout: mockStdout as any,
stdin: mockStdin as any,
mouse: true,
skipFallback: true,
});

// Mount and start
termuijsJsx.setCurrentApp(app);
app.mount().catch(err => {
console.error("Mount error:", err);
});
termuiAppRef.current = app;

// Handle re-renders similarly to standard termuijsJsx.render
termuijsJsx.setRequestRender(() => {
const instances = (globalThis as any).__termuijs_instances;
const rootInstance = instances?.get(rootWidget);
let newRoot;
if (rootInstance) {
newRoot = termuijsJsx.reRenderComponent(rootInstance);
} else {
newRoot = termuijsJsx.reconcile(element);
}
rootBox.clearChildren();
rootBox.addChild(newRoot);
rootBox.markDirty();
rootWidget = newRoot;
app.screen.invalidate();
app.requestRender();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

setError(null);

} catch (err: any) {
console.error(err);
setError(err.message || String(err));
}

return () => {
if (dataDisposable) {
dataDisposable.dispose();
}
if (termuiAppRef.current) {
try {
termuiAppRef.current.unmount();
} catch (e) { }
}
try {
termuijsJsx.unmountAll();
} catch (e) { }
try {
termuijsJsx.setCurrentApp(null as any);
termuijsJsx.setRequestRender(null as any);
} catch (e) { }
unmounted = true;
};
}, [code]);

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', backgroundColor: '#0a0a0a', color: '#e5e5e5', fontFamily: 'sans-serif' }}>
<header style={{ padding: '1rem', borderBottom: '1px solid #333', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 'bold' }}>TermUI Playground</h1>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<select
value={activeExample}
onChange={handleExampleChange}
style={{ padding: '0.5rem', backgroundColor: '#1f1f1f', color: '#e5e5e5', border: '1px solid #333', borderRadius: '4px' }}
>
{Object.entries(EXAMPLES).map(([key, ex]) => (
<option key={key} value={key}>{ex.name}</option>
))}
</select>
<button onClick={handleCopy} style={{ padding: '0.5rem 1rem', background: '#333', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>Copy Code</button>
<button onClick={handleInstall} style={{ padding: '0.5rem 1rem', background: '#007acc', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>Install Example</button>
</div>
</header>

<div style={{ display: 'flex', flexGrow: 1, overflow: 'hidden' }}>
<div style={{ width: '50%', borderRight: '1px solid #333', overflowY: 'auto', backgroundColor: '#1e1e1e' }}>
<Editor
value={code}
onValueChange={setCode}
highlight={c => Prism.highlight(c, Prism.languages.tsx, 'tsx')}
padding={16}
style={{
fontFamily: '"Fira Code", "Menlo", "Monaco", "Consolas", monospace',
fontSize: 14,
minHeight: '100%',
}}
/>
</div>

<div style={{ width: '50%', backgroundColor: '#000', padding: '2rem', display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'hidden' }}>
<div style={{ width: '100%', height: '100%', backgroundColor: '#0c0c0c', border: '1px solid #333', padding: '1rem', borderRadius: '8px', boxShadow: '0 10px 30px rgba(0,0,0,0.5)', display: 'flex', flexDirection: 'column' }}>
{error && (
<pre style={{ color: '#ff5555', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxWidth: '80ch', marginBottom: '1rem' }}>
{error}
</pre>
)}
<div style={{ flexGrow: 1, position: 'relative' }} ref={terminalContainerRef} />
</div>
</div>
</div>
</div>
);
}
Loading
Loading