Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8d4464
Create new extension "Serial Buttons"
sevimuelli Sep 22, 2025
b2b3ee4
Merge branch 'jetkvm:dev' into dev
sevimuelli Sep 22, 2025
d8f670f
Add backend to send custom commands
sevimuelli Sep 23, 2025
67e9136
Add order buttons and response field
sevimuelli Sep 23, 2025
cfd5e7c
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons
sevimuelli Sep 24, 2025
c07ae51
Merge extensions "Serial Console" and "Serial Buttons"
sevimuelli Oct 1, 2025
c2219d1
Merge branch 'dev' into feat/custom-serial-buttons
sevimuelli Oct 1, 2025
2b6571d
Update backend to combine serial console and custom buttons
sevimuelli Oct 2, 2025
897927e
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons
sevimuelli Oct 2, 2025
4ddce3f
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons
sevimuelli Oct 3, 2025
3b14267
Update backend, implement pause function in terminal
sevimuelli Oct 9, 2025
7b9410c
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons
sevimuelli Oct 9, 2025
2ce5623
Improve normalization
sevimuelli Oct 9, 2025
e556530
Minor serial helper improvements
sevimuelli Oct 9, 2025
7c09ac3
Merge branch 'dev' into feat/custom-serial-buttons
sevimuelli Oct 9, 2025
0630a7b
Update serial console part
sevimuelli Oct 16, 2025
39e67f3
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons
sevimuelli Oct 16, 2025
668ca59
Small bug fixes
sevimuelli Oct 17, 2025
86415bc
Merge branch 'dev' into feat/custom-serial-buttons
sevimuelli Nov 1, 2025
cee8d64
Add localization
sevimuelli Nov 1, 2025
8135a38
Merge branch 'jetkvm:dev' into feat/custom-serial-buttons
sevimuelli Nov 9, 2025
d67d465
Merge branch 'dev' into feat/custom-serial-buttons
sevimuelli Dec 1, 2025
7e241b1
Fix linting error
sevimuelli Dec 2, 2025
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
321 changes: 157 additions & 164 deletions jsonrpc.go

Large diffs are not rendered by default.

358 changes: 328 additions & 30 deletions serial.go

Large diffs are not rendered by default.

656 changes: 656 additions & 0 deletions serial_console_helpers.go

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions ui/localization/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,19 +745,45 @@
"saving": "Saving…",
"search_placeholder": "Search…",
"serial_console": "Serial Console",
"serial_console_add_button": "Add Button",
"serial_console_baud_rate": "Baud Rate",
"serial_console_button_editor_command": "Command",
"serial_console_button_editor_command_placeholder": "Command to send",
"serial_console_button_editor_delete": "Delete",
"serial_console_button_editor_explanation": "When sent, the selected line ending {terminator} will be appended.",
"serial_console_button_editor_label": "Label",
"serial_console_button_editor_label_placeholder": "New Command",
"serial_console_button_editor_move_down": "Move Down",
"serial_console_button_editor_move_up": "Move Up",
"serial_console_configure_description": "Configure your serial console settings",
"serial_console_crlf_handling": "CRLF Handling",
"serial_console_data_bits": "Data Bits",
"serial_console_get_settings_error": "Failed to get serial console settings: {error}",
"serial_console_hide_settings": "Hide Settings",
"serial_console_line_ending": "Line Ending",
"serial_console_line_ending_explanation": "Character(s) sent at the end of each command",
"serial_console_local_echo": "Local Echo",
"serial_console_local_echo_description": "Show characters you type in the console",
"serial_console_normalization_mode": "Normalization Mode",
"serial_console_open_console": "Open Console",
"serial_console_parity": "Parity",
"serial_console_parity_even": "Even Parity",
"serial_console_parity_mark": "Mark Parity",
"serial_console_parity_none": "No Parity",
"serial_console_parity_odd": "Odd Parity",
"serial_console_parity_space": "Space Parity",
"serial_console_preserve_ansi": "Preserve ANSI",
"serial_console_preserve_ansi_keep": "Keep escape code",
"serial_console_preserve_ansi_strip": "Strip escape code",
"serial_console_send_custom_command": "Failed to send custom command: {command}: {error}",
"serial_console_set_settings_error": "Failed to set serial console settings to {settings}: {error}",
"serial_console_show_newline_tag": "Show newline tag",
"serial_console_show_newline_tag_hide": "Hide <LF> tag",
"serial_console_show_newline_tag_show": "Show <LF> tag",
"serial_console_show_settings": "Show Settings",
"serial_console_stop_bits": "Stop Bits",
"serial_console_tab_replacement": "Tab replacement",
"serial_console_tab_replacement_description": "Empty for no replacement",
"setting_remote_description": "Setting remote description",
"setting_remote_session_description": "Setting remote session description...",
"setting_up_connection_to_device": "Setting up connection to device...",
Expand Down
302 changes: 302 additions & 0 deletions ui/src/components/CommandInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";

