Merge pull request #234 from langflow-ai/tui-improvements

TUI improvements
This commit is contained in:
Sebastián Estévez 2025-10-08 23:27:58 -04:00 committed by GitHub
commit cb361dffa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 855 additions and 268 deletions

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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,
) )

View file

@ -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;
} }
""" """

View file

@ -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."""

View file

@ -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"

View file

@ -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

View file

@ -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":

View file

@ -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
View 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)