Merge pull request #234 from langflow-ai/tui-improvements
TUI improvements
This commit is contained in:
commit
cb361dffa9
11 changed files with 855 additions and 268 deletions
|
|
@ -50,6 +50,7 @@ services:
|
||||||
- OPENSEARCH_HOST=opensearch
|
- OPENSEARCH_HOST=opensearch
|
||||||
- LANGFLOW_URL=http://langflow:7860
|
- LANGFLOW_URL=http://langflow:7860
|
||||||
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
|
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
|
||||||
|
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
|
||||||
- LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
|
- LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
|
||||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
||||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ services:
|
||||||
- OPENSEARCH_HOST=opensearch
|
- OPENSEARCH_HOST=opensearch
|
||||||
- LANGFLOW_URL=http://langflow:7860
|
- LANGFLOW_URL=http://langflow:7860
|
||||||
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
|
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
|
||||||
|
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
|
||||||
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
|
||||||
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
|
||||||
- LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}
|
- LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ function DoclingSetupDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Then, select <span className="font-semibold text-foreground">Start Native Services</span> in the TUI. Once docling-serve is running, refresh OpenRAG.
|
Then, select <span className="font-semibold text-foreground">Start All Services</span> in the TUI. Once docling-serve is running, refresh OpenRAG.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ if _legacy_flow_id and not os.getenv("LANGFLOW_CHAT_FLOW_ID"):
|
||||||
|
|
||||||
|
|
||||||
# Langflow superuser credentials for API key generation
|
# Langflow superuser credentials for API key generation
|
||||||
|
LANGFLOW_AUTO_LOGIN = os.getenv("LANGFLOW_AUTO_LOGIN", "False").lower() in ("true", "1", "yes")
|
||||||
LANGFLOW_SUPERUSER = os.getenv("LANGFLOW_SUPERUSER")
|
LANGFLOW_SUPERUSER = os.getenv("LANGFLOW_SUPERUSER")
|
||||||
LANGFLOW_SUPERUSER_PASSWORD = os.getenv("LANGFLOW_SUPERUSER_PASSWORD")
|
LANGFLOW_SUPERUSER_PASSWORD = os.getenv("LANGFLOW_SUPERUSER_PASSWORD")
|
||||||
# Allow explicit key via environment; generation will be skipped if set
|
# Allow explicit key via environment; generation will be skipped if set
|
||||||
|
|
@ -179,7 +180,16 @@ async def generate_langflow_api_key(modify: bool = False):
|
||||||
)
|
)
|
||||||
LANGFLOW_KEY = None # Clear invalid key
|
LANGFLOW_KEY = None # Clear invalid key
|
||||||
|
|
||||||
if not LANGFLOW_SUPERUSER or not LANGFLOW_SUPERUSER_PASSWORD:
|
# Use default langflow/langflow credentials if auto-login is enabled and credentials not set
|
||||||
|
username = LANGFLOW_SUPERUSER
|
||||||
|
password = LANGFLOW_SUPERUSER_PASSWORD
|
||||||
|
|
||||||
|
if LANGFLOW_AUTO_LOGIN and (not username or not password):
|
||||||
|
logger.info("LANGFLOW_AUTO_LOGIN is enabled, using default langflow/langflow credentials")
|
||||||
|
username = username or "langflow"
|
||||||
|
password = password or "langflow"
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"LANGFLOW_SUPERUSER and LANGFLOW_SUPERUSER_PASSWORD not set, skipping API key generation"
|
"LANGFLOW_SUPERUSER and LANGFLOW_SUPERUSER_PASSWORD not set, skipping API key generation"
|
||||||
)
|
)
|
||||||
|
|
@ -197,8 +207,8 @@ async def generate_langflow_api_key(modify: bool = False):
|
||||||
f"{LANGFLOW_URL}/api/v1/login",
|
f"{LANGFLOW_URL}/api/v1/login",
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
data={
|
data={
|
||||||
"username": LANGFLOW_SUPERUSER,
|
"username": username,
|
||||||
"password": LANGFLOW_SUPERUSER_PASSWORD,
|
"password": password,
|
||||||
},
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
178
src/tui/main.py
178
src/tui/main.py
|
|
@ -32,7 +32,7 @@ class OpenRAGTUI(App):
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
background: #0f172a;
|
background: #27272a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-container {
|
#main-container {
|
||||||
|
|
@ -60,6 +60,7 @@ class OpenRAGTUI(App):
|
||||||
.button-row Button {
|
.button-row Button {
|
||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
min-width: 20;
|
min-width: 20;
|
||||||
|
border: solid #3f3f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
#config-header {
|
#config-header {
|
||||||
|
|
@ -106,6 +107,41 @@ class OpenRAGTUI(App):
|
||||||
margin: 0 0 1 1;
|
margin: 0 0 1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Password field label rows */
|
||||||
|
#config-form Horizontal {
|
||||||
|
height: auto;
|
||||||
|
align: left middle;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#config-form Horizontal Label {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password input rows */
|
||||||
|
#opensearch-password-row,
|
||||||
|
#langflow-password-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#opensearch-password-row Input,
|
||||||
|
#langflow-password-row Input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password toggle buttons */
|
||||||
|
#toggle-opensearch-password,
|
||||||
|
#toggle-langflow-password {
|
||||||
|
min-width: 8;
|
||||||
|
width: 8;
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
margin-left: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Docs path actions row */
|
/* Docs path actions row */
|
||||||
|
|
||||||
#services-content {
|
#services-content {
|
||||||
|
|
@ -182,80 +218,144 @@ class OpenRAGTUI(App):
|
||||||
padding: 1;
|
padding: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Frontend-inspired color scheme */
|
/* Modern dark theme with pink accents */
|
||||||
Static {
|
Static {
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.success {
|
Button,
|
||||||
background: #4ade80;
|
Button.-default,
|
||||||
color: #000;
|
Button.-primary,
|
||||||
|
Button.-success,
|
||||||
|
Button.-warning,
|
||||||
|
Button.-error {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #52525b !important;
|
||||||
|
text-style: none !important;
|
||||||
|
tint: transparent 0% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.error {
|
Button > *,
|
||||||
background: #ef4444;
|
Button.-default > *,
|
||||||
color: #fff;
|
Button.-primary > *,
|
||||||
|
Button.-success > *,
|
||||||
|
Button.-warning > *,
|
||||||
|
Button.-error > * {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
text-style: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.warning {
|
Button:hover,
|
||||||
background: #eab308;
|
Button.-default:hover,
|
||||||
color: #000;
|
Button.-primary:hover,
|
||||||
|
Button.-success:hover,
|
||||||
|
Button.-warning:hover,
|
||||||
|
Button.-error:hover {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #52525b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.primary {
|
Button:focus,
|
||||||
background: #2563eb;
|
Button:focus-within,
|
||||||
color: #fff;
|
Button.-active,
|
||||||
}
|
Button.-default:focus,
|
||||||
|
Button.-default:focus-within,
|
||||||
Button.default {
|
Button.-default.-active,
|
||||||
background: #475569;
|
Button.-primary:focus,
|
||||||
color: #f1f5f9;
|
Button.-primary:focus-within,
|
||||||
border: solid #64748b;
|
Button.-primary.-active,
|
||||||
|
Button.-success:focus,
|
||||||
|
Button.-success:focus-within,
|
||||||
|
Button.-success.-active,
|
||||||
|
Button.-warning:focus,
|
||||||
|
Button.-warning:focus-within,
|
||||||
|
Button.-warning.-active,
|
||||||
|
Button.-error:focus,
|
||||||
|
Button.-error:focus-within,
|
||||||
|
Button.-error.-active {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #ec4899 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
DataTable {
|
DataTable {
|
||||||
background: #1e293b;
|
background: #27272a;
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
DataTable > .datatable--header {
|
DataTable > .datatable--header {
|
||||||
background: #334155;
|
background: #3f3f46;
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
DataTable > .datatable--cursor {
|
DataTable > .datatable--cursor {
|
||||||
background: #475569;
|
background: #52525b;
|
||||||
}
|
}
|
||||||
|
|
||||||
Input {
|
Input {
|
||||||
background: #334155;
|
background: #18181b;
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
border: solid #64748b;
|
border: solid #3f3f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
Input:focus {
|
||||||
|
border: solid #ec4899;
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
Checkbox {
|
||||||
|
background: transparent;
|
||||||
|
color: #fafafa;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Checkbox > Static {
|
||||||
|
background: transparent;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
background: #27272a;
|
||||||
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
Footer {
|
Footer {
|
||||||
background: #334155;
|
background: #27272a;
|
||||||
color: #f1f5f9;
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
#runtime-status {
|
#runtime-status {
|
||||||
background: #1e293b;
|
background: #27272a;
|
||||||
border: solid #64748b;
|
border: solid #3f3f46;
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
#system-info {
|
#system-info {
|
||||||
background: #1e293b;
|
background: #27272a;
|
||||||
border: solid #64748b;
|
border: solid #3f3f46;
|
||||||
color: #f1f5f9;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
#services-table, #images-table {
|
#services-table, #images-table {
|
||||||
background: #1e293b;
|
background: #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-background: #27272a;
|
||||||
|
scrollbar-background-hover: #3f3f46;
|
||||||
|
scrollbar-background-active: #3f3f46;
|
||||||
|
scrollbar-color: #52525b;
|
||||||
|
scrollbar-color-hover: #71717a;
|
||||||
|
scrollbar-color-active: #71717a;
|
||||||
|
scrollbar-corner-color: #27272a;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,15 @@ class ContainerManager:
|
||||||
|
|
||||||
async def _run_compose_command_streaming(
|
async def _run_compose_command_streaming(
|
||||||
self, args: List[str], cpu_mode: Optional[bool] = None
|
self, args: List[str], cpu_mode: Optional[bool] = None
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[tuple[str, bool]]:
|
||||||
"""Run a compose command and yield output lines in real-time."""
|
"""Run a compose command and yield output with progress bar support.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Tuples of (message, replace_last) where replace_last indicates if the
|
||||||
|
message should replace the previous line (for progress updates)
|
||||||
|
"""
|
||||||
if not self.is_available():
|
if not self.is_available():
|
||||||
yield "No container runtime available"
|
yield ("No container runtime available", False)
|
||||||
return
|
return
|
||||||
|
|
||||||
if cpu_mode is None:
|
if cpu_mode is None:
|
||||||
|
|
@ -179,37 +184,58 @@ class ContainerManager:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
cwd=Path.cwd(),
|
cwd=Path.cwd(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simple approach: read line by line and yield each one
|
|
||||||
if process.stdout:
|
if process.stdout:
|
||||||
|
buffer = ""
|
||||||
while True:
|
while True:
|
||||||
line = await process.stdout.readline()
|
chunk = await process.stdout.read(1024)
|
||||||
if not line:
|
if not chunk:
|
||||||
|
if buffer.strip():
|
||||||
|
yield (buffer.strip(), False)
|
||||||
break
|
break
|
||||||
|
|
||||||
line_text = line.decode(errors="ignore").rstrip()
|
buffer += chunk.decode(errors="ignore")
|
||||||
if line_text:
|
|
||||||
yield line_text
|
while "\n" in buffer or "\r" in buffer:
|
||||||
|
cr_pos = buffer.find("\r")
|
||||||
|
nl_pos = buffer.find("\n")
|
||||||
|
|
||||||
|
if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
|
||||||
|
line = buffer[:cr_pos]
|
||||||
|
buffer = buffer[cr_pos + 1:]
|
||||||
|
if line.strip():
|
||||||
|
yield (line.strip(), True)
|
||||||
|
elif nl_pos != -1:
|
||||||
|
line = buffer[:nl_pos]
|
||||||
|
buffer = buffer[nl_pos + 1:]
|
||||||
|
if line.strip():
|
||||||
|
yield (line.strip(), False)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
# Wait for process to complete
|
|
||||||
await process.wait()
|
await process.wait()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield f"Command execution failed: {e}"
|
yield (f"Command execution failed: {e}", False)
|
||||||
|
|
||||||
async def _stream_compose_command(
|
async def _stream_compose_command(
|
||||||
self,
|
self,
|
||||||
args: List[str],
|
args: List[str],
|
||||||
success_flag: Dict[str, bool],
|
success_flag: Dict[str, bool],
|
||||||
cpu_mode: Optional[bool] = None,
|
cpu_mode: Optional[bool] = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[tuple[str, bool]]:
|
||||||
"""Run compose command with live output and record success/failure."""
|
"""Run compose command with live output and record success/failure.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Tuples of (message, replace_last) where replace_last indicates if the
|
||||||
|
message should replace the previous line (for progress updates)
|
||||||
|
"""
|
||||||
if not self.is_available():
|
if not self.is_available():
|
||||||
success_flag["value"] = False
|
success_flag["value"] = False
|
||||||
yield "No container runtime available"
|
yield ("No container runtime available", False)
|
||||||
return
|
return
|
||||||
|
|
||||||
if cpu_mode is None:
|
if cpu_mode is None:
|
||||||
|
|
@ -226,32 +252,52 @@ class ContainerManager:
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
success_flag["value"] = False
|
success_flag["value"] = False
|
||||||
yield f"Command execution failed: {e}"
|
yield (f"Command execution failed: {e}", False)
|
||||||
return
|
return
|
||||||
|
|
||||||
success_flag["value"] = True
|
success_flag["value"] = True
|
||||||
|
|
||||||
if process.stdout:
|
if process.stdout:
|
||||||
|
# Buffer to accumulate data for progress bar handling
|
||||||
|
buffer = ""
|
||||||
while True:
|
while True:
|
||||||
line = await process.stdout.readline()
|
chunk = await process.stdout.read(1024)
|
||||||
if not line:
|
if not chunk:
|
||||||
|
# Process any remaining buffer content
|
||||||
|
if buffer.strip():
|
||||||
|
yield (buffer.strip(), False)
|
||||||
break
|
break
|
||||||
|
|
||||||
line_text = line.decode(errors="ignore")
|
buffer += chunk.decode(errors="ignore")
|
||||||
# Compose often uses carriage returns for progress bars; normalise them
|
|
||||||
for chunk in line_text.replace("\r", "\n").split("\n"):
|
# Process complete lines or carriage return updates
|
||||||
chunk = chunk.strip()
|
while "\n" in buffer or "\r" in buffer:
|
||||||
if not chunk:
|
# Check if we have a carriage return (progress update) before newline
|
||||||
continue
|
cr_pos = buffer.find("\r")
|
||||||
yield chunk
|
nl_pos = buffer.find("\n")
|
||||||
lowered = chunk.lower()
|
|
||||||
if "error" in lowered or "failed" in lowered:
|
if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
|
||||||
success_flag["value"] = False
|
# Carriage return found - extract and yield as replaceable line
|
||||||
|
line = buffer[:cr_pos]
|
||||||
|
buffer = buffer[cr_pos + 1:]
|
||||||
|
if line.strip():
|
||||||
|
yield (line.strip(), True) # replace_last=True for progress updates
|
||||||
|
elif nl_pos != -1:
|
||||||
|
# Newline found - extract and yield as new line
|
||||||
|
line = buffer[:nl_pos]
|
||||||
|
buffer = buffer[nl_pos + 1:]
|
||||||
|
if line.strip():
|
||||||
|
lowered = line.lower()
|
||||||
|
yield (line.strip(), False) # replace_last=False for new lines
|
||||||
|
if "error" in lowered or "failed" in lowered:
|
||||||
|
success_flag["value"] = False
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
returncode = await process.wait()
|
returncode = await process.wait()
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
success_flag["value"] = False
|
success_flag["value"] = False
|
||||||
yield f"Command exited with status {returncode}"
|
yield (f"Command exited with status {returncode}", False)
|
||||||
|
|
||||||
async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]:
|
async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]:
|
||||||
"""Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
|
"""Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
|
||||||
|
|
@ -516,14 +562,14 @@ class ContainerManager:
|
||||||
if hasattr(self, '_compose_search_log'):
|
if hasattr(self, '_compose_search_log'):
|
||||||
for line in self._compose_search_log.split('\n'):
|
for line in self._compose_search_log.split('\n'):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
yield False, line
|
yield False, line, False
|
||||||
|
|
||||||
yield False, f"Final compose file: {compose_file.absolute()}"
|
yield False, f"Final compose file: {compose_file.absolute()}", False
|
||||||
if not compose_file.exists():
|
if not compose_file.exists():
|
||||||
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}"
|
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}", False
|
||||||
return
|
return
|
||||||
|
|
||||||
yield False, "Starting OpenRAG services..."
|
yield False, "Starting OpenRAG services...", False
|
||||||
|
|
||||||
missing_images: List[str] = []
|
missing_images: List[str] = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -534,30 +580,30 @@ class ContainerManager:
|
||||||
|
|
||||||
if missing_images:
|
if missing_images:
|
||||||
images_list = ", ".join(missing_images)
|
images_list = ", ".join(missing_images)
|
||||||
yield False, f"Pulling container images ({images_list})..."
|
yield False, f"Pulling container images ({images_list})...", False
|
||||||
pull_success = {"value": True}
|
pull_success = {"value": True}
|
||||||
async for line in self._stream_compose_command(
|
async for message, replace_last in self._stream_compose_command(
|
||||||
["pull"], pull_success, cpu_mode
|
["pull"], pull_success, cpu_mode
|
||||||
):
|
):
|
||||||
yield False, line
|
yield False, message, replace_last
|
||||||
if not pull_success["value"]:
|
if not pull_success["value"]:
|
||||||
yield False, "Some images failed to pull; attempting to start services anyway..."
|
yield False, "Some images failed to pull; attempting to start services anyway...", False
|
||||||
|
|
||||||
yield False, "Creating and starting containers..."
|
yield False, "Creating and starting containers...", False
|
||||||
up_success = {"value": True}
|
up_success = {"value": True}
|
||||||
async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
|
async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
|
||||||
yield False, line
|
yield False, message, replace_last
|
||||||
|
|
||||||
if up_success["value"]:
|
if up_success["value"]:
|
||||||
yield True, "Services started successfully"
|
yield True, "Services started successfully", False
|
||||||
else:
|
else:
|
||||||
yield False, "Failed to start services. See output above for details."
|
yield False, "Failed to start services. See output above for details.", False
|
||||||
|
|
||||||
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
|
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
|
||||||
"""Stop all services and yield progress updates."""
|
"""Stop all services and yield progress updates."""
|
||||||
yield False, "Stopping OpenRAG services..."
|
yield False, "Stopping OpenRAG services..."
|
||||||
|
|
||||||
success, stdout, stderr = await self._run_compose_command(["down"])
|
success, stdout, stderr = await self._run_compose_command(["stop"])
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
yield True, "Services stopped successfully"
|
yield True, "Services stopped successfully"
|
||||||
|
|
@ -581,35 +627,35 @@ class ContainerManager:
|
||||||
self, cpu_mode: bool = False
|
self, cpu_mode: bool = False
|
||||||
) -> AsyncIterator[tuple[bool, str]]:
|
) -> AsyncIterator[tuple[bool, str]]:
|
||||||
"""Upgrade services (pull latest images and restart) and yield progress updates."""
|
"""Upgrade services (pull latest images and restart) and yield progress updates."""
|
||||||
yield False, "Pulling latest images..."
|
yield False, "Pulling latest images...", False
|
||||||
|
|
||||||
# Pull latest images with streaming output
|
# Pull latest images with streaming output
|
||||||
pull_success = True
|
pull_success = True
|
||||||
async for line in self._run_compose_command_streaming(["pull"], cpu_mode):
|
async for message, replace_last in self._run_compose_command_streaming(["pull"], cpu_mode):
|
||||||
yield False, line
|
yield False, message, replace_last
|
||||||
# Check for error patterns in the output
|
# Check for error patterns in the output
|
||||||
if "error" in line.lower() or "failed" in line.lower():
|
if "error" in message.lower() or "failed" in message.lower():
|
||||||
pull_success = False
|
pull_success = False
|
||||||
|
|
||||||
if not pull_success:
|
if not pull_success:
|
||||||
yield False, "Failed to pull some images, but continuing with restart..."
|
yield False, "Failed to pull some images, but continuing with restart...", False
|
||||||
|
|
||||||
yield False, "Images updated, restarting services..."
|
yield False, "Images updated, restarting services...", False
|
||||||
|
|
||||||
# Restart with new images using streaming output
|
# Restart with new images using streaming output
|
||||||
restart_success = True
|
restart_success = True
|
||||||
async for line in self._run_compose_command_streaming(
|
async for message, replace_last in self._run_compose_command_streaming(
|
||||||
["up", "-d", "--force-recreate"], cpu_mode
|
["up", "-d", "--force-recreate"], cpu_mode
|
||||||
):
|
):
|
||||||
yield False, line
|
yield False, message, replace_last
|
||||||
# Check for error patterns in the output
|
# Check for error patterns in the output
|
||||||
if "error" in line.lower() or "failed" in line.lower():
|
if "error" in message.lower() or "failed" in message.lower():
|
||||||
restart_success = False
|
restart_success = False
|
||||||
|
|
||||||
if restart_success:
|
if restart_success:
|
||||||
yield True, "Services upgraded and restarted successfully"
|
yield True, "Services upgraded and restarted successfully", False
|
||||||
else:
|
else:
|
||||||
yield False, "Some errors occurred during service restart"
|
yield False, "Some errors occurred during service restart", False
|
||||||
|
|
||||||
async def reset_services(self) -> AsyncIterator[tuple[bool, str]]:
|
async def reset_services(self) -> AsyncIterator[tuple[bool, str]]:
|
||||||
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""
|
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,17 @@ class EnvManager:
|
||||||
if not self.config.langflow_secret_key:
|
if not self.config.langflow_secret_key:
|
||||||
self.config.langflow_secret_key = self.generate_langflow_secret_key()
|
self.config.langflow_secret_key = self.generate_langflow_secret_key()
|
||||||
|
|
||||||
|
# Configure autologin based on whether password is set
|
||||||
if not self.config.langflow_superuser_password:
|
if not self.config.langflow_superuser_password:
|
||||||
self.config.langflow_superuser_password = self.generate_secure_password()
|
# If no password is set, enable autologin mode
|
||||||
|
self.config.langflow_auto_login = "True"
|
||||||
|
self.config.langflow_new_user_is_active = "True"
|
||||||
|
self.config.langflow_enable_superuser_cli = "True"
|
||||||
|
else:
|
||||||
|
# If password is set, disable autologin mode
|
||||||
|
self.config.langflow_auto_login = "False"
|
||||||
|
self.config.langflow_new_user_is_active = "False"
|
||||||
|
self.config.langflow_enable_superuser_cli = "False"
|
||||||
|
|
||||||
def validate_config(self, mode: str = "full") -> bool:
|
def validate_config(self, mode: str = "full") -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -183,10 +192,7 @@ class EnvManager:
|
||||||
|
|
||||||
# Langflow secret key is auto-generated; no user input required
|
# Langflow secret key is auto-generated; no user input required
|
||||||
|
|
||||||
if not validate_non_empty(self.config.langflow_superuser_password):
|
# Langflow password is now optional - if not provided, autologin mode will be enabled
|
||||||
self.config.validation_errors["langflow_superuser_password"] = (
|
|
||||||
"Langflow superuser password is required"
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode == "full":
|
if mode == "full":
|
||||||
# Validate OAuth settings if provided
|
# Validate OAuth settings if provided
|
||||||
|
|
@ -249,10 +255,12 @@ class EnvManager:
|
||||||
# Core settings
|
# Core settings
|
||||||
f.write("# Core settings\n")
|
f.write("# Core settings\n")
|
||||||
f.write(f"LANGFLOW_SECRET_KEY={self._quote_env_value(self.config.langflow_secret_key)}\n")
|
f.write(f"LANGFLOW_SECRET_KEY={self._quote_env_value(self.config.langflow_secret_key)}\n")
|
||||||
f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n")
|
# Only write LANGFLOW_SUPERUSER and password if password is set
|
||||||
f.write(
|
if self.config.langflow_superuser_password:
|
||||||
f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n"
|
f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n")
|
||||||
)
|
f.write(
|
||||||
|
f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n"
|
||||||
|
)
|
||||||
f.write(f"LANGFLOW_CHAT_FLOW_ID={self._quote_env_value(self.config.langflow_chat_flow_id)}\n")
|
f.write(f"LANGFLOW_CHAT_FLOW_ID={self._quote_env_value(self.config.langflow_chat_flow_id)}\n")
|
||||||
f.write(
|
f.write(
|
||||||
f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n"
|
f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Configuration screen for OpenRAG TUI."""
|
"""Configuration screen for OpenRAG TUI."""
|
||||||
|
|
||||||
|
import re
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
|
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
|
|
@ -12,6 +13,7 @@ from textual.widgets import (
|
||||||
Label,
|
Label,
|
||||||
TabbedContent,
|
TabbedContent,
|
||||||
TabPane,
|
TabPane,
|
||||||
|
Checkbox,
|
||||||
)
|
)
|
||||||
from textual.validation import ValidationResult, Validator
|
from textual.validation import ValidationResult, Validator
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
@ -50,6 +52,40 @@ class DocumentsPathValidator(Validator):
|
||||||
return self.failure(error_msg)
|
return self.failure(error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordValidator(Validator):
|
||||||
|
"""Validator for OpenSearch admin password."""
|
||||||
|
|
||||||
|
def validate(self, value: str) -> ValidationResult:
|
||||||
|
# Allow empty value (will be auto-generated)
|
||||||
|
if not value:
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
# Minimum length: 8 characters
|
||||||
|
if len(value) < 8:
|
||||||
|
return self.failure("Password must be at least 8 characters long")
|
||||||
|
|
||||||
|
# Check for required character types
|
||||||
|
has_uppercase = bool(re.search(r"[A-Z]", value))
|
||||||
|
has_lowercase = bool(re.search(r"[a-z]", value))
|
||||||
|
has_digit = bool(re.search(r"[0-9]", value))
|
||||||
|
has_special = bool(re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?]", value))
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if not has_uppercase:
|
||||||
|
missing.append("uppercase letter")
|
||||||
|
if not has_lowercase:
|
||||||
|
missing.append("lowercase letter")
|
||||||
|
if not has_digit:
|
||||||
|
missing.append("digit")
|
||||||
|
if not has_special:
|
||||||
|
missing.append("special character")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
return self.failure(f"Password must contain: {', '.join(missing)}")
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
|
||||||
class ConfigScreen(Screen):
|
class ConfigScreen(Screen):
|
||||||
"""Configuration screen for environment setup."""
|
"""Configuration screen for environment setup."""
|
||||||
|
|
||||||
|
|
@ -112,41 +148,54 @@ class ConfigScreen(Screen):
|
||||||
|
|
||||||
# OpenSearch Admin Password
|
# OpenSearch Admin Password
|
||||||
yield Label("OpenSearch Admin Password *")
|
yield Label("OpenSearch Admin Password *")
|
||||||
current_value = getattr(self.env_manager.config, "opensearch_password", "")
|
yield Static(
|
||||||
input_widget = Input(
|
"Min 8 chars with uppercase, lowercase, digit, and special character",
|
||||||
placeholder="Auto-generated secure password",
|
classes="helper-text",
|
||||||
value=current_value,
|
|
||||||
password=True,
|
|
||||||
id="input-opensearch_password",
|
|
||||||
)
|
)
|
||||||
yield input_widget
|
current_value = getattr(self.env_manager.config, "opensearch_password", "")
|
||||||
self.inputs["opensearch_password"] = input_widget
|
with Horizontal(id="opensearch-password-row"):
|
||||||
|
input_widget = Input(
|
||||||
|
placeholder="Auto-generated secure password",
|
||||||
|
value=current_value,
|
||||||
|
password=True,
|
||||||
|
id="input-opensearch_password",
|
||||||
|
validators=[PasswordValidator()],
|
||||||
|
)
|
||||||
|
yield input_widget
|
||||||
|
self.inputs["opensearch_password"] = input_widget
|
||||||
|
yield Button("👁", id="toggle-opensearch-password", variant="default")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# Langflow Admin Username
|
# Langflow Admin Password
|
||||||
yield Label("Langflow Admin Username *")
|
with Horizontal():
|
||||||
|
yield Label("Langflow Admin Password (optional)")
|
||||||
|
yield Checkbox("Generate password", id="generate-langflow-password")
|
||||||
|
current_value = getattr(
|
||||||
|
self.env_manager.config, "langflow_superuser_password", ""
|
||||||
|
)
|
||||||
|
with Horizontal(id="langflow-password-row"):
|
||||||
|
input_widget = Input(
|
||||||
|
placeholder="Langflow password",
|
||||||
|
value=current_value,
|
||||||
|
password=True,
|
||||||
|
id="input-langflow_superuser_password",
|
||||||
|
)
|
||||||
|
yield input_widget
|
||||||
|
self.inputs["langflow_superuser_password"] = input_widget
|
||||||
|
yield Button("👁", id="toggle-langflow-password", variant="default")
|
||||||
|
yield Static(" ")
|
||||||
|
|
||||||
|
# Langflow Admin Username - conditionally displayed based on password
|
||||||
|
current_password = getattr(self.env_manager.config, "langflow_superuser_password", "")
|
||||||
|
yield Label("Langflow Admin Username *", id="langflow-username-label")
|
||||||
current_value = getattr(self.env_manager.config, "langflow_superuser", "")
|
current_value = getattr(self.env_manager.config, "langflow_superuser", "")
|
||||||
input_widget = Input(
|
input_widget = Input(
|
||||||
placeholder="admin", value=current_value, id="input-langflow_superuser"
|
placeholder="admin", value=current_value, id="input-langflow_superuser"
|
||||||
)
|
)
|
||||||
yield input_widget
|
yield input_widget
|
||||||
self.inputs["langflow_superuser"] = input_widget
|
self.inputs["langflow_superuser"] = input_widget
|
||||||
yield Static(" ")
|
yield Static(" ", id="langflow-username-spacer")
|
||||||
|
|
||||||
# Langflow Admin Password
|
|
||||||
yield Label("Langflow Admin Password *")
|
|
||||||
current_value = getattr(
|
|
||||||
self.env_manager.config, "langflow_superuser_password", ""
|
|
||||||
)
|
|
||||||
input_widget = Input(
|
|
||||||
placeholder="Auto-generated secure password",
|
|
||||||
value=current_value,
|
|
||||||
password=True,
|
|
||||||
id="input-langflow_superuser_password",
|
|
||||||
)
|
|
||||||
yield input_widget
|
|
||||||
self.inputs["langflow_superuser_password"] = input_widget
|
|
||||||
yield Static(" ")
|
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# API Keys Section
|
# API Keys Section
|
||||||
|
|
@ -161,15 +210,17 @@ class ConfigScreen(Screen):
|
||||||
classes="helper-text",
|
classes="helper-text",
|
||||||
)
|
)
|
||||||
current_value = getattr(self.env_manager.config, "openai_api_key", "")
|
current_value = getattr(self.env_manager.config, "openai_api_key", "")
|
||||||
input_widget = Input(
|
with Horizontal(id="openai-key-row"):
|
||||||
placeholder="sk-...",
|
input_widget = Input(
|
||||||
value=current_value,
|
placeholder="sk-...",
|
||||||
password=True,
|
value=current_value,
|
||||||
validators=[OpenAIKeyValidator()],
|
password=True,
|
||||||
id="input-openai_api_key",
|
validators=[OpenAIKeyValidator()],
|
||||||
)
|
id="input-openai_api_key",
|
||||||
yield input_widget
|
)
|
||||||
self.inputs["openai_api_key"] = input_widget
|
yield input_widget
|
||||||
|
self.inputs["openai_api_key"] = input_widget
|
||||||
|
yield Button("Show", id="toggle-openai-key", variant="default")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# Add OAuth fields only in full mode
|
# Add OAuth fields only in full mode
|
||||||
|
|
@ -212,14 +263,16 @@ class ConfigScreen(Screen):
|
||||||
current_value = getattr(
|
current_value = getattr(
|
||||||
self.env_manager.config, "google_oauth_client_secret", ""
|
self.env_manager.config, "google_oauth_client_secret", ""
|
||||||
)
|
)
|
||||||
input_widget = Input(
|
with Horizontal(id="google-secret-row"):
|
||||||
placeholder="",
|
input_widget = Input(
|
||||||
value=current_value,
|
placeholder="",
|
||||||
password=True,
|
value=current_value,
|
||||||
id="input-google_oauth_client_secret",
|
password=True,
|
||||||
)
|
id="input-google_oauth_client_secret",
|
||||||
yield input_widget
|
)
|
||||||
self.inputs["google_oauth_client_secret"] = input_widget
|
yield input_widget
|
||||||
|
self.inputs["google_oauth_client_secret"] = input_widget
|
||||||
|
yield Button("Show", id="toggle-google-secret", variant="default")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# Microsoft Graph Client ID
|
# Microsoft Graph Client ID
|
||||||
|
|
@ -260,14 +313,16 @@ class ConfigScreen(Screen):
|
||||||
current_value = getattr(
|
current_value = getattr(
|
||||||
self.env_manager.config, "microsoft_graph_oauth_client_secret", ""
|
self.env_manager.config, "microsoft_graph_oauth_client_secret", ""
|
||||||
)
|
)
|
||||||
input_widget = Input(
|
with Horizontal(id="microsoft-secret-row"):
|
||||||
placeholder="",
|
input_widget = Input(
|
||||||
value=current_value,
|
placeholder="",
|
||||||
password=True,
|
value=current_value,
|
||||||
id="input-microsoft_graph_oauth_client_secret",
|
password=True,
|
||||||
)
|
id="input-microsoft_graph_oauth_client_secret",
|
||||||
yield input_widget
|
)
|
||||||
self.inputs["microsoft_graph_oauth_client_secret"] = input_widget
|
yield input_widget
|
||||||
|
self.inputs["microsoft_graph_oauth_client_secret"] = input_widget
|
||||||
|
yield Button("Show", id="toggle-microsoft-secret", variant="default")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# AWS Access Key ID
|
# AWS Access Key ID
|
||||||
|
|
@ -328,50 +383,8 @@ class ConfigScreen(Screen):
|
||||||
self.inputs["openrag_documents_paths"] = input_widget
|
self.inputs["openrag_documents_paths"] = input_widget
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# Langflow Auth Settings
|
# Langflow Auth Settings - These are automatically configured based on password presence
|
||||||
yield Static("Langflow Auth Settings", classes="tab-header")
|
# Not shown in UI; set in env_manager.setup_secure_defaults()
|
||||||
yield Static(" ")
|
|
||||||
|
|
||||||
# Langflow Auto Login
|
|
||||||
yield Label("Langflow Auto Login")
|
|
||||||
current_value = getattr(self.env_manager.config, "langflow_auto_login", "False")
|
|
||||||
input_widget = Input(
|
|
||||||
placeholder="False", value=current_value, id="input-langflow_auto_login"
|
|
||||||
)
|
|
||||||
yield input_widget
|
|
||||||
self.inputs["langflow_auto_login"] = input_widget
|
|
||||||
yield Static(" ")
|
|
||||||
|
|
||||||
# Langflow New User Is Active
|
|
||||||
yield Label("Langflow New User Is Active")
|
|
||||||
current_value = getattr(
|
|
||||||
self.env_manager.config, "langflow_new_user_is_active", "False"
|
|
||||||
)
|
|
||||||
input_widget = Input(
|
|
||||||
placeholder="False",
|
|
||||||
value=current_value,
|
|
||||||
id="input-langflow_new_user_is_active",
|
|
||||||
)
|
|
||||||
yield input_widget
|
|
||||||
self.inputs["langflow_new_user_is_active"] = input_widget
|
|
||||||
yield Static(" ")
|
|
||||||
|
|
||||||
# Langflow Enable Superuser CLI
|
|
||||||
yield Label("Langflow Enable Superuser CLI")
|
|
||||||
current_value = getattr(
|
|
||||||
self.env_manager.config, "langflow_enable_superuser_cli", "False"
|
|
||||||
)
|
|
||||||
input_widget = Input(
|
|
||||||
placeholder="False",
|
|
||||||
value=current_value,
|
|
||||||
id="input-langflow_enable_superuser_cli",
|
|
||||||
)
|
|
||||||
yield input_widget
|
|
||||||
self.inputs["langflow_enable_superuser_cli"] = input_widget
|
|
||||||
yield Static(" ")
|
|
||||||
yield Static(" ")
|
|
||||||
|
|
||||||
# Langflow Secret Key removed from UI; generated automatically on save
|
|
||||||
|
|
||||||
# Add optional fields only in full mode
|
# Add optional fields only in full mode
|
||||||
if self.mode == "full":
|
if self.mode == "full":
|
||||||
|
|
@ -454,6 +467,10 @@ class ConfigScreen(Screen):
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize the screen when mounted."""
|
"""Initialize the screen when mounted."""
|
||||||
|
# Set initial visibility of username field based on password
|
||||||
|
current_password = getattr(self.env_manager.config, "langflow_superuser_password", "")
|
||||||
|
self._update_langflow_username_visibility(current_password)
|
||||||
|
|
||||||
# Focus the first input field
|
# Focus the first input field
|
||||||
try:
|
try:
|
||||||
# Find the first input field and focus it
|
# Find the first input field and focus it
|
||||||
|
|
@ -463,6 +480,26 @@ class ConfigScreen(Screen):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||||
|
"""Handle checkbox changes."""
|
||||||
|
if event.checkbox.id == "generate-langflow-password":
|
||||||
|
langflow_password_input = self.inputs.get("langflow_superuser_password")
|
||||||
|
if event.value:
|
||||||
|
# Generate password when checked
|
||||||
|
password = self.env_manager.generate_secure_password()
|
||||||
|
if langflow_password_input:
|
||||||
|
langflow_password_input.value = password
|
||||||
|
# Show username field
|
||||||
|
self._update_langflow_username_visibility(password)
|
||||||
|
self.notify("Generated Langflow password", severity="information")
|
||||||
|
else:
|
||||||
|
# Clear password when unchecked (enable autologin)
|
||||||
|
if langflow_password_input:
|
||||||
|
langflow_password_input.value = ""
|
||||||
|
# Hide username field
|
||||||
|
self._update_langflow_username_visibility("")
|
||||||
|
self.notify("Cleared Langflow password - autologin enabled", severity="information")
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
if event.button.id == "generate-btn":
|
if event.button.id == "generate-btn":
|
||||||
|
|
@ -473,21 +510,58 @@ class ConfigScreen(Screen):
|
||||||
self.action_back()
|
self.action_back()
|
||||||
elif event.button.id == "pick-docs-btn":
|
elif event.button.id == "pick-docs-btn":
|
||||||
self.action_pick_documents_path()
|
self.action_pick_documents_path()
|
||||||
|
elif event.button.id == "toggle-opensearch-password":
|
||||||
|
# Toggle OpenSearch password visibility
|
||||||
|
input_widget = self.inputs.get("opensearch_password")
|
||||||
|
if input_widget:
|
||||||
|
input_widget.password = not input_widget.password
|
||||||
|
event.button.label = "🙈" if not input_widget.password else "👁"
|
||||||
|
elif event.button.id == "toggle-langflow-password":
|
||||||
|
# Toggle Langflow password visibility
|
||||||
|
input_widget = self.inputs.get("langflow_superuser_password")
|
||||||
|
if input_widget:
|
||||||
|
input_widget.password = not input_widget.password
|
||||||
|
event.button.label = "🙈" if not input_widget.password else "👁"
|
||||||
|
|
||||||
def action_generate(self) -> None:
|
def action_generate(self) -> None:
|
||||||
"""Generate secure passwords for admin accounts."""
|
"""Generate secure passwords for admin accounts."""
|
||||||
self.env_manager.setup_secure_defaults()
|
# First sync input values to config to get current state
|
||||||
|
opensearch_input = self.inputs.get("opensearch_password")
|
||||||
|
if opensearch_input:
|
||||||
|
self.env_manager.config.opensearch_password = opensearch_input.value
|
||||||
|
|
||||||
|
# Only generate OpenSearch password if empty
|
||||||
|
if not self.env_manager.config.opensearch_password:
|
||||||
|
self.env_manager.config.opensearch_password = self.env_manager.generate_secure_password()
|
||||||
|
|
||||||
|
# Update secret keys
|
||||||
|
if not self.env_manager.config.langflow_secret_key:
|
||||||
|
self.env_manager.config.langflow_secret_key = self.env_manager.generate_langflow_secret_key()
|
||||||
|
|
||||||
# Update input fields with generated values
|
# Update input fields with generated values
|
||||||
for field_name, input_widget in self.inputs.items():
|
if opensearch_input:
|
||||||
if field_name in ["opensearch_password", "langflow_superuser_password"]:
|
opensearch_input.value = self.env_manager.config.opensearch_password
|
||||||
new_value = getattr(self.env_manager.config, field_name)
|
|
||||||
input_widget.value = new_value
|
|
||||||
|
|
||||||
self.notify("Generated secure passwords", severity="information")
|
self.notify("Generated secure password for OpenSearch", severity="information")
|
||||||
|
|
||||||
def action_save(self) -> None:
|
def action_save(self) -> None:
|
||||||
"""Save the configuration."""
|
"""Save the configuration."""
|
||||||
|
# First, check Textual input validators
|
||||||
|
validation_errors = []
|
||||||
|
for field_name, input_widget in self.inputs.items():
|
||||||
|
if hasattr(input_widget, "validate") and input_widget.value:
|
||||||
|
result = input_widget.validate(input_widget.value)
|
||||||
|
if result and not result.is_valid:
|
||||||
|
for failure in result.failures:
|
||||||
|
validation_errors.append(f"{field_name}: {failure.description}")
|
||||||
|
|
||||||
|
if validation_errors:
|
||||||
|
self.notify(
|
||||||
|
f"Validation failed:\n" + "\n".join(validation_errors[:3]),
|
||||||
|
severity="error",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Update config from input fields
|
# Update config from input fields
|
||||||
for field_name, input_widget in self.inputs.items():
|
for field_name, input_widget in self.inputs.items():
|
||||||
setattr(self.env_manager.config, field_name, input_widget.value)
|
setattr(self.env_manager.config, field_name, input_widget.value)
|
||||||
|
|
@ -507,10 +581,8 @@ class ConfigScreen(Screen):
|
||||||
# Save to file
|
# Save to file
|
||||||
if self.env_manager.save_env_file():
|
if self.env_manager.save_env_file():
|
||||||
self.notify("Configuration saved successfully!", severity="information")
|
self.notify("Configuration saved successfully!", severity="information")
|
||||||
# Switch to monitor screen
|
# Go back to welcome screen
|
||||||
from .monitor import MonitorScreen
|
self.dismiss()
|
||||||
|
|
||||||
self.app.push_screen(MonitorScreen())
|
|
||||||
else:
|
else:
|
||||||
self.notify("Failed to save configuration", severity="error")
|
self.notify("Failed to save configuration", severity="error")
|
||||||
|
|
||||||
|
|
@ -588,5 +660,31 @@ class ConfigScreen(Screen):
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
"""Handle input changes for real-time validation feedback."""
|
"""Handle input changes for real-time validation feedback."""
|
||||||
|
# Handle Langflow password changes - show/hide username field
|
||||||
|
if event.input.id == "input-langflow_superuser_password":
|
||||||
|
self._update_langflow_username_visibility(event.value)
|
||||||
# This will trigger validation display in real-time
|
# This will trigger validation display in real-time
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _update_langflow_username_visibility(self, password_value: str) -> None:
|
||||||
|
"""Show or hide the Langflow username field based on password presence."""
|
||||||
|
has_password = bool(password_value and password_value.strip())
|
||||||
|
|
||||||
|
# Get the widgets
|
||||||
|
try:
|
||||||
|
username_label = self.query_one("#langflow-username-label")
|
||||||
|
username_input = self.query_one("#input-langflow_superuser")
|
||||||
|
username_spacer = self.query_one("#langflow-username-spacer")
|
||||||
|
|
||||||
|
# Show or hide based on password presence
|
||||||
|
if has_password:
|
||||||
|
username_label.display = True
|
||||||
|
username_input.display = True
|
||||||
|
username_spacer.display = True
|
||||||
|
else:
|
||||||
|
username_label.display = False
|
||||||
|
username_input.display = False
|
||||||
|
username_spacer.display = False
|
||||||
|
except Exception:
|
||||||
|
# Widgets don't exist yet, ignore
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ class WelcomeScreen(Screen):
|
||||||
"""
|
"""
|
||||||
welcome_text.append(ascii_art, style="bold white")
|
welcome_text.append(ascii_art, style="bold white")
|
||||||
welcome_text.append("Terminal User Interface for OpenRAG\n", style="dim")
|
welcome_text.append("Terminal User Interface for OpenRAG\n", style="dim")
|
||||||
welcome_text.append(f"v{__version__}\n\n", style="dim cyan")
|
welcome_text.append(f"v{__version__}\n\n", style="white")
|
||||||
|
|
||||||
# Check if all services are running
|
# Check if all services are running
|
||||||
all_services_running = self.services_running and self.docling_running
|
all_services_running = self.services_running and self.docling_running
|
||||||
|
|
@ -237,6 +237,22 @@ class WelcomeScreen(Screen):
|
||||||
except:
|
except:
|
||||||
pass # Button might not exist
|
pass # Button might not exist
|
||||||
|
|
||||||
|
async def on_screen_resume(self) -> None:
|
||||||
|
"""Called when returning from another screen (e.g., config screen)."""
|
||||||
|
# Reload environment variables
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
# Update OAuth config state
|
||||||
|
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
|
||||||
|
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-detect service state
|
||||||
|
self._detect_services_sync()
|
||||||
|
|
||||||
|
# Refresh the welcome content and buttons
|
||||||
|
await self._refresh_welcome_content()
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
if event.button.id == "basic-setup-btn":
|
if event.button.id == "basic-setup-btn":
|
||||||
|
|
|
||||||
|
|
@ -6,53 +6,62 @@ from typing import Callable, Optional, AsyncIterator
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, ScrollableContainer
|
from textual.containers import Container, Horizontal
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Button, Static, Label, TextArea
|
from textual.widgets import Button, Static, Label, TextArea, Footer
|
||||||
|
|
||||||
from ..utils.clipboard import copy_text_to_clipboard
|
from ..utils.clipboard import copy_text_to_clipboard
|
||||||
|
from .waves import Waves
|
||||||
|
|
||||||
|
|
||||||
class CommandOutputModal(ModalScreen):
|
class CommandOutputModal(ModalScreen):
|
||||||
"""Modal dialog for displaying command output in real-time."""
|
"""Modal dialog for displaying command output in real-time."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("w,+", "add_wave", "Add"),
|
||||||
|
("r,-", "remove_wave", "Remove"),
|
||||||
|
("p", "pause_waves", "Pause"),
|
||||||
|
("f", "speed_up", "Faster"),
|
||||||
|
("s", "speed_down", "Slower"),
|
||||||
|
]
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
CommandOutputModal {
|
CommandOutputModal {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waves-background {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
layer: background;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dialog {
|
#dialog {
|
||||||
width: 90%;
|
width: 90%;
|
||||||
height: 90%;
|
height: 90%;
|
||||||
border: thick $primary;
|
border: solid #3f3f46;
|
||||||
background: $surface;
|
background: #27272a;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#title {
|
#title {
|
||||||
background: $primary;
|
background: #3f3f46;
|
||||||
color: $text;
|
color: #fafafa;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#output-container {
|
|
||||||
height: 1fr;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#command-output {
|
#command-output {
|
||||||
height: 100%;
|
height: 1fr;
|
||||||
border: solid $accent;
|
border: solid #3f3f46;
|
||||||
margin: 1 0;
|
margin: 1;
|
||||||
background: $surface-darken-1;
|
background: #18181b;
|
||||||
}
|
color: #fafafa;
|
||||||
|
|
||||||
#command-output > .text-area--content {
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#button-row {
|
#button-row {
|
||||||
|
|
@ -66,11 +75,96 @@ class CommandOutputModal(ModalScreen):
|
||||||
#button-row Button {
|
#button-row Button {
|
||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
min-width: 16;
|
min-width: 16;
|
||||||
|
background: #27272a;
|
||||||
|
color: #fafafa;
|
||||||
|
border: round #52525b;
|
||||||
|
text-style: none;
|
||||||
|
tint: transparent 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button > Static {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button > * {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:hover {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #52525b;
|
||||||
|
tint: transparent 0%;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:hover > Static {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:hover > * {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:focus {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #ec4899;
|
||||||
|
tint: transparent 0%;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:focus > Static {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:focus > * {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button.-active {
|
||||||
|
background: #27272a !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
border: round #ec4899;
|
||||||
|
tint: transparent 0%;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button.-active > Static {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
text-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button.-active > * {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fafafa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:disabled {
|
||||||
|
background: #27272a;
|
||||||
|
color: #52525b;
|
||||||
|
border: round #3f3f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button:disabled > Static {
|
||||||
|
background: transparent;
|
||||||
|
color: #52525b;
|
||||||
}
|
}
|
||||||
|
|
||||||
#copy-status {
|
#copy-status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 1;
|
margin-bottom: 1;
|
||||||
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -84,43 +178,40 @@ class CommandOutputModal(ModalScreen):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: Title of the modal dialog
|
title: Title of the modal dialog
|
||||||
command_generator: Async generator that yields (is_complete, message) tuples
|
command_generator: Async generator that yields (is_complete, message) or (is_complete, message, replace_last) tuples
|
||||||
on_complete: Optional callback to run when command completes
|
on_complete: Optional callback to run when command completes
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title_text = title
|
self.title_text = title
|
||||||
self.command_generator = command_generator
|
self.command_generator = command_generator
|
||||||
self.on_complete = on_complete
|
self.on_complete = on_complete
|
||||||
self._output_text: str = ""
|
self._output_lines: list[str] = []
|
||||||
|
self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index
|
||||||
self._status_task: Optional[asyncio.Task] = None
|
self._status_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the modal dialog layout."""
|
"""Create the modal dialog layout."""
|
||||||
|
yield Waves(id="waves-background")
|
||||||
with Container(id="dialog"):
|
with Container(id="dialog"):
|
||||||
yield Label(self.title_text, id="title")
|
yield Label(self.title_text, id="title")
|
||||||
with ScrollableContainer(id="output-container"):
|
yield TextArea(
|
||||||
yield TextArea(
|
text="",
|
||||||
text="",
|
read_only=True,
|
||||||
read_only=True,
|
show_line_numbers=False,
|
||||||
show_line_numbers=False,
|
id="command-output",
|
||||||
id="command-output",
|
)
|
||||||
)
|
|
||||||
with Container(id="button-row"):
|
with Container(id="button-row"):
|
||||||
yield Button("Copy Output", variant="default", id="copy-btn")
|
yield Button("Copy Output", variant="default", id="copy-btn")
|
||||||
yield Button(
|
yield Button(
|
||||||
"Close", variant="primary", id="close-btn", disabled=True
|
"Close", variant="primary", id="close-btn", disabled=True
|
||||||
)
|
)
|
||||||
yield Static("", id="copy-status")
|
yield Static("", id="copy-status")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Start the command when the modal is mounted."""
|
"""Start the command when the modal is mounted."""
|
||||||
# Start the command but don't store the worker
|
# Start the command but don't store the worker
|
||||||
self.run_worker(self._run_command(), exclusive=False)
|
self.run_worker(self._run_command(), exclusive=False)
|
||||||
# Focus the output so users can select text immediately
|
|
||||||
try:
|
|
||||||
self.query_one("#command-output", TextArea).focus()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
"""Cancel any pending timers when modal closes."""
|
"""Cancel any pending timers when modal closes."""
|
||||||
|
|
@ -135,22 +226,59 @@ class CommandOutputModal(ModalScreen):
|
||||||
elif event.button.id == "copy-btn":
|
elif event.button.id == "copy-btn":
|
||||||
self.copy_to_clipboard()
|
self.copy_to_clipboard()
|
||||||
|
|
||||||
|
def action_add_wave(self) -> None:
|
||||||
|
"""Add a wave to the animation."""
|
||||||
|
waves = self.query_one("#waves-background", Waves)
|
||||||
|
waves._add_wavelet()
|
||||||
|
|
||||||
|
def action_remove_wave(self) -> None:
|
||||||
|
"""Remove a wave from the animation."""
|
||||||
|
waves = self.query_one("#waves-background", Waves)
|
||||||
|
if waves.wavelets:
|
||||||
|
waves.wavelets.pop()
|
||||||
|
|
||||||
|
def action_pause_waves(self) -> None:
|
||||||
|
"""Pause/unpause the wave animation."""
|
||||||
|
waves = self.query_one("#waves-background", Waves)
|
||||||
|
waves.paused = not waves.paused
|
||||||
|
|
||||||
|
def action_speed_up(self) -> None:
|
||||||
|
"""Increase wave speed."""
|
||||||
|
waves = self.query_one("#waves-background", Waves)
|
||||||
|
for w in waves.wavelets:
|
||||||
|
w.speed = min(2.0, w.speed * 1.2)
|
||||||
|
|
||||||
|
def action_speed_down(self) -> None:
|
||||||
|
"""Decrease wave speed."""
|
||||||
|
waves = self.query_one("#waves-background", Waves)
|
||||||
|
for w in waves.wavelets:
|
||||||
|
w.speed = max(0.1, w.speed * 0.8)
|
||||||
|
|
||||||
async def _run_command(self) -> None:
|
async def _run_command(self) -> None:
|
||||||
"""Run the command and update the output in real-time."""
|
"""Run the command and update the output in real-time."""
|
||||||
output = self.query_one("#command-output", TextArea)
|
output = self.query_one("#command-output", TextArea)
|
||||||
container = self.query_one("#output-container", ScrollableContainer)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for is_complete, message in self.command_generator:
|
async for result in self.command_generator:
|
||||||
self._append_output(message)
|
# Handle both (is_complete, message) and (is_complete, message, replace_last) tuples
|
||||||
output.text = self._output_text
|
if len(result) == 2:
|
||||||
container.scroll_end(animate=False)
|
is_complete, message = result
|
||||||
|
replace_last = False
|
||||||
|
else:
|
||||||
|
is_complete, message, replace_last = result
|
||||||
|
|
||||||
|
self._update_output(message, replace_last)
|
||||||
|
output.text = "\n".join(self._output_lines)
|
||||||
|
|
||||||
|
# Move cursor to end to trigger scroll
|
||||||
|
output.move_cursor((len(self._output_lines), 0))
|
||||||
|
|
||||||
# If command is complete, update UI
|
# If command is complete, update UI
|
||||||
if is_complete:
|
if is_complete:
|
||||||
self._append_output("Command completed successfully")
|
self._update_output("Command completed successfully", False)
|
||||||
output.text = self._output_text
|
output.text = "\n".join(self._output_lines)
|
||||||
container.scroll_end(animate=False)
|
output.move_cursor((len(self._output_lines), 0))
|
||||||
|
|
||||||
# Call the completion callback if provided
|
# Call the completion callback if provided
|
||||||
if self.on_complete:
|
if self.on_complete:
|
||||||
await asyncio.sleep(0.5) # Small delay for better UX
|
await asyncio.sleep(0.5) # Small delay for better UX
|
||||||
|
|
@ -162,30 +290,62 @@ class CommandOutputModal(ModalScreen):
|
||||||
|
|
||||||
self.call_after_refresh(_invoke_callback)
|
self.call_after_refresh(_invoke_callback)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._append_output(f"Error: {e}")
|
self._update_output(f"Error: {e}", False)
|
||||||
output.text = self._output_text
|
output.text = "\n".join(self._output_lines)
|
||||||
container.scroll_end(animate=False)
|
output.move_cursor((len(self._output_lines), 0))
|
||||||
finally:
|
finally:
|
||||||
# Enable the close button and focus it
|
# Enable the close button and focus it
|
||||||
close_btn = self.query_one("#close-btn", Button)
|
close_btn = self.query_one("#close-btn", Button)
|
||||||
close_btn.disabled = False
|
close_btn.disabled = False
|
||||||
close_btn.focus()
|
close_btn.focus()
|
||||||
|
|
||||||
def _append_output(self, message: str) -> None:
|
def _update_output(self, message: str, replace_last: bool = False) -> None:
|
||||||
"""Append a message to the output buffer."""
|
"""Update the output buffer by appending or replacing the last line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The message to add or use as replacement
|
||||||
|
replace_last: If True, replace the last line (or layer-specific line); if False, append new line
|
||||||
|
"""
|
||||||
if message is None:
|
if message is None:
|
||||||
return
|
return
|
||||||
message = message.rstrip("\n")
|
message = message.rstrip("\n")
|
||||||
if not message:
|
if not message:
|
||||||
return
|
return
|
||||||
if self._output_text:
|
|
||||||
self._output_text += "\n" + message
|
# Always check if this is a layer update (regardless of replace_last flag)
|
||||||
|
parts = message.split(None, 1)
|
||||||
|
if parts:
|
||||||
|
potential_layer_id = parts[0]
|
||||||
|
|
||||||
|
# Check if this looks like a layer ID (hex string, 12 chars for Docker layers)
|
||||||
|
if len(potential_layer_id) == 12 and all(c in '0123456789abcdefABCDEF' for c in potential_layer_id):
|
||||||
|
# This is a layer message
|
||||||
|
if potential_layer_id in self._layer_line_map:
|
||||||
|
# Update the existing line for this layer
|
||||||
|
line_idx = self._layer_line_map[potential_layer_id]
|
||||||
|
if 0 <= line_idx < len(self._output_lines):
|
||||||
|
self._output_lines[line_idx] = message
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# New layer, add it and track the line index
|
||||||
|
self._layer_line_map[potential_layer_id] = len(self._output_lines)
|
||||||
|
self._output_lines.append(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Not a layer message, handle normally
|
||||||
|
if replace_last:
|
||||||
|
# Fallback: just replace the last line
|
||||||
|
if self._output_lines:
|
||||||
|
self._output_lines[-1] = message
|
||||||
|
else:
|
||||||
|
self._output_lines.append(message)
|
||||||
else:
|
else:
|
||||||
self._output_text = message
|
# Append as a new line
|
||||||
|
self._output_lines.append(message)
|
||||||
|
|
||||||
def copy_to_clipboard(self) -> None:
|
def copy_to_clipboard(self) -> None:
|
||||||
"""Copy the modal output to the clipboard."""
|
"""Copy the modal output to the clipboard."""
|
||||||
if not self._output_text:
|
if not self._output_lines:
|
||||||
message = "No output to copy yet"
|
message = "No output to copy yet"
|
||||||
self.notify(message, severity="warning")
|
self.notify(message, severity="warning")
|
||||||
status = self.query_one("#copy-status", Static)
|
status = self.query_one("#copy-status", Static)
|
||||||
|
|
@ -193,7 +353,8 @@ class CommandOutputModal(ModalScreen):
|
||||||
self._schedule_status_clear(status)
|
self._schedule_status_clear(status)
|
||||||
return
|
return
|
||||||
|
|
||||||
success, message = copy_text_to_clipboard(self._output_text)
|
output_text = "\n".join(self._output_lines)
|
||||||
|
success, message = copy_text_to_clipboard(output_text)
|
||||||
self.notify(message, severity="information" if success else "error")
|
self.notify(message, severity="information" if success else "error")
|
||||||
status = self.query_one("#copy-status", Static)
|
status = self.query_one("#copy-status", Static)
|
||||||
style = "bold green" if success else "bold red"
|
style = "bold green" if success else "bold red"
|
||||||
|
|
|
||||||
146
src/tui/widgets/waves.py
Normal file
146
src/tui/widgets/waves.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Waves animation widget for command modals."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from textual import events
|
||||||
|
from textual.reactive import reactive
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Wavelet:
|
||||||
|
x: float
|
||||||
|
lane: int # 0 or 1
|
||||||
|
speed: float # chars/frame
|
||||||
|
phase: float # for subtle vertical bob / color cycle
|
||||||
|
hue: int # index in palette
|
||||||
|
|
||||||
|
|
||||||
|
class Waves(Static):
|
||||||
|
"""Animated waves widget that displays moving wavelets around the border."""
|
||||||
|
|
||||||
|
can_focus = False
|
||||||
|
fps = 24
|
||||||
|
paused = reactive(False)
|
||||||
|
show_help = reactive(False)
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
self.palette = ["#93c5fd", "#60a5fa", "#38bdf8", "#a78bfa", "#f472b6"]
|
||||||
|
self.wavelets: List[Wavelet] = []
|
||||||
|
# Start with a few wavelets
|
||||||
|
for _ in range(3):
|
||||||
|
self._add_wavelet()
|
||||||
|
self.set_interval(1 / self.fps, self._tick)
|
||||||
|
|
||||||
|
def _offset_for_lane(self, lane: int, width: int, height: int) -> int:
|
||||||
|
max_offset = max(1, min(width, height) // 2 - 1)
|
||||||
|
return min(1 + lane, max_offset)
|
||||||
|
|
||||||
|
def _build_path(
|
||||||
|
self, width: int, height: int, offset: int
|
||||||
|
) -> List[tuple[int, int]]:
|
||||||
|
left = offset
|
||||||
|
right = max(offset, width - offset - 1)
|
||||||
|
top = offset
|
||||||
|
bottom = max(offset, height - offset - 1)
|
||||||
|
if right < left or bottom < top:
|
||||||
|
return [(max(0, left), max(0, top))]
|
||||||
|
|
||||||
|
path: List[tuple[int, int]] = []
|
||||||
|
# Top edge
|
||||||
|
for x in range(left, right + 1):
|
||||||
|
path.append((x, top))
|
||||||
|
# Right edge (excluding corners already added)
|
||||||
|
for y in range(top + 1, bottom):
|
||||||
|
path.append((right, y))
|
||||||
|
# Bottom edge (if distinct from top)
|
||||||
|
if bottom != top:
|
||||||
|
for x in range(right, left - 1, -1):
|
||||||
|
path.append((x, bottom))
|
||||||
|
# Left edge (excluding corners)
|
||||||
|
if right != left:
|
||||||
|
for y in range(bottom - 1, top, -1):
|
||||||
|
path.append((left, y))
|
||||||
|
return path or [(left, top)]
|
||||||
|
|
||||||
|
def _path_for_lane(
|
||||||
|
self, width: int, height: int, lane: int
|
||||||
|
) -> List[tuple[int, int]]:
|
||||||
|
offset = self._offset_for_lane(lane, width, height)
|
||||||
|
path = self._build_path(width, height, offset)
|
||||||
|
if not path and offset > 1:
|
||||||
|
offset = 1
|
||||||
|
path = self._build_path(width, height, offset)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _add_wavelet(self):
|
||||||
|
w = max(10, self.size.width)
|
||||||
|
self.wavelets.append(
|
||||||
|
Wavelet(
|
||||||
|
x=0,
|
||||||
|
lane=random.choice([0, 1]),
|
||||||
|
speed=0.25 + random.random() * 0.35, # slow & smooth
|
||||||
|
phase=random.random() * math.tau,
|
||||||
|
hue=random.randrange(len(self.palette)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Initialize position once we know the current perimeter
|
||||||
|
h = max(6, self.size.height)
|
||||||
|
path = self._path_for_lane(w, h, self.wavelets[-1].lane)
|
||||||
|
if path:
|
||||||
|
self.wavelets[-1].x = random.uniform(0, len(path))
|
||||||
|
|
||||||
|
def set_throughput(self, bytes_per_sec: float):
|
||||||
|
"""Modulate wavelet speed based on download throughput."""
|
||||||
|
boost = min(1.8, 1.0 + math.log10(bytes_per_sec + 1) / 6.0)
|
||||||
|
for w in self.wavelets:
|
||||||
|
w.speed = min(1.2, w.speed * 0.7 + (0.25 * boost))
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
if self.paused:
|
||||||
|
self.refresh()
|
||||||
|
return
|
||||||
|
width = max(10, self.size.width)
|
||||||
|
height = max(6, self.size.height)
|
||||||
|
for w in self.wavelets:
|
||||||
|
path = self._path_for_lane(width, height, w.lane)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
perimeter = len(path)
|
||||||
|
if perimeter <= 0:
|
||||||
|
continue
|
||||||
|
w.x %= perimeter
|
||||||
|
new_pos = w.x + w.speed
|
||||||
|
wrapped = new_pos >= perimeter
|
||||||
|
w.x = new_pos % perimeter
|
||||||
|
if wrapped:
|
||||||
|
# Tiny color/phase change on wrap
|
||||||
|
w.hue = (w.hue + 1) % len(self.palette)
|
||||||
|
w.phase = random.random() * math.tau
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
W = max(10, self.size.width)
|
||||||
|
H = max(6, self.size.height)
|
||||||
|
buf = [[" "] * W for _ in range(H)]
|
||||||
|
|
||||||
|
# Draw wavelets moving around the border
|
||||||
|
for wv in self.wavelets:
|
||||||
|
path = self._path_for_lane(W, H, wv.lane)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
perimeter = len(path)
|
||||||
|
if perimeter <= 0:
|
||||||
|
continue
|
||||||
|
idx = int(wv.x) % perimeter
|
||||||
|
x, y = path[idx]
|
||||||
|
if 0 <= x < W and 0 <= y < H:
|
||||||
|
col = self.palette[wv.hue]
|
||||||
|
buf[y][x] = f"[{col}]≈[/]"
|
||||||
|
|
||||||
|
# No border - just wavelets on empty background
|
||||||
|
|
||||||
|
return "\n".join("".join(r) for r in buf)
|
||||||
Loading…
Add table
Reference in a new issue