import InputField from "@/components/InputField"; // your existing input component
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";

interface Hit { value: string; index: number }

// ---------- history hook ----------
function useCommandHistory(max = 300) {
const { send } = useJsonRpc();
const [items, setItems] = useState<string[]>([]);

const deleteHistory = useCallback(() => {
console.log("Deleting serial command history");
send("deleteSerialCommandHistory", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to delete serial command history: ${resp.error.data || "Unknown error"}`,
);
} else {
setItems([]);
notifications.success("Serial command history deleted");
}
});
}, [send]);

useEffect(() => {
send("getSerialCommandHistory", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get command history: ${resp.error.data || "Unknown error"}`,
);
} else if ("result" in resp) {
setItems(resp.result as string[]);
}
});
}, [send]);

const [pointer, setPointer] = useState<number>(-1); // -1 = fresh line
const [anchorPrefix, setAnchorPrefix] = useState<string | null>(null);

useEffect(() => {
if (items.length > 1) {
send("setSerialCommandHistory", { commandHistory: items }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to update command history: ${resp.error.data || "Unknown error"}`);
return;
}
});
}
}, [items, send]);

const push = useCallback((cmd: string) => {
if (!cmd.trim()) return;
setItems((prev) => {
const next = prev[prev.length - 1] === cmd ? prev : [...prev, cmd];
return next.slice(-max);
});
setPointer(-1);
setAnchorPrefix(null);
}, [max]);

const resetTraversal = useCallback(() => {
setPointer(-1);
setAnchorPrefix(null);
}, []);

const up = useCallback((draft: string) => {
const pref = anchorPrefix ?? draft;
if (anchorPrefix == null) setAnchorPrefix(pref);
let i = pointer < 0 ? items.length - 1 : pointer - 1;
for (; i >= 0; i--) {
if (items[i].startsWith(pref)) {
setPointer(i);
return items[i];
}
}
return draft;
}, [items, pointer, anchorPrefix]);

const down = useCallback((draft: string) => {
const pref = anchorPrefix ?? draft;
if (anchorPrefix == null) setAnchorPrefix(pref);
let i = pointer < 0 ? 0 : pointer + 1;
for (; i < items.length; i++) {
if (items[i].startsWith(pref)) {
setPointer(i);
return items[i];
}
}
setPointer(-1);
return draft;
}, [items, pointer, anchorPrefix]);

const search = useCallback((query: string): Hit[] => {
if (!query) return [];
const q = query.toLowerCase();
return [...items]
.map((value, index) => ({ value, index }))
.filter((x) => x.value.toLowerCase().includes(q))
.reverse(); // newest first
}, [items]);

return { push, up, down, resetTraversal, search, deleteHistory };
}

function Portal({ children }: { children: React.ReactNode }) {
if (typeof document === "undefined") {
return null;
}
return createPortal(children, document.body);
}

// ---------- reverse search popup ----------
function ReverseSearch({
open, results, sel, setSel, onPick, onClose, onDeleteHistory
}: {
open: boolean;
results: Hit[];
sel: number;
setSel: (i: number) => void;
onPick: (val: string) => void;
onClose: () => void;
onDeleteHistory: () => void;
}) {
const listRef = React.useRef<HTMLDivElement>(null);

// keep selected item in view when sel changes
useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.querySelector<HTMLDivElement>(`[data-idx="${sel}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [sel, results]);

if (!open) return null;
return (
<Portal>
<div
className="absolute bottom-12 left-0 right-0 ml-17 mr-8 mb-5 rounded-md border border-slate-600 bg-slate-900/95 p-2 shadow-lg"
role="listbox"
aria-activedescendant={`rev-opt-${sel}`}
>
<div ref={listRef} className="max-h-48 overflow-auto">
{results.length === 0 ? (
<div className="px-2 py-1 text-sm text-slate-400">No matches</div>
) : results.map((r, i) => (
<div
id={`rev-opt-${i}`}
data-idx={i}
key={`${r.index}-${i}`}
role="option"
aria-selected={i === sel}
className={clsx(
"px-2 py-1 font-mono text-sm cursor-pointer",
i === sel ? "bg-slate-700 text-white rounded" : "text-slate-200",
)}
onMouseEnter={() => setSel(i)}
onClick={() => onPick(r.value)}
>
{r.value}
</div>
))}
</div>
<div className="mt-1 flex justify-between text-s text-slate-400">
<span>↑/↓ select • Enter accept • Esc close</span>
<div>
<button className="underline mr-2" onClick={onClose}>Close</button>
<button className="underline mr-2" onClick={onDeleteHistory}>Delete history</button>
</div>
</div>
</div>
</Portal>
);
}

// ---------- main component ----------
interface CommandInputProps {
onSend: (line: string) => void; // called on Enter
storageKey?: string; // localStorage key for history
placeholder?: string; // input placeholder
className?: string; // container className
disabled?: boolean; // disable input (optional)
}

export function CommandInput({
onSend,
placeholder = "Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)",
className,
disabled,
}: CommandInputProps) {
const [cmd, setCmd] = useState("");
const [revOpen, setRevOpen] = useState(false);
const [revQuery, setRevQuery] = useState("");
const [sel, setSel] = useState(0);
const { push, up, down, resetTraversal, search, deleteHistory } = useCommandHistory();

const results = useMemo(() => search(revQuery), [revQuery, search]);

const cmdInputRef = React.useRef<HTMLInputElement>(null);

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const isMeta = e.ctrlKey || e.metaKey;

if (e.key === "Enter" && !e.shiftKey && !isMeta) {
e.preventDefault();
if (!cmd) return;
onSend(cmd);
push(cmd);
setCmd("");
resetTraversal();
setRevOpen(false);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setCmd((prev) => up(prev));
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setCmd((prev) => down(prev));
return;
}
if (isMeta && e.key.toLowerCase() === "r") {
e.preventDefault();
setRevOpen(true);
setRevQuery(cmd);
setSel(0);
return;
}
if (e.key === "Escape" && revOpen) {
e.preventDefault();
setRevOpen(false);
return;
}
};

return (
<div className={clsx("relative", className)}>
<div className="flex items-center gap-2" style={{visibility: revOpen ? "hidden" : "unset"} }>
<span className="text-xs text-slate-400 select-none">CMD</span>
<InputField
ref={cmdInputRef}
size="MD"
disabled={disabled}
value={cmd}
onChange={(e) => { setCmd(e.target.value); resetTraversal(); }}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="font-mono"
/>
</div>

{/* Reverse search controls */}
{revOpen && (
<div className="mt-[-40px]">
<div className="flex items-center gap-2 bg-[#0f172a]">
<span className="text-s text-slate-400 select-none">Search</span>
<InputField
size="MD"
autoFocus
value={revQuery}
onChange={(e) => {
setRevQuery(e.target.value);
setSel(0); // reset selection whenever the query changes
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((i) => (i + 1) % Math.max(1, results.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((i) => (i - 1 + results.length) % Math.max(1, results.length));
} else if (e.key === "Enter") {
// ...
} else if (e.key === "Escape") {
// ...
}
}}
placeholder="Type to filter history…"
className="font-mono"
/>
</div>
<ReverseSearch
open={revOpen}
results={results}
sel={sel}
setSel={setSel}
onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }}
onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}}
onDeleteHistory={deleteHistory}
/>
</div>
)}
</div>
);
};

export default CommandInput;
Loading