llamactl 0.3.0a4__py3-none-any.whl → 0.3.0a5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ from llama_deploy.appserver.app import (
5
+ prepare_server,
6
+ start_server_in_target_venv,
7
+ )
8
+ from llama_deploy.core.config import DEFAULT_DEPLOYMENT_FILE_PATH
9
+ from rich import print as rprint
10
+
11
+ from ..app import app
12
+ from ..options import global_options
13
+
14
+
15
+ @app.command(
16
+ "serve",
17
+ help="Serve a LlamaDeploy app locally for development and testing",
18
+ )
19
+ @click.argument(
20
+ "deployment_file",
21
+ required=False,
22
+ default=DEFAULT_DEPLOYMENT_FILE_PATH,
23
+ type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
24
+ )
25
+ @click.option(
26
+ "--no-install", is_flag=True, help="Skip installing python and js dependencies"
27
+ )
28
+ @click.option(
29
+ "--no-reload", is_flag=True, help="Skip reloading the API server on code changes"
30
+ )
31
+ @click.option("--no-open-browser", is_flag=True, help="Skip opening the browser")
32
+ @click.option(
33
+ "--preview",
34
+ is_flag=True,
35
+ help="Preview mode pre-builds the UI to static files, like a production build",
36
+ )
37
+ @global_options
38
+ def serve(
39
+ deployment_file: Path,
40
+ no_install: bool,
41
+ no_reload: bool,
42
+ no_open_browser: bool,
43
+ preview: bool,
44
+ ) -> None:
45
+ """Run llama_deploy API Server in the foreground. If no deployment_file is provided, will look for a llama_deploy.yaml in the current directory."""
46
+ if not deployment_file.exists():
47
+ rprint(f"[red]Deployment file '{deployment_file}' not found[/red]")
48
+ raise click.Abort()
49
+
50
+ try:
51
+ prepare_server(
52
+ install=not no_install,
53
+ build=preview,
54
+ )
55
+ start_server_in_target_venv(
56
+ cwd=Path.cwd(),
57
+ deployment_file=deployment_file,
58
+ proxy_ui=not preview,
59
+ reload=not no_reload,
60
+ open_browser=not no_open_browser,
61
+ )
62
+
63
+ except KeyboardInterrupt:
64
+ print("Shutting down...")
65
+
66
+ except Exception as e:
67
+ rprint(f"[red]Error: {e}[/red]")
68
+ raise click.Abort()
@@ -1,10 +1,10 @@
1
1
  """Configuration and profile management for llamactl"""
2
2
 
3
+ import os
3
4
  import sqlite3
4
- from pathlib import Path
5
- from typing import Optional, List
6
5
  from dataclasses import dataclass
7
- import os
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
8
 
9
9
 
10
10
  @dataclass
@@ -13,7 +13,7 @@ class Profile:
13
13
 
14
14
  name: str
15
15
  api_url: str
16
- active_project_id: Optional[str] = None
16
+ active_project_id: str | None = None
17
17
 
18
18
 
19
19
  class ConfigManager:
@@ -69,7 +69,7 @@ class ConfigManager:
69
69
  conn.commit()
70
70
 
71
71
  def create_profile(
72
- self, name: str, api_url: str, active_project_id: Optional[str] = None
72
+ self, name: str, api_url: str, active_project_id: str | None = None
73
73
  ) -> Profile:
74
74
  """Create a new profile"""
