From 60596e790ffb047136c16fa93008b2042d6122d5 Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Thu, 2 Apr 2026 09:13:25 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20state=20persistence=20=E2=80=94=20Termi?= =?UTF-8?q?nal=20+=20Explorer=20survive=20navigation=20(Zustand=20stores)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.py | 113 ++++++ frontend-react/src/pages/ExplorerPage.tsx | 32 +- frontend-react/src/pages/TerminalPage.tsx | 458 +++++++++++----------- frontend-react/src/stores/app.ts | 12 + frontend-react/src/stores/terminal.ts | 30 ++ 5 files changed, 401 insertions(+), 244 deletions(-) create mode 100644 frontend-react/src/stores/terminal.ts diff --git a/backend/app.py b/backend/app.py index 81077ce..841fd09 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1096,6 +1096,93 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))): return {"status":"error","message":str(e)[:500]} # ── OCI CLI Terminal ───────────────────────────────────────────────────────── +# OCID resource type → OCI CLI get command mapping +_OCID_CMD_MAP = { + "instance": "oci compute instance get --instance-id", + "image": "oci compute image get --image-id", + "vcn": "oci network vcn get --vcn-id", + "subnet": "oci network subnet get --subnet-id", + "securitylist": "oci network security-list get --security-list-id", + "routetable": "oci network route-table get --rt-id", + "internetgateway": "oci network internet-gateway get --ig-id", + "natgateway": "oci network nat-gateway get --nat-gateway-id", + "servicegateway": "oci network service-gateway get --service-gateway-id", + "drg": "oci network drg get --drg-id", + "drgattachment": "oci network drg-attachment get --drg-attachment-id", + "localpeeringgateway": "oci network local-peering-gateway get --local-peering-gateway-id", + "networksecuritygroup": "oci network nsg get --nsg-id", + "vnic": "oci network vnic get --vnic-id", + "privateip": "oci network private-ip get --private-ip-id", + "publicip": "oci network public-ip get --public-ip-id", + "dhcpoptions": "oci network dhcp-options get --dhcp-id", + "cpe": "oci network cpe get --cpe-id", + "ipsecconnection": "oci network ip-sec-connection get --ipsc-id", + "volume": "oci bv volume get --volume-id", + "bootvolume": "oci bv boot-volume get --boot-volume-id", + "volumebackup": "oci bv backup get --volume-backup-id", + "bootvolumereplica": "oci bv boot-volume-replica get --boot-volume-replica-id", + "volumegroup": "oci bv volume-group get --volume-group-id", + "bucket": "oci os bucket get --bucket-name", + "compartment": "oci iam compartment get --compartment-id", + "tenancy": "oci iam tenancy get --tenancy-id", + "user": "oci iam user get --user-id", + "group": "oci iam group get --group-id", + "policy": "oci iam policy get --policy-id", + "dynamicgroup": "oci iam dynamic-group get --dynamic-group-id", + "identityprovider": "oci iam identity-provider get --identity-provider-id", + "autonomousdatabase": "oci db autonomous-database get --autonomous-database-id", + "dbsystem": "oci db system get --db-system-id", + "database": "oci db database get --database-id", + "dbhome": "oci db db-home get --db-home-id", + "dbnode": "oci db node get --db-node-id", + "mysqldbsystem": "oci mysql db-system get --db-system-id", + "loadbalancer": "oci lb load-balancer get --load-balancer-id", + "networkloadbalancer": "oci nlb network-load-balancer get --network-load-balancer-id", + "certificate": "oci certs-mgmt certificate get --certificate-id", + "vault": "oci kms management vault get --vault-id", + "key": "oci kms management key get --key-id", + "secret": "oci vault secret get --secret-id", + "fnapp": "oci fn application get --application-id", + "fnfunc": "oci fn function get --function-id", + "apigateway": "oci api-gateway gateway get --gateway-id", + "containerinstance": "oci container-instances container-instance get --container-instance-id", + "cluster": "oci ce cluster get --cluster-id", + "nodepool": "oci ce node-pool get --node-pool-id", + "filesystem": "oci fs file-system get --file-system-id", + "mounttarget": "oci fs mount-target get --mount-target-id", + "alarm": "oci monitoring alarm get --alarm-id", + "loggroup": "oci logging log-group get --log-group-id", + "log": "oci logging log get --log-id", + "topic": "oci ons topic get --topic-id", + "subscription": "oci ons subscription get --subscription-id", + "stream": "oci streaming stream get --stream-id", + "streampool": "oci streaming stream-pool get --stream-pool-id", + "dnszone": "oci dns zone get --zone-name-or-id", + "networkfirewall": "oci network-firewall network-firewall get --network-firewall-id", + "networkfirewallpolicy": "oci network-firewall network-firewall-policy get --network-firewall-policy-id", + "waaspolicy": "oci waas waas-policy get --waas-policy-id", + "instancepool": "oci compute-management instance-pool get --instance-pool-id", + "instanceconfiguration": "oci compute-management instance-configuration get --instance-configuration-id", + "capacityreservation": "oci compute capacity-reservation get --capacity-reservation-id", + "dedicatedvmhost": "oci compute dedicated-vm-host get --dedicated-vm-host-id", +} + +def _resolve_ocid_command(ocid: str) -> str | None: + """Parse an OCID and return the corresponding OCI CLI get command.""" + parts = ocid.strip().split(".") + if len(parts) < 4 or parts[0] != "ocid1": + return None + resource_type = parts[1].lower() + # Extract region from OCID if present (4th part, non-empty for regional resources) + region = parts[3] if len(parts) > 3 and parts[3] and parts[3] != parts[2] else None + cmd_template = _OCID_CMD_MAP.get(resource_type) + if not cmd_template: + return None + cmd = f"{cmd_template} {ocid}" + if region: + cmd += f" --region {region}" + return cmd + _TERMINAL_BLOCKED_PATTERNS = re.compile( r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)', re.IGNORECASE @@ -1111,7 +1198,32 @@ async def terminal_execute(req: dict, u=Depends(current_user)): if not command: raise HTTPException(400, "Comando vazio") _verify_config_access("oci", oci_config_id, u) + # Auto-lookup: OCID pasted directly → resolve to oci get command + resolved_cmd = None + if command.startswith("ocid1."): + resolved_cmd = _resolve_ocid_command(command) + if resolved_cmd: + command = resolved_cmd + else: + raise HTTPException(400, f"Tipo de recurso não reconhecido no OCID: {command.split('.')[1]}") + # find → search resources by display name using OCI Search service + elif command.startswith("find "): + query = command[5:].strip() + if not query: + raise HTTPException(400, "Uso: find | find %partial% | find 10.0.1.5") + # Detect IP address → search by IP + if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query): + search_query = f"query privateip resources where ipAddress = '{query}'" + elif '%' in query: + # Partial match with LIKE + search_query = f"query all resources where displayName =~ '{query}'" + else: + search_query = f"query all resources where displayName = '{query}'" + resolved_cmd = f"oci search resource structured-search --query-text \"{search_query}\" --limit 20" + command = resolved_cmd # Security: only allow 'oci' commands + if command == "oci": + command = "oci --help" if not command.startswith("oci "): raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list") if _TERMINAL_BLOCKED_PATTERNS.search(command): @@ -1141,6 +1253,7 @@ async def terminal_execute(req: dict, u=Depends(current_user)): "exit_code": exit_code, "output": output[:50000], "error": error[:5000] if exit_code != 0 else "", + "resolved_cmd": resolved_cmd, } except asyncio.TimeoutError: with db() as c: diff --git a/frontend-react/src/pages/ExplorerPage.tsx b/frontend-react/src/pages/ExplorerPage.tsx index 92bed53..b05bc6c 100644 --- a/frontend-react/src/pages/ExplorerPage.tsx +++ b/frontend-react/src/pages/ExplorerPage.tsx @@ -890,18 +890,21 @@ export default function ExplorerPage() { /* ── State (from store for user selections) ──────────────────────────── */ const selectedConfig = store.expSelectedConfig; const setSelectedConfig = store.setExpSelectedConfig; - const [compartmentTree, setCompartmentTree] = useState([]); + const compartmentTree = store.expCompartmentTree as TreeNode[]; + const setCompartmentTree = store.setExpCompartmentTree; const selectedCompartment = store.expSelectedCompartment; const setSelectedCompartment = store.setExpSelectedCompartment; const selectedRegions = store.expSelectedRegions; const setSelectedRegions = store.setExpSelectedRegions; - const [availableRegions, setAvailableRegions] = useState([]); + const availableRegions = store.expAvailableRegions as Region[]; + const setAvailableRegions = store.setExpAvailableRegions; const selectedCategory = store.expSelectedCategory; const setSelectedCategory = store.setExpSelectedCategory; const selectedResourceType = store.expSelectedResourceType; const setSelectedResourceType = store.setExpSelectedResourceType; const [resourceData, setResourceData] = useState(null); - const [counts, setCounts] = useState>({}); + const counts = store.expCounts; + const setCounts = store.setExpCounts; const [loading, setLoading] = useState(false); const [countsRefreshing, setCountsRefreshing] = useState(false); const [treeOpen, setTreeOpen] = useState>({}); @@ -913,6 +916,7 @@ export default function ExplorerPage() { const containerRef = useRef(null); const draggingRef = useRef(false); + const prevConfigRef = useRef(selectedConfig); /* ── Pick first config ──────────────────────────────────────────────────── */ useEffect(() => { @@ -922,12 +926,18 @@ export default function ExplorerPage() { /* ── Load tree + regions on config change ───────────────────────────────── */ useEffect(() => { if (!selectedConfig) return; + const configChanged = prevConfigRef.current !== selectedConfig; + prevConfigRef.current = selectedConfig; + // Skip full reload if returning to same config (page re-mount) + if (!configChanged && selectedCompartment && compartmentTree.length > 0) return; let cancelled = false; setTreeLoading(true); setCompartmentTree([]); - setSelectedCompartment(''); - setCounts({}); - setResourceData(null); + if (configChanged) { + setSelectedCompartment(''); + setCounts({}); + setResourceData(null); + } (async () => { try { @@ -939,16 +949,18 @@ export default function ExplorerPage() { if (Array.isArray(flat)) { const tree = buildTree(flat); setCompartmentTree(tree); - // Auto-select first node and expand it - if (tree.length > 0) { + // Auto-select first node only if not already selected + if (tree.length > 0 && !selectedCompartment) { setSelectedCompartment(tree[0].id); setTreeOpen((prev) => ({ ...prev, [tree[0].id]: true })); } } if (Array.isArray(regs)) { setAvailableRegions(regs); - const home = regs.find((r) => r.is_home); - setSelectedRegions(home ? [home.name] : regs.length > 0 ? [regs[0].name] : []); + if (selectedRegions.length === 0) { + const home = regs.find((r) => r.is_home); + setSelectedRegions(home ? [home.name] : regs.length > 0 ? [regs[0].name] : []); + } } } catch { /* ignore */ diff --git a/frontend-react/src/pages/TerminalPage.tsx b/frontend-react/src/pages/TerminalPage.tsx index ff92b0c..176c48a 100644 --- a/frontend-react/src/pages/TerminalPage.tsx +++ b/frontend-react/src/pages/TerminalPage.tsx @@ -1,30 +1,31 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Terminal, Loader2, ChevronDown } from 'lucide-react'; +import { Terminal, Loader2, ChevronDown, HelpCircle, X } from 'lucide-react'; import client from '@/api/client'; import { useI18n } from '@/i18n'; +import { useTerminalStore } from '@/stores/terminal'; -interface TermLine { - type: 'input' | 'output' | 'error' | 'info'; - text: string; -} +interface OciCfg { id: string; tenancy_name: string; } -interface OciCfg { - id: string; - tenancy_name: string; -} +const FONT = "'JetBrains Mono','Fira Code','Cascadia Code','SF Mono','Consolas','Liberation Mono',monospace"; +const BG = '#0c0c0c'; +const FG = '#cccccc'; +const GREEN = '#16c60c'; +const BLUE = '#3b78ff'; +const RED = '#e74856'; +const DIM = '#767676'; +const YELLOW = '#f9f1a5'; export default function TerminalPage() { const { t } = useI18n(); + const { lines, selectedCfg, cmdHistory, addLine, clearLines, setSelectedCfg, setCmdHistory, addToHistory } = useTerminalStore(); const [configs, setConfigs] = useState([]); - const [selectedCfg, setSelectedCfg] = useState(''); const [command, setCommand] = useState(''); - const [lines, setLines] = useState([]); const [running, setRunning] = useState(false); - const [cmdHistory, setCmdHistory] = useState([]); const [histIdx, setHistIdx] = useState(-1); const [suggestions, setSuggestions] = useState([]); const [tabCount, setTabCount] = useState(0); - const outputRef = useRef(null); + const [showHelp, setShowHelp] = useState(false); + const containerRef = useRef(null); const inputRef = useRef(null); useEffect(() => { @@ -34,63 +35,67 @@ export default function TerminalPage() { }).catch(() => {}); }, []); - useEffect(() => { - if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; - }, [lines]); + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + if (containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight; + }); + }, []); + + useEffect(scrollToBottom, [lines, running, suggestions, scrollToBottom]); useEffect(() => { - if (selectedCfg) { + if (selectedCfg && cmdHistory.length === 0) { client.get(`/terminal/history?oci_config_id=${selectedCfg}&limit=100`).then((data: any) => { setCmdHistory(data.map((h: any) => h.command).reverse()); }).catch(() => {}); } }, [selectedCfg]); - const addLine = useCallback((type: TermLine['type'], text: string) => { - setLines(prev => [...prev, { type, text }]); - }, []); + + const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || 'oci'; + const PS1_user = `agent@${cfgName}`; const execute = useCallback(async () => { const cmd = command.trim(); if (!cmd) return; if (!selectedCfg) { addLine('error', t('term.selectConfig')); return; } - - // Built-in commands - if (cmd === 'clear') { setLines([]); setCommand(''); return; } + if (cmd === 'clear') { clearLines(); setCommand(''); return; } if (cmd === 'history') { - addLine('input', `$ ${cmd}`); - cmdHistory.forEach((h, i) => addLine('output', ` ${i + 1} ${h}`)); - setCommand(''); - return; + addLine('input', cmd); + cmdHistory.forEach((h, i) => addLine('output', ` ${String(i + 1).padStart(4)} ${h}`)); + setCommand(''); return; } if (cmd === 'help') { - addLine('input', `$ ${cmd}`); - addLine('info', 'OCI CLI Terminal — Comandos disponíveis:'); - addLine('info', ' oci [options] Executar comando OCI CLI'); - addLine('info', ' Tab Autocomplete'); - addLine('info', ' ↑ / ↓ Navegar histórico'); - addLine('info', ' clear Limpar terminal'); - addLine('info', ' history Ver histórico'); - addLine('info', ' help Este menu'); - setCommand(''); - return; + addLine('input', cmd); + addLine('info', 'Commands:'); + addLine('info', ' oci [opts] Run OCI CLI command'); + addLine('info', ' ocid1.instance.oc1.xxx... Auto-lookup resource by OCID'); + addLine('info', ' find Search resource by display name'); + addLine('info', ' find %partial% Search with partial match'); + addLine('info', ' find 10.0.1.5 Search by private IP'); + addLine('info', ' clear Clear screen'); + addLine('info', ' history Command history'); + addLine('info', ' help This message'); + addLine('info', ''); + addLine('info', 'Shortcuts:'); + addLine('info', ' Tab Autocomplete'); + addLine('info', ' ↑ / ↓ Browse history'); + addLine('info', ' Ctrl+L Clear screen'); + setCommand(''); return; } - addLine('input', `$ ${cmd}`); + addLine('input', cmd); setCommand(''); - setCmdHistory(prev => [...prev, cmd]); + addToHistory(cmd); setHistIdx(-1); setSuggestions([]); setRunning(true); try { - const resp = await client.post('/terminal/execute', { - oci_config_id: selectedCfg, - command: cmd, - }) as any; + const resp = await client.post('/terminal/execute', { oci_config_id: selectedCfg, command: cmd }) as any; + if (resp.resolved_cmd) addLine('info', `→ ${resp.resolved_cmd}`); if (resp.output) addLine('output', resp.output); if (resp.error) addLine('error', resp.error); - if (!resp.output && !resp.error) addLine('info', '(no output)'); } catch (err: any) { addLine('error', err?.message || err?.detail || String(err)); } finally { @@ -106,230 +111,215 @@ export default function TerminalPage() { const sugs: string[] = resp.suggestions || []; if (sugs.length === 0) return; if (sugs.length === 1) { - // Single match — auto-complete const parts = command.trimEnd().split(' '); - if (command.endsWith(' ')) { - setCommand(command + sugs[0] + ' '); - } else { - parts[parts.length - 1] = sugs[0]; - setCommand(parts.join(' ') + ' '); - } - setSuggestions([]); - setTabCount(0); + if (command.endsWith(' ')) setCommand(command + sugs[0] + ' '); + else { parts[parts.length - 1] = sugs[0]; setCommand(parts.join(' ') + ' '); } + setSuggestions([]); setTabCount(0); } else { - // Multiple matches — show them - if (tabCount > 0) { - // Second Tab — display all - addLine('input', `$ ${command}`); - addLine('info', sugs.join(' ')); - } - setSuggestions(sugs); - setTabCount(prev => prev + 1); - // Find common prefix + if (tabCount > 0) { addLine('input', command); addLine('info', sugs.join(' ')); } + setSuggestions(sugs); setTabCount(prev => prev + 1); let common = sugs[0]; - for (const s of sugs) { - while (!s.startsWith(common)) common = common.slice(0, -1); - } + for (const s of sugs) { while (!s.startsWith(common)) common = common.slice(0, -1); } if (common) { const parts = command.trimEnd().split(' '); - const last = parts[parts.length - 1]; - if (command.endsWith(' ')) { - if (common.length > 0) setCommand(command + common); - } else if (common.length > last.length) { - parts[parts.length - 1] = common; - setCommand(parts.join(' ')); + if (command.endsWith(' ')) { if (common.length > 0) setCommand(command + common); } + else if (common.length > (parts[parts.length - 1] || '').length) { + parts[parts.length - 1] = common; setCommand(parts.join(' ')); } } } - } catch { - // ignore - } + } catch { /* ignore */ } }, [command, running, tabCount, addLine]); const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Tab') { - e.preventDefault(); - handleTab(); - return; - } - setTabCount(0); - setSuggestions([]); - - if (e.key === 'Enter' && !running) { - execute(); - } else if (e.key === 'ArrowUp') { + if (e.key === 'Tab') { e.preventDefault(); handleTab(); return; } + setTabCount(0); setSuggestions([]); + if (e.key === 'Enter' && !running) execute(); + else if (e.key === 'ArrowUp') { e.preventDefault(); if (cmdHistory.length > 0) { - const newIdx = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx; - setHistIdx(newIdx); - setCommand(cmdHistory[cmdHistory.length - 1 - newIdx] || ''); + const n = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx; + setHistIdx(n); setCommand(cmdHistory[cmdHistory.length - 1 - n] || ''); } } else if (e.key === 'ArrowDown') { e.preventDefault(); - if (histIdx > 0) { - setHistIdx(histIdx - 1); - setCommand(cmdHistory[cmdHistory.length - histIdx] || ''); - } else { - setHistIdx(-1); - setCommand(''); - } - } else if (e.key === 'l' && e.ctrlKey) { - e.preventDefault(); - setLines([]); - } else if (e.key === 'c' && e.ctrlKey && running) { - addLine('error', '^C'); - } + if (histIdx > 0) { setHistIdx(histIdx - 1); setCommand(cmdHistory[cmdHistory.length - histIdx] || ''); } + else { setHistIdx(-1); setCommand(''); } + } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); clearLines(); } }; - const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || ''; - const prompt = `agent@${cfgName || 'oci'}:~$ `; + const Prompt = () => ( + + {PS1_user} + : + ~ + $ + + ); return ( -
- {/* Header bar */} -
-
- - OCI CLI -
+
+ {/* Top bar — config selector */} +
+ + OCI CLI
- { setSelectedCfg(e.target.value); clearLines(); setCmdHistory([]); }} + className="pl-2.5 pr-7 py-1.5 rounded text-xs font-medium outline-none cursor-pointer appearance-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}> - {configs.map(c => ( - - ))} + {configs.map(c => )} - +
-
- - Tab: autocomplete | ↑↓: history | Ctrl+L: clear - +
+
- {/* Terminal window */} -
inputRef.current?.focus()} - > - {/* Title bar */} -
-
-
-
-
+ {/* Help panel */} + {showHelp && ( +
+
+ Terminal Commands +
- - agent@oci-cis-agent — {cfgName || 'terminal'} +
+
oci <service> <resource> <action>Execute OCI CLI command
+
ociShow OCI CLI help
+
ocid1.instance.oc1.xxx...Auto-lookup resource by OCID
+
find <name>Search resource by display name
+
find %partial%Search with partial match (LIKE)
+
find 10.0.1.5Search by private IP address
+
clearClear screen
+
historyShow command history
+
+
+ Shortcuts +
+ Tab Autocomplete + ↑↓ History + Ctrl+L Clear +
+
+
+ )} + + {/* Terminal body — single continuous scroll */} +
inputRef.current?.focus()} + style={{ + flex: 1, + overflow: 'auto', + background: BG, + padding: '8px 12px', + fontFamily: FONT, + fontSize: '13px', + lineHeight: '20px', + color: FG, + cursor: 'text', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + scrollbarWidth: 'thin', + scrollbarColor: `#333 ${BG}`, + }} + > + {/* MOTD */} + {lines.length === 0 && !running && ( + <> + Oracle Cloud Infrastructure CLI{'\n'} + Type + help + for commands, + Tab + for autocomplete{'\n\n'} + + )} + + {/* All output lines — continuous flow */} + {lines.map((line, i) => { + if (line.type === 'input') { + return {line.text}{'\n'}; + } + if (line.type === 'error') { + return {line.text}{'\n'}; + } + if (line.type === 'info') { + return {line.text}{'\n'}; + } + // output + return {line.text}{'\n'}; + })} + + {/* Autocomplete suggestions */} + {suggestions.length > 1 && !running && ( + {suggestions.join(' ')}{'\n'} + )} + + {/* Running indicator */} + {running && ( + + + {t('term.executing')}{'\n'} -
+ )} - {/* Output */} -
- {/* MOTD */} - {lines.length === 0 && ( -
-
{`
-  ___   ____ ___    ____ _     ___
- / _ \\ / ___|_ _|  / ___| |   |_ _|
-| | | | |    | |  | |   | |    | |
-| |_| | |___ | |  | |___| |___ | |
- \\___/ \\____|___|  \\____|_____|___|
-`}
- Oracle Cloud Infrastructure CLI v3.x -
- Type help for available commands, Tab for autocomplete -

-
- )} - - {lines.map((line, i) => ( -
- {line.type === 'input' && ( - {prompt.split(':')[0]}:~$ {line.text.replace(/^\$ /, '')} - )} - {line.type === 'output' && ( - {line.text} - )} - {line.type === 'error' && ( - {line.text} - )} - {line.type === 'info' && ( - {line.text} - )} -
- ))} - - {running && ( -
- - {t('term.executing')} -
- )} - - {/* Autocomplete suggestions inline */} - {suggestions.length > 1 && !running && ( -
- {suggestions.join(' ')} -
- )} -
- - {/* Input line */} -
- {prompt.split(':')[0]}: - ~ - $ - { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }} - onKeyDown={handleKeyDown} - disabled={running} - placeholder={running ? '' : ''} - autoFocus - spellCheck={false} - autoComplete="off" - autoCapitalize="off" - className="flex-1 outline-none" - style={{ - background: 'transparent', - border: 'none', - color: '#e6edf3', - fontSize: 'inherit', - fontFamily: 'inherit', - lineHeight: 'inherit', - caretColor: '#e6edf3', - padding: 0, - }} - /> - {/* Blinking cursor when empty and not running */} - {!command && !running && ( + {/* Active input line — part of the scroll, not a separate bar */} + {!running && ( + + + { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }} + onKeyDown={handleKeyDown} + autoFocus + spellCheck={false} + autoComplete="off" + autoCapitalize="off" + style={{ + background: 'transparent', + border: 'none', + outline: 'none', + color: FG, + fontSize: 'inherit', + fontFamily: 'inherit', + lineHeight: 'inherit', + padding: 0, + margin: 0, + width: `${Math.max(1, command.length + 1)}ch`, + caretColor: FG, + verticalAlign: 'baseline', + }} + /> - )} -
+ + )}