From 9b063a5119f8515526f0ef71249ad2fbbe34dffc Mon Sep 17 00:00:00 2001 From: Anthony Ivan Date: Thu, 28 May 2026 09:26:30 +0800 Subject: [PATCH 1/4] Show profile name alongside workspace URL in picker When selecting a workspace during first-time setup, display the Databricks CLI profile alias next to the host URL so users can easily identify which workspace to pick. Co-authored-by: Anthony Ivan --- src/ucode/ui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ucode/ui.py b/src/ucode/ui.py index 249082c..6d98c54 100644 --- a/src/ucode/ui.py +++ b/src/ucode/ui.py @@ -189,7 +189,10 @@ def prompt_for_workspace( if profiles: choices = [ - questionary.Choice(title=host, value=(host, profile_name)) + questionary.Choice( + title=f"{host} ({profile_name})", + value=(host, profile_name), + ) for host, profile_name in profiles ] choices.append(questionary.Choice(title="Enter a different URL", value=None)) From 98ee36cf3158ae77d3367be93d9a3e9368bcc89a Mon Sep 17 00:00:00 2001 From: Anthony Ivan Date: Thu, 28 May 2026 22:25:17 +0800 Subject: [PATCH 2/4] Render profile-name picker as 2-column layout, preserve duplicate hosts - get_databricks_profiles now returns one entry per profile instead of deduping by host, so workspaces with multiple profiles each get a row. - prompt_for_workspace renders a `Profile Name` / `Workspace URL` header (matching `databricks auth profiles`, minus the Valid column) with ljust-padded names so columns align. - The picker's value is still (host, profile_name); that tuple is what flows into configure_shared_state and gets persisted to state.json, so the profile the user actually selects (not just the first one for the host) is what every later databricks CLI invocation uses. Co-authored-by: Isaac --- src/ucode/databricks.py | 12 ++++-- src/ucode/ui.py | 24 ++++++++---- tests/test_databricks.py | 58 +++++++++++++++++++++++++++++ tests/test_ui.py | 80 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 12 deletions(-) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 087ac89..5dea2f3 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -413,6 +413,11 @@ def has_valid_databricks_auth(workspace: str, profile: str | None = None) -> boo def get_databricks_profiles() -> list[tuple[str, str]]: """Return [(host_url, profile_name), ...] from Databricks CLI profiles. + Each non-PAT profile is returned individually — duplicate hosts (multiple + profiles pointing at the same workspace) appear as separate entries so the + workspace picker can offer each profile by name. Order matches the CLI's + own ordering. + Returns ``[]`` on any failure (CLI missing, timeout, non-zero exit, JSON decode error). When ``UCODE_DEBUG=1`` each dropout path logs *why* the result was empty so a silently-disappearing workspace picker is @@ -438,8 +443,7 @@ def get_databricks_profiles() -> list[tuple[str, str]]: _debug("get_databricks_profiles", f"json decode error: {exc.msg}") return [] - # dict dedupes by host (first non-PAT profile wins). - out: dict[str, str] = {} + out: list[tuple[str, str]] = [] pat = 0 for p in profiles: host = (p.get("host") or "").rstrip("/") @@ -449,13 +453,13 @@ def get_databricks_profiles() -> list[tuple[str, str]]: if p.get("auth_type") == "pat": pat += 1 continue - out.setdefault(host, name) + out.append((host, name)) _debug( "get_databricks_profiles", f"returned={len(out)} total={len(profiles)} pat={pat}", ) - return list(out.items()) + return out def find_profile_name_for_host(workspace: str) -> str | None: diff --git a/src/ucode/ui.py b/src/ucode/ui.py index 6d98c54..66b7c72 100644 --- a/src/ucode/ui.py +++ b/src/ucode/ui.py @@ -181,26 +181,34 @@ def prompt_for_workspace( """Ask the user for a workspace URL, offering profiles as quick-select. `profiles` is a list of (host_url, profile_name) tuples. Caller fetches - them — `ui.py` stays Databricks-agnostic. Returns ``(url, profile_name)``; - profile_name is ``None`` when the user typed a URL manually. + them — `ui.py` stays Databricks-agnostic. Duplicate hosts (multiple + profiles pointing at the same workspace) are shown separately; the picker + returns the exact (host, profile_name) the user selected. Returns + ``(url, profile_name)``; profile_name is ``None`` when the user typed a + URL manually. """ console.print() console.print(Panel(description, title="ucode setup", style="bold blue", expand=False)) if profiles: - choices = [ - questionary.Choice( - title=f"{host} ({profile_name})", - value=(host, profile_name), - ) - for host, profile_name in profiles + name_header = "Profile Name" + url_header = "Workspace URL" + name_width = max(len(name_header), *(len(name) for _, name in profiles)) + # Match the 2-char cursor gutter so the header line aligns with rows. + header_title = f" {name_header.ljust(name_width)} {url_header}" + choices: list[questionary.Choice | questionary.Separator] = [ + questionary.Separator(header_title) ] + for host, profile_name in profiles: + row_title = f"{profile_name.ljust(name_width)} {host}" + choices.append(questionary.Choice(title=row_title, value=(host, profile_name))) choices.append(questionary.Choice(title="Enter a different URL", value=None)) style = questionary.Style( [ ("highlighted", "fg:cyan bold"), ("pointer", "fg:cyan bold"), ("answer", "fg:cyan"), + ("separator", "fg:white bold"), ] ) choice = questionary.select( diff --git a/tests/test_databricks.py b/tests/test_databricks.py index 6857efc..0e94fad 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -21,6 +21,7 @@ build_shared_base_urls, build_tool_base_url, ensure_databricks_cli_version, + get_databricks_profiles, get_databricks_token, list_databricks_apps, list_databricks_connections, @@ -342,6 +343,63 @@ def test_error_suggests_logout_when_matching_profile_exists(self, tmp_path, monk assert f"databricks auth login --host {WS} --profile example-profile" in message +class TestGetDatabricksProfiles: + def _patched_run(self, monkeypatch, payload: dict, returncode: int = 0) -> None: + def fake_run(args, **kwargs): + return subprocess.CompletedProcess(args, returncode, stdout=json.dumps(payload)) + + monkeypatch.setattr(db_mod, "run", fake_run) + + def test_keeps_duplicate_hosts_as_separate_entries(self, monkeypatch): + self._patched_run( + monkeypatch, + { + "profiles": [ + {"host": WS, "name": "first", "auth_type": "databricks-cli"}, + {"host": WS, "name": "second", "auth_type": "databricks-cli"}, + { + "host": "https://other.databricks.com", + "name": "third", + "auth_type": "databricks-cli", + }, + ] + }, + ) + profiles = get_databricks_profiles() + assert profiles == [ + (WS, "first"), + (WS, "second"), + ("https://other.databricks.com", "third"), + ] + + def test_skips_pat_profiles(self, monkeypatch): + self._patched_run( + monkeypatch, + { + "profiles": [ + {"host": WS, "name": "oauth", "auth_type": "databricks-cli"}, + {"host": WS, "name": "tokenized", "auth_type": "pat"}, + ] + }, + ) + assert get_databricks_profiles() == [(WS, "oauth")] + + def test_strips_trailing_slash_on_host(self, monkeypatch): + self._patched_run( + monkeypatch, + { + "profiles": [ + {"host": f"{WS}/", "name": "p", "auth_type": "databricks-cli"}, + ] + }, + ) + assert get_databricks_profiles() == [(WS, "p")] + + def test_returns_empty_on_non_zero_exit(self, monkeypatch): + self._patched_run(monkeypatch, {"profiles": []}, returncode=1) + assert get_databricks_profiles() == [] + + class TestListDatabricksConnections: def test_lists_paginated_connections_with_workspace_env(self, monkeypatch): calls: list[dict] = [] diff --git a/tests/test_ui.py b/tests/test_ui.py index 5b8f846..9b143dc 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -5,11 +5,14 @@ from datetime import timedelta import pytest +import questionary +from ucode import ui as ui_mod from ucode.ui import ( format_duration, format_token_count, normalize_workspace_url, + prompt_for_workspace, render_box_table, status_badge, ) @@ -144,3 +147,80 @@ def test_cell_wraps_when_max_width_set(self): def test_dash_for_empty_cell(self): result = render_box_table(["A"], [[""]]) assert "-" in result + + +class _StubQuestion: + def __init__(self, answer): + self._answer = answer + + def ask(self): + return self._answer + + +class TestPromptForWorkspace: + """Capture the choices passed to ``questionary.select`` so we can assert on + layout (header alignment + duplicate-host preservation) without driving + real keyboard I/O.""" + + def _capture_select(self, monkeypatch, answer): + captured: dict = {} + + def fake_select(message, choices, **kwargs): + captured["message"] = message + captured["choices"] = choices + captured["kwargs"] = kwargs + return _StubQuestion(answer) + + monkeypatch.setattr(questionary, "select", fake_select) + monkeypatch.setattr(ui_mod.questionary, "select", fake_select) + return captured + + def test_shows_header_and_each_profile_row(self, monkeypatch): + profiles = [ + ("https://a.cloud.databricks.com", "alpha"), + ("https://b.cloud.databricks.com", "beta-profile-name"), + ] + captured = self._capture_select(monkeypatch, answer=profiles[0]) + url, profile = prompt_for_workspace("setup", profiles) + + assert (url, profile) == profiles[0] + choices = captured["choices"] + # Header (separator), 2 rows, "Enter a different URL" entry. + assert len(choices) == 4 + assert isinstance(choices[0], questionary.Separator) + header = choices[0].title + assert "Profile Name" in header + assert "Workspace URL" in header + # Profile names ljust-padded to the longest name (17 chars). + name_width = max(len(name) for _, name in profiles) + assert "alpha".ljust(name_width) in choices[1].title + assert profiles[0][0] in choices[1].title + assert "beta-profile-name".ljust(name_width) in choices[2].title + assert profiles[1][0] in choices[2].title + # Final fallback entry still present. + assert choices[3].title == "Enter a different URL" + + def test_keeps_duplicate_hosts_as_separate_rows(self, monkeypatch): + profiles = [ + ("https://shared.cloud.databricks.com", "first"), + ("https://shared.cloud.databricks.com", "second"), + ] + captured = self._capture_select(monkeypatch, answer=profiles[1]) + url, profile = prompt_for_workspace("setup", profiles) + + assert (url, profile) == profiles[1] + # Both rows present — duplicates not collapsed. + choices = captured["choices"] + # Filter to choices whose value is a (host, profile) tuple — drops the + # header separator and the trailing "Enter a different URL" entry. + host_choices = [c for c in choices if isinstance(getattr(c, "value", None), tuple)] + assert [c.value for c in host_choices] == profiles + + def test_returns_normalized_url_with_profile(self, monkeypatch): + # Picker handed back a URL with a trailing slash — normalize_workspace_url + # should strip it before returning. + profiles = [("https://example.cloud.databricks.com/", "p")] + self._capture_select(monkeypatch, answer=profiles[0]) + url, profile = prompt_for_workspace("setup", profiles) + assert url == "https://example.cloud.databricks.com" + assert profile == "p" From 6334acb395dabf63708cd20977fcf21b2bd243a7 Mon Sep 17 00:00:00 2001 From: Anthony Ivan Date: Thu, 28 May 2026 23:29:34 +0800 Subject: [PATCH 3/4] Address PR #114 review: clamp name column, match typed URL to profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cap profile-name column at 40 chars with ellipsis truncation so a long name can't push the URL column off-screen on an 80-col terminal. Value tuple still carries the full untruncated name. - In the "Enter a different URL" branch, if the typed URL matches exactly one known profile, return that profile so downstream `--profile` calls resolve unambiguously. Multi-match falls through to host-based lookup. - Add regression test locking in picker → configure_shared_state(profile=…) flow so a future refactor can't silently drop the profile. Co-authored-by: Isaac --- src/ucode/ui.py | 28 ++++++++++++++++--- tests/test_cli.py | 33 +++++++++++++++++++++++ tests/test_ui.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/ucode/ui.py b/src/ucode/ui.py index 022cd8c..09ee375 100644 --- a/src/ucode/ui.py +++ b/src/ucode/ui.py @@ -193,14 +193,28 @@ def prompt_for_workspace( if profiles: name_header = "Profile Name" url_header = "Workspace URL" - name_width = max(len(name_header), *(len(name) for _, name in profiles)) + # Clamp so a single very long profile name can't push the URL column + # off-screen on an 80-col terminal — questionary doesn't wrap row + # titles cleanly, and a wrapped row breaks the picker visually. + max_name_width = 40 + name_width = min( + max_name_width, + max(len(name_header), *(len(name) for _, name in profiles)), + ) # Match the 2-char cursor gutter so the header line aligns with rows. header_title = f" {name_header.ljust(name_width)} {url_header}" choices: list[questionary.Choice | questionary.Separator] = [ questionary.Separator(header_title) ] for host, profile_name in profiles: - row_title = f"{profile_name.ljust(name_width)} {host}" + display_name = ( + profile_name + if len(profile_name) <= name_width + else profile_name[: name_width - 1] + "…" + ) + row_title = f"{display_name.ljust(name_width)} {host}" + # Value carries the full untruncated profile name so downstream + # `--profile` calls always use the real name, not the display form. choices.append(questionary.Choice(title=row_title, value=(host, profile_name))) choices.append(questionary.Choice(title="Enter a different URL", value=None)) style = questionary.Style( @@ -221,9 +235,17 @@ def prompt_for_workspace( while True: raw_value = console.input(f" [bold]Workspace URL[/bold] {muted('›')} ").strip() try: - return normalize_workspace_url(raw_value), None + url = normalize_workspace_url(raw_value) except ValueError as exc: print_err(str(exc)) + continue + # If the typed URL matches exactly one known profile, attach it so + # downstream `--profile` calls resolve unambiguously. Multi-match + # cases stay on the existing host-based fallback to avoid silently + # picking the wrong profile. + matches = [name for host, name in (profiles or []) if host == url] + matched_profile = matches[0] if len(matches) == 1 else None + return url, matched_profile def prompt_for_tools(available: list[tuple[str, str]]) -> list[str]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 6b335a8..20a5918 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -602,6 +602,39 @@ def test_unavailable_selected_tool_errors_before_configure(self, monkeypatch): with pytest.raises(RuntimeError, match="Codex"): cli_mod.configure_workspace_command(selected_tools=["claude", "codex"]) + def test_picker_selected_profile_flows_to_configure_shared_state(self, monkeypatch): + """Picker's (host, profile) tuple must reach configure_shared_state's + `profile` kwarg, otherwise downstream --profile calls fall back to + host-based resolution and silently pick the wrong profile.""" + import ucode.cli as cli_mod + + monkeypatch.setattr( + cli_mod, + "_prompt_for_configuration", + lambda tool=None: ("https://shared.cloud.databricks.com", "picked-profile"), + ) + captured: dict = {} + + def fake_configure_shared_state(workspace, profile=None, tools=None, force_login=False): + captured["workspace"] = workspace + captured["profile"] = profile + return {**MINIMAL_STATE, "workspace": workspace, "profile": profile} + + monkeypatch.setattr(cli_mod, "configure_shared_state", fake_configure_shared_state) + monkeypatch.setattr(cli_mod, "save_state", lambda state: None) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda state, tool: True) + monkeypatch.setattr(cli_mod, "prompt_for_tools", lambda available: ["claude"]) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *args, **kwargs: True) + monkeypatch.setattr( + cli_mod, + "configure_selected_tools", + lambda state, tools: {**state, "available_tools": tools}, + ) + monkeypatch.setattr(cli_mod, "validate_all_tools", lambda state: None) + + assert cli_mod.configure_workspace_command() == 0 + assert captured["profile"] == "picked-profile" + def test_multiple_workspaces_configure_all_and_use_first(self, monkeypatch): import ucode.cli as cli_mod diff --git a/tests/test_ui.py b/tests/test_ui.py index 6dfd905..3847fc8 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -280,3 +280,72 @@ def test_no_profiles_goes_straight_to_manual_prompt(self): url, profile = prompt_for_workspace("desc", profiles=None) assert url == "https://example.databricks.com" assert profile is None + + # ------------------------------------------------------------------ + # Long-name display clamping (PR #114 review feedback) + # ------------------------------------------------------------------ + + def test_long_profile_name_is_truncated_in_display_only(self, monkeypatch): + # 60-char name — exceeds the 40-char clamp. The displayed row title + # must be truncated with an ellipsis but the value tuple must carry + # the full untruncated name through to configure_shared_state. + long_name = "x" * 60 + profiles = [("https://a.cloud.databricks.com", long_name)] + captured = self._capture_select(monkeypatch, answer=profiles[0]) + url, profile = prompt_for_workspace("setup", profiles) + + assert (url, profile) == profiles[0] + choices = captured["choices"] + # Header + 1 row + "Enter a different URL". + assert len(choices) == 3 + # Display title is truncated to 40 chars (39 of name + "…"). + row_title = choices[1].title + assert long_name not in row_title + assert "…" in row_title + # Value tuple still carries the full name. + assert choices[1].value == profiles[0] + + # ------------------------------------------------------------------ + # Typed-URL → known-profile match (PR #114 review feedback) + # ------------------------------------------------------------------ + + def test_typed_url_matching_single_profile_returns_that_profile(self): + profiles = [ + ("https://a.cloud.databricks.com", "prof-a"), + ("https://b.cloud.databricks.com", "prof-b"), + ] + with ( + patch("ucode.ui.questionary.select") as mock_select, + patch("ucode.ui.console.input", return_value="https://a.cloud.databricks.com"), + ): + mock_select.return_value.ask.return_value = None # falls through to manual + url, profile = prompt_for_workspace("desc", profiles=profiles) + assert url == "https://a.cloud.databricks.com" + assert profile == "prof-a" + + def test_typed_url_matching_multiple_profiles_returns_none(self): + # When the typed URL matches multiple profiles, we can't safely + # auto-pick one — fall through to host-based resolution downstream. + profiles = [ + ("https://shared.cloud.databricks.com", "first"), + ("https://shared.cloud.databricks.com", "second"), + ] + with ( + patch("ucode.ui.questionary.select") as mock_select, + patch("ucode.ui.console.input", return_value="https://shared.cloud.databricks.com"), + ): + mock_select.return_value.ask.return_value = None + url, profile = prompt_for_workspace("desc", profiles=profiles) + assert url == "https://shared.cloud.databricks.com" + assert profile is None + + def test_typed_url_with_no_matching_profile_returns_none(self): + profiles = [("https://a.cloud.databricks.com", "prof-a")] + with ( + patch("ucode.ui.questionary.select") as mock_select, + patch("ucode.ui.console.input", return_value="https://other.databricks.com"), + ): + mock_select.return_value.ask.return_value = None + url, profile = prompt_for_workspace("desc", profiles=profiles) + assert url == "https://other.databricks.com" + assert profile is None From 9109f74b8df6a033c7f12062dc9f6535a83b121a Mon Sep 17 00:00:00 2001 From: Anthony Ivan Date: Thu, 28 May 2026 23:58:43 +0800 Subject: [PATCH 4/4] Drop redundant typed-URL profile-match in prompt_for_workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `configure_shared_state` already calls `find_profile_name_for_host` when profile is None (cli.py:161-162), which does the exact same host-to-profile lookup. The inline match was equivalent code at a different layer — removing it. Co-authored-by: Isaac --- src/ucode/ui.py | 10 +--------- tests/test_ui.py | 44 -------------------------------------------- 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/src/ucode/ui.py b/src/ucode/ui.py index 09ee375..1d85152 100644 --- a/src/ucode/ui.py +++ b/src/ucode/ui.py @@ -235,17 +235,9 @@ def prompt_for_workspace( while True: raw_value = console.input(f" [bold]Workspace URL[/bold] {muted('›')} ").strip() try: - url = normalize_workspace_url(raw_value) + return normalize_workspace_url(raw_value), None except ValueError as exc: print_err(str(exc)) - continue - # If the typed URL matches exactly one known profile, attach it so - # downstream `--profile` calls resolve unambiguously. Multi-match - # cases stay on the existing host-based fallback to avoid silently - # picking the wrong profile. - matches = [name for host, name in (profiles or []) if host == url] - matched_profile = matches[0] if len(matches) == 1 else None - return url, matched_profile def prompt_for_tools(available: list[tuple[str, str]]) -> list[str]: diff --git a/tests/test_ui.py b/tests/test_ui.py index 3847fc8..935455f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -305,47 +305,3 @@ def test_long_profile_name_is_truncated_in_display_only(self, monkeypatch): # Value tuple still carries the full name. assert choices[1].value == profiles[0] - # ------------------------------------------------------------------ - # Typed-URL → known-profile match (PR #114 review feedback) - # ------------------------------------------------------------------ - - def test_typed_url_matching_single_profile_returns_that_profile(self): - profiles = [ - ("https://a.cloud.databricks.com", "prof-a"), - ("https://b.cloud.databricks.com", "prof-b"), - ] - with ( - patch("ucode.ui.questionary.select") as mock_select, - patch("ucode.ui.console.input", return_value="https://a.cloud.databricks.com"), - ): - mock_select.return_value.ask.return_value = None # falls through to manual - url, profile = prompt_for_workspace("desc", profiles=profiles) - assert url == "https://a.cloud.databricks.com" - assert profile == "prof-a" - - def test_typed_url_matching_multiple_profiles_returns_none(self): - # When the typed URL matches multiple profiles, we can't safely - # auto-pick one — fall through to host-based resolution downstream. - profiles = [ - ("https://shared.cloud.databricks.com", "first"), - ("https://shared.cloud.databricks.com", "second"), - ] - with ( - patch("ucode.ui.questionary.select") as mock_select, - patch("ucode.ui.console.input", return_value="https://shared.cloud.databricks.com"), - ): - mock_select.return_value.ask.return_value = None - url, profile = prompt_for_workspace("desc", profiles=profiles) - assert url == "https://shared.cloud.databricks.com" - assert profile is None - - def test_typed_url_with_no_matching_profile_returns_none(self): - profiles = [("https://a.cloud.databricks.com", "prof-a")] - with ( - patch("ucode.ui.questionary.select") as mock_select, - patch("ucode.ui.console.input", return_value="https://other.databricks.com"), - ): - mock_select.return_value.ask.return_value = None - url, profile = prompt_for_workspace("desc", profiles=profiles) - assert url == "https://other.databricks.com" - assert profile is None