75
75
  profile = Profile(
@@ -88,7 +88,7 @@ class ConfigManager:
88
88
 
89
89
  return profile
90
90
 
91
- def get_profile(self, name: str) -> Optional[Profile]:
91
+ def get_profile(self, name: str) -> Profile | None:
92
92
  """Get a profile by name"""
93
93
  with sqlite3.connect(self.db_path) as conn:
94
94
  cursor = conn.execute(
@@ -125,7 +125,7 @@ class ConfigManager:
125
125
 
126
126
  return cursor.rowcount > 0
127
127
 
128
- def set_current_profile(self, name: Optional[str]):
128
+ def set_current_profile(self, name: str | None):
129
129
  """Set the current active profile"""
130
130
  with sqlite3.connect(self.db_path) as conn:
131
131
  if name is None:
@@ -137,7 +137,7 @@ class ConfigManager:
137
137
  )
138
138
  conn.commit()
139
139
 
140
- def get_current_profile_name(self) -> Optional[str]:
140
+ def get_current_profile_name(self) -> str | None:
141
141
  """Get the name of the current active profile"""
142
142
  with sqlite3.connect(self.db_path) as conn:
143
143
  cursor = conn.execute(
@@ -146,14 +146,14 @@ class ConfigManager:
146
146
  row = cursor.fetchone()
147
147
  return row[0] if row else None
148
148
 
149
- def get_current_profile(self) -> Optional[Profile]:
149
+ def get_current_profile(self) -> Profile | None:
150
150
  """Get the current active profile"""
151
151
  current_name = self.get_current_profile_name()
152
152
  if current_name:
153
153
  return self.get_profile(current_name)
154
154
  return None
155
155
 
156
- def set_active_project(self, profile_name: str, project_id: Optional[str]) -> bool:
156
+ def set_active_project(self, profile_name: str, project_id: str | None) -> bool:
157
157
  """Set the active project for a profile. Returns True if profile exists."""
158
158
  with sqlite3.connect(self.db_path) as conn:
159
159
  cursor = conn.execute(
@@ -163,7 +163,7 @@ class ConfigManager:
163
163
  conn.commit()
164
164
  return cursor.rowcount > 0
165
165
 
166
- def get_active_project(self, profile_name: str) -> Optional[str]:
166
+ def get_active_project(self, profile_name: str) -> str | None:
167
167
  """Get the active project for a profile"""
168
168
  profile = self.get_profile(profile_name)
169
169
  return profile.active_project_id if profile else None
llama_deploy/cli/env.py CHANGED
@@ -1,9 +1,10 @@
1
1
  """Environment variable handling utilities for llamactl"""
2
2
 
3
- from typing import Dict
4
3
  from io import StringIO
5
- from rich import print as rprint
4
+ from typing import Dict
5
+
6
6
  from dotenv import dotenv_values
7
+ from rich import print as rprint
7
8
 
8
9
 
9
10
  def load_env_secrets_from_string(env_content: str) -> Dict[str, str]:
@@ -12,7 +12,7 @@ from ..config import config_manager
12
12
  console = Console()
13
13
 
14
14
 
15
- def select_deployment(deployment_id: Optional[str] = None) -> Optional[str]:
15
+ def select_deployment(deployment_id: str | None = None) -> str | None:
16
16
  """
17
17
  Select a deployment interactively if ID not provided.
18
18
  Returns the selected deployment ID or None if cancelled.
@@ -48,7 +48,7 @@ def select_deployment(deployment_id: Optional[str] = None) -> Optional[str]:
48
48
  return None
49
49
 
50
50
 
51
- def select_profile(profile_name: Optional[str] = None) -> Optional[str]:
51
+ def select_profile(profile_name: str | None = None) -> str | None:
52
52
  """
53
53
  Select a profile interactively if name not provided.
54
54
  Returns the selected profile name or None if cancelled.
@@ -1,4 +1,5 @@
1
1
  import logging
2
+
2
3
  import click
3
4
 
4
5
 
@@ -18,4 +19,5 @@ def global_options(f):
18
19
  callback=debug_callback,
19
20
  expose_value=False,
20
21
  is_eager=True,
22
+ hidden=True,
21
23
  )(f)
@@ -1,16 +1,27 @@
1
1
  """Textual-based deployment forms for CLI interactions"""
2
2
 
3
- from dataclasses import dataclass, field
4
3
  import dataclasses
5
4
  import logging
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
6
8
  from pathlib import Path
9
+ from urllib.parse import urlsplit
7
10
 
8
- from llama_deploy.cli.textual.secrets_form import SecretsWidget
11
+ from llama_deploy.cli.client import get_project_client as get_client
12
+ from llama_deploy.cli.env import load_env_secrets_from_string
13
+ from llama_deploy.cli.textual.deployment_help import (
14
+ DeploymentHelpBackMessage,
15
+ DeploymentHelpWidget,
16
+ )
9
17
  from llama_deploy.cli.textual.git_validation import (
10
18
  GitValidationWidget,
11
- ValidationResultMessage,
12
19
  ValidationCancelMessage,
20
+ ValidationResultMessage,
13
21
  )
22
+ from llama_deploy.cli.textual.secrets_form import SecretsWidget
23
+ from llama_deploy.core.deployment_config import DeploymentConfig
24
+ from llama_deploy.core.git.git_util import get_current_branch, is_git_repo, list_remotes
14
25
  from llama_deploy.core.schema.deployments import (
15
26
  DeploymentCreate,
16
27
  DeploymentResponse,
@@ -18,11 +29,11 @@ from llama_deploy.core.schema.deployments import (
18
29
  )
19
30
  from textual.app import App, ComposeResult
20
31
  from textual.containers import Container, HorizontalGroup, Widget
32
+ from textual.content import Content
33
+ from textual.message import Message
34
+ from textual.reactive import reactive
21
35
  from textual.validation import Length
22
36
  from textual.widgets import Button, Input, Label, Static
23
- from textual.reactive import reactive
24
- from llama_deploy.cli.client import get_project_client as get_client
25
- from textual.message import Message
26
37
 
27
38
 
28
39
  @dataclass
@@ -119,8 +130,11 @@ class DeploymentFormWidget(Widget):
119
130
 
120
131
  def compose(self) -> ComposeResult:
121
132
  title = "Edit Deployment" if self.form_data.is_editing else "Create Deployment"
133
+
122
134
  yield Static(
123
- title,
135
+ Content.from_markup(
136
+ f"{title} [italic][@click=app.show_help()]More info[/][/italic]"
137
+ ),
124
138
  classes="primary-message",
125
139
  )
126
140
  yield Static(
@@ -201,7 +215,7 @@ class DeploymentFormWidget(Widget):
201
215
  if event.button.id == "save":
202
216
  self._save()
203
217
  elif event.button.id == "change_pat":
204
- updated_form = dataclasses.replace(self._get_form_data())
218
+ updated_form = dataclasses.replace(self.resolve_form_data())
205
219
  updated_form.has_existing_pat = False
206
220
  updated_form.personal_access_token = ""
207
221
  self.form_data = updated_form
@@ -210,7 +224,7 @@ class DeploymentFormWidget(Widget):
210
224
  self.post_message(CancelFormMessage())
211
225
 
212
226
  def _save(self) -> None:
213
- self.form_data = self._get_form_data()
227
+ self.form_data = self.resolve_form_data()
214
228
  if self._validate_form():
215
229
  # Post message to parent app to start validation
216
230
  self.post_message(StartValidationMessage(self.form_data))
@@ -245,7 +259,7 @@ class DeploymentFormWidget(Widget):
245
259
  """Show an error message"""
246
260
  self.error_message = message
247
261
 
248
- def _get_form_data(self) -> DeploymentForm:
262
+ def resolve_form_data(self) -> DeploymentForm:
249
263
  """Extract form data from inputs"""
250
264
  name_input = self.query_one("#name", Input)
251
265
  repo_url_input = self.query_one("#repo_url", Input)
@@ -299,12 +313,22 @@ class StartValidationMessage(Message):
299
313
  self.form_data = form_data
300
314
 
301
315
 
316
+ class ShowHelpMessage(Message):
317
+ def __init__(self, form_data: DeploymentForm):
318
+ super().__init__()
319
+ self.form_data = form_data
320
+
321
+
322
+ class HelpBackMessage(Message):
323
+ pass
324
+
325
+
302
326
  class DeploymentEditApp(App[DeploymentResponse | None]):
303
327
  """Textual app for editing/creating deployments"""
304
328
 
305
329
  CSS_PATH = Path(__file__).parent / "styles.tcss"
306
330
 
307
- # App states: 'form' or 'validation'
331
+ # App states: 'form', 'validation', or 'help'
308
332
  current_state: reactive[str] = reactive("form", recompose=True)
309
333
  form_data: reactive[DeploymentForm] = reactive(DeploymentForm())
310
334
  save_error: reactive[str] = reactive("", recompose=True)
@@ -336,6 +360,23 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
336
360
  if self.form_data.personal_access_token
337
361
  else None,
338
362
  )
363
+ elif self.current_state == "help":
364
+ yield DeploymentHelpWidget()
365
+ else:
366
+ yield Static("Unknown state: " + self.current_state)
367
+
368
+ def action_show_help(self) -> None:
369
+ widget = self.query("DeploymentFormWidget")
370
+ if widget:
371
+ typed_widget: DeploymentFormWidget = widget[0]
372
+ self.form_data = typed_widget.resolve_form_data()
373
+
374
+ self.current_state = "help"
375
+
376
+ def on_deployment_help_back_message(
377
+ self, message: DeploymentHelpBackMessage
378
+ ) -> None:
379
+ self.current_state = "form"
339
380
 
340
381
  def on_start_validation_message(self, message: StartValidationMessage) -> None:
341
382
  """Handle validation start message from form widget"""
@@ -364,6 +405,15 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
364
405
  self.save_error = ""
365
406
  self.current_state = "form"
366
407
 
408
+ def on_show_help_message(self, message: ShowHelpMessage) -> None:
409
+ """Navigate to help view, preserving current form state."""
410
+ self.form_data = message.form_data
411
+ self.current_state = "help"
412
+
413
+ def on_help_back_message(self, message: HelpBackMessage) -> None:
414
+ """Return from help to form, keeping form state intact."""
415
+ self.current_state = "form"
416
+
367
417
  def _perform_save(self) -> None:
368
418
  """Actually save the deployment after validation"""
369
419
  logging.info("saving form data", self.form_data)
@@ -403,6 +453,91 @@ def edit_deployment_form(
403
453
 
404
454
  def create_deployment_form() -> DeploymentResponse | None:
405
455
  """Launch deployment creation form and return result"""
406
- initial_data = DeploymentForm()
456
+ initial_data = _initialize_deployment_data()
407
457
  app = DeploymentEditApp(initial_data)
408
458
  return app.run()
459
+
460
+
461
+ def _initialize_deployment_data() -> DeploymentForm:
462
+ """
463
+ initialize the deployment form data from the current git repo and .env file
464
+ """
465
+
466
+ repo_url: str | None = None
467
+ git_ref: str | None = None
468
+ secrets: dict[str, str] = {}
469
+ name: str | None = None
470
+ if is_git_repo():
471
+ seen = set[str]()
472
+ remotes = list_remotes()
473
+ candidate_origins = []
474
+ for remote in remotes:
475
+ normalized_url = _normalize_to_http(remote)
476
+ if normalized_url not in seen:
477
+ candidate_origins.append(normalized_url)
478
+ seen.add(normalized_url)
479
+ preferred_origin = sorted(
480
+ candidate_origins, key=lambda x: "github.com" in x, reverse=True
481
+ )
482
+ if preferred_origin:
483
+ repo_url = preferred_origin[0]
484
+ git_ref = get_current_branch()
485
+ if Path(".env").exists():
486
+ secrets = load_env_secrets_from_string(Path(".env").read_text())
487
+ for f in os.listdir("."):
488
+ if f.endswith(".yml") or f.endswith(".yaml"):
489
+ try:
490
+ config = DeploymentConfig.from_yaml(Path(f))
491
+ name = config.name
492
+ break
493
+ except Exception:
494
+ pass
495
+
496
+ form = DeploymentForm(
497
+ name=name or "",
498
+ repo_url=repo_url or "",
499
+ git_ref=git_ref or "main",
500
+ secrets=secrets,
501
+ )
502
+ return form
503
+
504
+
505
+ def _normalize_to_http(url: str) -> str:
506
+ """
507
+ normalize a git url to a best guess for a corresponding http(s) url
508
+ """
509
+ candidate = (url or "").strip()
510
+
511
+ # If no scheme, first try scp-like SSH syntax: [user@]host:path
512
+ has_scheme = "://" in candidate
513
+ if not has_scheme:
514
+ scp_match = re.match(
515
+ r"^(?:(?P<user>[^@]+)@)?(?P<host>[^:/\s]+):(?P<path>[^/].+)$",
516
+ candidate,
517
+ )
518
+ if scp_match:
519
+ host = scp_match.group("host")
520
+ path = scp_match.group("path").lstrip("/")
521
+ if path.endswith(".git"):
522
+ path = path[:-4]
523
+ return f"https://{host}/{path}"
524
+
525
+ # If no scheme (and not scp), assume host/path and prepend https
526
+ parsed = urlsplit(candidate if has_scheme else f"https://{candidate}")
527
+
528
+ # Drop credentials from netloc
529
+ netloc = parsed.netloc.split("@", 1)[-1]
530
+
531
+ # Drop explicit port (common for SSH like :7999 which is wrong for https)
532
+ if ":" in netloc:
533
+ netloc = netloc.split(":", 1)[0]
534
+
535
+ # Normalize path and strip .git
536
+ path = parsed.path.lstrip("/")
537
+ if path.endswith(".git"):
538
+ path = path[:-4]
539
+
540
+ if path:
541
+ return f"https://{netloc}/{path}"
542
+ else:
543
+ return f"https://{netloc}"
@@ -0,0 +1,53 @@
1
+ from textwrap import dedent
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import HorizontalGroup, Widget
5
+ from textual.content import Content
6
+ from textual.message import Message
7
+ from textual.widgets import Button, Static
8
+
9
+
10
+ class DeploymentHelpBackMessage(Message):
11
+ pass
12
+
13
+
14
+ class DeploymentHelpWidget(Widget):
15
+ DEFAULT_CSS = """
16
+ DeploymentHelpWidget {
17
+ layout: vertical;
18
+ height: auto;
19
+ }
20
+ """
21
+
22
+ def compose(self) -> ComposeResult:
23
+ yield Static(
24
+ "Deploy your app to llama cloud or your own infrastructure:",
25
+ classes="primary-message",
26
+ )
27
+ yield Static(
28
+ Content.from_markup(
29
+ dedent("""
30
+ [b]Deployment Name[/b]
31
+ A unique name to identify this deployment. Controls the URL where your deployment is accessible. Will have a random suffix appended if not unique.
32
+
33
+ [b]Git Repository[/b]
34
+ A git repository URL to pull code from. If not publically accessible, you will be prompted to install the llama deploy github app. If code is on another platform, either provide a Personal Access Token (basic access credentials) instead.
35
+
36
+ [b]Git Ref[/b]
37
+ The git ref to deploy. This can be a branch, tag, or commit hash. If this is a branch, after deploying, run a `[slategrey reverse]llamactl deploy refresh[/]` to update the deployment to the latest git ref after you make updates.
38
+
39
+ [b]Deployment File[/b]
40
+ The `[slategrey reverse]llama_deploy.yaml[/]` file to use for the deployment. This is a yaml file that contains the configuration for how to serve the deployment.
41
+
42
+ [b]Secrets[/b]
43
+ Secrets to add as environment variables to the deployment. e.g. to access a database or an API. Supports adding in `[slategrey reverse].env[/]` file format.
44
+
45
+ """).strip()
46
+ ),
47
+ )
48
+ with HorizontalGroup(classes="button-row"):
49
+ yield Button("Back", variant="primary", id="help_back", compact=True)
50
+
51
+ def on_button_pressed(self, event: Button.Pressed) -> None:
52
+ if event.button.id == "help_back":
53
+ self.post_message(DeploymentHelpBackMessage())
@@ -4,17 +4,16 @@ import logging
4
4
  import webbrowser
5
5
  from typing import Literal, cast
6
6
 
7
+ from llama_deploy.cli.client import get_project_client as get_client
8
+ from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
9
+ from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
10
+ from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
7
11
  from textual.app import ComposeResult
8
12
  from textual.containers import HorizontalGroup, Widget
9
- from textual.widgets import Button, Input, Label, Static
10
- from textual.message import Message
11
13
  from textual.content import Content
14
+ from textual.message import Message
12
15
  from textual.reactive import reactive
13
-
14
- from llama_deploy.cli.client import get_project_client as get_client
15
- from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
16
- from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
17
- from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
16
+ from textual.widgets import Button, Input, Label, Static
18
17
 
19
18
  logger = logging.getLogger(__name__)
20
19
 
@@ -4,7 +4,7 @@ import asyncio
4
4
  import logging
5
5
  import webbrowser
6
6
  from textwrap import dedent
7
- from typing import Dict, Any, cast
7
+ from typing import Any, Dict, cast
8
8
 
9
9
  from aiohttp import web
10
10
 
@@ -1,4 +1,5 @@
1
1
  import re
2
+
2
3
  from textual.widgets import Static
3
4
 
4
5
 
@@ -12,8 +12,7 @@ from textual.containers import (
12
12
  from textual.validation import Length
13
13
  from textual.widgets import Button, Input, Label, Static
14
14
 
15
- from ..config import Profile
16
- from ..config import config_manager
15
+ from ..config import Profile, config_manager
17
16
 
18
17
 
19
18
  @dataclass
@@ -23,7 +22,7 @@ class ProfileForm:
23
22
  name: str = ""
24
23
  api_url: str = ""
25
24
  active_project_id: str = ""
26
- existing_name: Optional[str] = None
25
+ existing_name: str | None = None
27
26
 
28
27
  @classmethod
29
28
  def from_profile(cls, profile: Profile) -> "ProfileForm":
@@ -35,7 +34,7 @@ class ProfileForm:
35
34
  )
36
35
 
37
36
 
38
- class ProfileEditApp(App[Optional[ProfileForm]]):
37
+ class ProfileEditApp(App[ProfileForm | None]):
39
38
  """Textual app for editing profiles"""
40
39
 
41
40
  CSS_PATH = Path(__file__).parent / "styles.tcss"
@@ -71,7 +70,7 @@ class ProfileEditApp(App[Optional[ProfileForm]]):
71
70
  yield Label("API URL: *", classes="required form-label", shrink=True)
72
71
  yield Input(
73
72
  value=self.form_data.api_url,
74
- placeholder="https://prod-cloud-llama-deploy",
73
+ placeholder="http://prod-cloud-llama-deploy",
75
74
  validators=[Length(minimum=1)],
76
75
  id="api_url",
77
76
  compact=True,
@@ -162,10 +161,10 @@ def edit_profile_form(profile: Profile) -> ProfileForm | None:
162
161
  return app.run()
163
162
 
164
163
 
165
- def create_profile_form() -> Optional[ProfileForm]:
164
+ def create_profile_form() -> ProfileForm | None:
166
165
  """Launch profile creation form and return result"""
167
166
  return edit_profile_form(
168
167
  Profile(
169
- name="", api_url="https://prod-cloud-llama-deploy", active_project_id=None
168
+ name="", api_url="http://prod-cloud-llama-deploy", active_project_id=None
170
169
  )
171
170
  )
@@ -1,11 +1,10 @@
1
1
  """Textual-based deployment forms for CLI interactions"""
2
2
 
3
3
  from llama_deploy.cli.env import load_env_secrets_from_string
4
-
5
4
  from textual.app import ComposeResult
6
5
  from textual.containers import HorizontalGroup, Widget
7
- from textual.widgets import Button, Input, Label, Static, TextArea
8
6
  from textual.reactive import reactive
7
+ from textual.widgets import Button, Input, Label, Static, TextArea
9
8
 
10
9
 
11
10
  class SecretsWidget(Widget):
@@ -62,9 +61,7 @@ class SecretsWidget(Widget):
62
61
  """
63
62
  secrets = reactive({}, recompose=True) # Auto-recompose when secrets change
64
63
  prior_secrets = reactive(set(), recompose=True)
65
- visible_secrets = reactive(
66
- set(), recompose=True
67
- ) # Auto-recompose when visible secrets change
64
+ visible_secrets = reactive(set(), recompose=True)
68
65
 
69
66
  def __init__(
70
67
  self,
@@ -75,11 +72,18 @@ class SecretsWidget(Widget):
75
72
  self.secrets = initial_secrets or {}
76
73
  self.prior_secrets = prior_secrets or set()
77
74
  self.visible_secrets = set()
75
+ # Persist textarea content across recomposes triggered by other actions
76
+ self._new_secrets_text = ""
78
77
 
79
78
  def compose(self) -> ComposeResult:
80
79
  """Compose the secrets section - called automatically when secrets change"""
81
- yield Static("Environment Variables", classes="secrets-header")
82
- secret_names = self.prior_secrets.union(self.secrets.keys())
80
+ yield Static("Secrets", classes="secrets-header")
81
+ # Preserve deterministic order: known secrets in insertion order, then prior-only sorted
82
+ known_secret_names = list(self.secrets.keys())
83
+ prior_only_secret_names = sorted(
84
+ [name for name in self.prior_secrets if name not in self.secrets]
85
+ )
86
+ secret_names = known_secret_names + prior_only_secret_names
83
87
  hidden = len(secret_names) == 0
84
88
  with Static(
85
89
  classes="secrets-grid" + (" hidden" if hidden else ""),
@@ -108,9 +112,15 @@ class SecretsWidget(Widget):
108
112
  compact=True,
109
113
  )
110
114
 
115
+ # Short help text for textarea format
116
+ yield Static(
117
+ "Format: one per line, KEY=VALUE (e.g., API_KEY=abc).",
118
+ classes="secret-label",
119
+ )
120
+
111
121
  with HorizontalGroup(classes="text-container"):
112
122
  yield TextArea(
113
- "",
123
+ self._new_secrets_text,
114
124
  id="new_secrets",
115
125
  show_line_numbers=True,
116
126
  highlight_cursor_line=True,
@@ -154,6 +164,7 @@ class SecretsWidget(Widget):
154
164
 
155
165
  # Clear textarea
156
166
  textarea.text = ""
167
+ self._new_secrets_text = ""
157
168
 
158
169
  def _toggle_secret_visibility(self, secret_name: str) -> None:
159
170
  """Toggle the visibility of a secret"""
@@ -175,6 +186,11 @@ class SecretsWidget(Widget):
175
186
  updated_prior_secrets.remove(secret_name)
176
187
  self.prior_secrets = updated_prior_secrets
177
188
 
189
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
190
+ """Track textarea edits so content persists across recomposes."""
191
+ text = event.text_area.text
192
+ self._new_secrets_text = text
193
+
178
194
  def get_updated_secrets(self) -> dict[str, str]:
179
195
  """Get current secrets with values from input fields"""
180
196
  self._add_secrets_from_textarea()