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
139 changes: 121 additions & 18 deletions examples/process-monitor/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** @jsxImportSource @termuijs/jsx */
import { renderApp, useState, useEffect, useRef, useKeymap } from '@termuijs/jsx';
import { Table, LineChart, BarChart, type TableColumn, type TableRow, type BarGroup } from '@termuijs/widgets';
import { renderApp, useState, useEffect, useRef, useKeymap, useInput } from '@termuijs/jsx';
import { Table, LineChart, BarChart, TextInput, type TableColumn, type TableRow, type BarGroup } from '@termuijs/widgets';
import { useCpu, useMemory, useTopProcesses, useSystemInfo } from '@termuijs/data';
import type { KeyEvent } from '@termuijs/core';

declare module '@termuijs/jsx' {
export namespace JSX {
Expand Down Expand Up @@ -81,13 +82,67 @@ function BarChartComponent({ data, style, options }: { data: BarGroup[]; style?:
return chartRef.current;
}

// ── TextInput JSX wrapper ────────────────────────────
function TextInputJSX({ value, onChange, placeholder, isFocused }: {
value: string;
onChange: (val: string) => void;
placeholder?: string;
isFocused: boolean;
}) {
const ref = useRef<TextInput | null>(null);
if (!ref.current) {
ref.current = new TextInput({ width: 30, height: 1 }, { placeholder, onChange });
}

if (ref.current.value !== value) {
ref.current.value = value;
}

ref.current.isFocused = isFocused;

useInput((key: string, event: KeyEvent) => {
if (!isFocused) return;
if (!ref.current) return;

switch (key) {
case 'backspace': ref.current.deleteBack(); break;
case 'delete': ref.current.deleteForward(); break;
case 'left': ref.current.moveCursorLeft(); break;
case 'right': ref.current.moveCursorRight(); break;
case 'home': ref.current.moveCursorHome(); break;
case 'end': ref.current.moveCursorEnd(); break;
default:
if (key && key.length === 1 && !event.ctrl && !event.alt) {
ref.current.insertChar(key);
}
}
});

return ref.current as any;

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 | ⚡ Quick win

Remove as any type assertion in TextInputJSX return

Line 121 violates repo TS rules (any + assertion without justification). Return a concrete type instead (e.g., make the function return TextInput and return ref.current directly).

Suggested fix
-function TextInputJSX({ value, onChange, placeholder, isFocused }: {
+function TextInputJSX({ value, onChange, placeholder, isFocused }: {
     value: string;
     onChange: (val: string) => void;
     placeholder?: string;
     isFocused: boolean;
-}) {
+}): TextInput {
@@
-    return ref.current as any;
+    return ref.current;
 }

As per coding guidelines: "**/*.{ts,tsx}: Use TypeScript strict mode. No any without an inline comment explaining why... No type assertions without an inline comment explaining why."

🤖 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 `@examples/process-monitor/src/index.tsx` at line 121, The TextInputJSX
function contains an unnecessary `as any` type assertion on the return statement
which violates repository TypeScript strict mode rules. Remove the `as any` type
assertion from the return statement and instead ensure the function has a
properly typed return value that matches the concrete type of ref.current (such
as TextInput). Update the function signature to explicitly declare the return
type rather than relying on type assertions.

Source: Coding guidelines

}

// ── Helper: build a text-based gauge bar ─────────────
function gaugeBar(percent: number, width: number): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
return `[${bar}] ${percent.toFixed(0)}%`;
Comment on lines +125 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 | ⚡ Quick win

Clamp gauge percent before building the bar

gaugeBar can crash if percent is < 0 or > 100 ('░'.repeat(empty) gets a negative count). Clamp first to keep rendering safe under noisy metric values.

Suggested fix
 function gaugeBar(percent: number, width: number): string {
-    const filled = Math.round((percent / 100) * width);
+    const safePercent = Math.max(0, Math.min(100, percent));
+    const filled = Math.round((safePercent / 100) * width);
     const empty = width - filled;
     const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
-    return `[${bar}] ${percent.toFixed(0)}%`;
+    return `[${bar}] ${safePercent.toFixed(0)}%`;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function gaugeBar(percent: number, width: number): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
return `[${bar}] ${percent.toFixed(0)}%`;
function gaugeBar(percent: number, width: number): string {
const safePercent = Math.max(0, Math.min(100, percent));
const filled = Math.round((safePercent / 100) * width);
const empty = width - filled;
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
return `[${bar}] ${safePercent.toFixed(0)}%`;
}
🤖 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 `@examples/process-monitor/src/index.tsx` around lines 125 - 129, The gaugeBar
function can receive percent values outside the 0-100 range, causing the filled
or empty calculation to be negative and crashing the repeat() calls. At the
start of the gaugeBar function, clamp the percent parameter to ensure it stays
within the valid 0 to 100 range using Math.max and Math.min before calculating
filled and empty values.

}

// ── Helper: format uptime seconds ────────────────────
function formatUptime(uptimeStr: string): string {
return uptimeStr;
}

function ProcessMonitor() {
const sys = useSystemInfo();
const cpu = useCpu(1000);
const mem = useMemory(1000);
const procs = useTopProcesses(10, 2000);
const procs = useTopProcesses(30, 2000);

const [cpuHistory, setCpuHistory] = useState<number[]>([]);
const [filterText, setFilterText] = useState('');
const [filterFocused, setFilterFocused] = useState(false);

useEffect(() => {
setCpuHistory(prev => {
Expand All @@ -98,7 +153,9 @@ function ProcessMonitor() {
}, [cpu.percent]);

useKeymap([
{ key: 'q', action: () => process.exit(0), description: 'Quit' },
{ key: 'q', action: () => { if (!filterFocused) process.exit(0); }, description: 'Quit' },
{ key: '/', action: () => setFilterFocused(true), description: 'Search' },
{ key: 'escape', action: () => { setFilterFocused(false); setFilterText(''); }, description: 'Clear search' },
]);

const memData: BarGroup[] = [
Expand All @@ -111,32 +168,53 @@ function ProcessMonitor() {
}
];

const tableRows = procs.map(p => ({
// Filter processes by name if search is active
const filteredProcs = filterText.length > 0
? procs.filter(p => p.name.toLowerCase().includes(filterText.toLowerCase()))
: procs;

const tableRows = filteredProcs.slice(0, 15).map(p => ({
pid: String(p.pid),
name: p.name.slice(0, 18),
cpu: `${p.cpu.toFixed(1)}%`,
mem: `${p.mem.toFixed(1)}%`,
user: p.user,
}));

// Compute process stats from the full list
const totalProcs = procs.length;

// CPU gauge bar for the header
const cpuGauge = gaugeBar(cpu.percent, 15);
// Memory gauge bar for the header
const memGauge = gaugeBar(mem.percent, 15);

return (
<box flexDirection="column" padding={1} gap={1} flexGrow={1}>
{/* Header */}
<box flexDirection="row" height={3} border="round" borderColor="cyan" padding={1}>
<text bold color="cyan"> 📊 TERMUI PROCESS MONITOR </text>
<spacer />
<text dim={true}>
{sys.hostname} • {sys.platform} ({sys.arch}) • Uptime: {sys.uptime}
{sys.hostname} • {sys.platform} ({sys.arch}) • Uptime: {formatUptime(sys.uptime)}
</text>
</box>

{/* System Summary Bar */}
<box flexDirection="row" height={3} border="single" borderColor="magenta" padding={1} gap={2}>
<text bold color="green"> CPU: {cpuGauge} </text>
<text bold color="yellow"> RAM: {mem.used}/{mem.total} ({mem.percent.toFixed(0)}%) </text>
<text bold color="blue"> Procs: {totalProcs} </text>
<text bold color="cyan"> Up: {formatUptime(sys.uptime)} </text>
</box>

{/* Main Area */}
<box flexDirection="row" flexGrow={1} gap={1}>
{/* Left Panel: CPU Line Chart & Memory status */}
{/* Left Panel: CPU Line Chart & Memory status & Process Stats */}
<box flexDirection="column" width={40} gap={1}>
<card title="CPU Load Trend" borderColor="green" flexGrow={1}>
<box flexDirection="column" flexGrow={1}>
<text bold color="green">Load: {cpu.percent.toFixed(1)}%</text>
<text bold color="green">Load: {cpu.percent.toFixed(1)}% ({cpu.count} cores @ {cpu.speed}MHz)</text>
<LineChartComponent
data={cpuHistory}
style={{ flexGrow: 1 }}
Expand All @@ -162,22 +240,47 @@ function ProcessMonitor() {
/>
</box>
</card>

{/* Process Statistics Panel */}
<card title="Process Stats" borderColor="magenta" height={6}>
<box flexDirection="column">
<text bold color="magenta"> Total Processes: {totalProcs}</text>
<text color="green"> CPU Model: {cpu.model.slice(0, 30)}</text>
<text color="cyan"> Load Avg: {cpu.loadAvg.map(l => l.toFixed(2)).join(', ')}</text>
</box>
</card>
</box>

{/* Right Panel: Top Processes */}
<card title="Top Processes" borderColor="blue" flexGrow={1}>
<TableComponent
columns={columns}
rows={tableRows}
style={{ flexGrow: 1 }}
options={{ stripe: true }}
/>
</card>
{/* Right Panel: Search + Top Processes */}
<box flexDirection="column" flexGrow={1} gap={1}>
{/* Search/Filter Bar */}
<box flexDirection="row" height={3} border="single" borderColor={filterFocused ? 'green' : 'white'} padding={1}>
<text bold color={filterFocused ? 'green' : 'white'}> 🔍 Filter: </text>
<TextInputJSX
value={filterText}
onChange={setFilterText}
placeholder="Type to filter processes..."
isFocused={filterFocused}
/>
{filterText.length > 0 && (
<text dim={true}> ({filteredProcs.length} matches)</text>
)}
</box>

<card title={filterText ? `Processes matching "${filterText}"` : "Top Processes"} borderColor="blue" flexGrow={1}>
<TableComponent
columns={columns}
rows={tableRows}
style={{ flexGrow: 1 }}
options={{ stripe: true }}
/>
</card>
</box>
</box>

{/* Footer */}
<box flexDirection="row" height={1}>
<text dim={true}>Controls: Press [q] to exit</text>
<text dim={true}>Controls: [q] Quit [/] Search [Esc] Clear search</text>
<spacer />
<text dim={true}>Refreshed automatically</text>
</box>
Expand Down
Loading
Loading