Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .cursor/skills/send-it/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,19 @@ If the user chose **Stage only**, stop here.
- Run `git pull origin` to sync the current branch (ignore errors if the remote branch doesn't exist yet).
- Run `git push -u origin HEAD` to push (sets upstream if needed).

## 5. PR creation (if needed)
## 5. PR creation or update (if needed)

- Check if a PR exists: `gh pr view --json url --repo cognitedata/toolkit 2>/dev/null`.
- Check if a PR exists: `gh pr view --json url,body --repo cognitedata/toolkit 2>/dev/null`.
- **If a PR already exists**, update its description to reflect the current state of the branch:
- Read the existing body, compare it to `git log main..HEAD --oneline` and the staged changes.
- If the description is stale or incomplete, propose an updated body and apply it with:

```bash
gh api repos/cognitedata/toolkit/pulls/<number> --method PATCH --field body='...'
```

- Always use the GitHub API directly (`gh api`) rather than `gh pr edit` to avoid GraphQL
deprecation warnings causing false failures.
- **If no PR exists**, suggest creating one:
- Ask the user for the Jira ticket ID.
- Propose a title: `[TICKET-ID] Description of changes`.
Expand Down
3 changes: 3 additions & 0 deletions cognite_toolkit/_cdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,14 @@
)
from cognite_toolkit._cdf_tk.feature_flags import Flags
from cognite_toolkit._cdf_tk.plugins import Plugins
from cognite_toolkit._cdf_tk.ui import apply_questionary_toolkit_defaults
from cognite_toolkit._cdf_tk.utils import (
sentry_exception_filter,
)
from cognite_toolkit._version import __version__ as current_version

apply_questionary_toolkit_defaults()

if USE_SENTRY:
import sentry_sdk

Expand Down
11 changes: 8 additions & 3 deletions cognite_toolkit/_cdf_tk/commands/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def _start_message(build_dir: Path, dry_run: bool, env_vars: EnvironmentVariable
content = f"[bold]{verb}[/] resource files from {build_dir} directory."
if not _RUNNING_IN_BROWSER:
content += f"\n\nConnected to {env_vars.as_string()}"
print(ToolkitPanel(content, title="Deploy"))
print(ToolkitPanel(content, title="Deploy", expand=False))

def clean_all_resources(
self,
Expand All @@ -192,7 +192,12 @@ def clean_all_resources(
return None
flags = "--drop and --drop-data" if (drop and drop_data) else ("--drop" if drop else "--drop-data")
print(
ToolkitPanel(f"Cleaning resources as {flags} is passed", title="Clean", border_style=AuraColor.AMBER.rich)
ToolkitPanel(
f"Cleaning resources as {flags} is passed",
title="Clean",
border_style=AuraColor.AMBER.rich,
expand=False,
)
)

for loader_cls in reversed(ordered_loaders):
Expand Down Expand Up @@ -282,7 +287,7 @@ def deploy_all_resources(

"""
if verbose:
print(ToolkitPanel("[bold]Deploying resources...[/]", title="Deploy"))
print(ToolkitPanel("[bold]Deploying resources...[/]", title="Deploy", expand=False))

if ordered_loaders is None:
selected_loaders = self._clean_command.get_selected_loaders(build_dir, set(), None)
Expand Down
49 changes: 46 additions & 3 deletions cognite_toolkit/_cdf_tk/ui.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from collections.abc import Sequence
from enum import Enum
from typing import Any, ClassVar, Literal
from typing import Any, ClassVar, Literal, cast

import questionary
import questionary.constants as qc
import questionary.styles as qstyles
from prompt_toolkit.styles import Style as PTStyle
from prompt_toolkit.styles import merge_styles
from rich import box as rich_box
from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderableType, RenderResult
from rich.padding import Padding, PaddingDimensions
Expand All @@ -16,7 +20,10 @@
"AuraColor",
"ToolkitPanel",
"ToolkitPanelSection",
"ToolkitQuestion",
"ToolkitTable",
"apply_questionary_toolkit_defaults",
"checkbox_follow_pointer",
"hanging_indent",
]

Expand Down Expand Up @@ -131,17 +138,53 @@ def as_panel_detail(self) -> RenderableType:
return Padding(self, (1, 0, 1, 2))


# "reverse" keeps the active row visible when truecolor (fg:#...) is ignored or low-contrast
# (macOS Terminal / iTerm2); see CDF-27852.
_QUESTIONARY_POINTER_HIGHLIGHT = f"reverse bold {AuraColor.FJORD.fg}"

QUESTIONARY_STYLE = questionary.Style(
[
("qmark", AuraColor.FJORD.fg), # token in front of the question
("question", "bold"), # question text
("answer", f"{AuraColor.NORDIC.fg} bold"), # submitted answer text behind the question
("pointer", f"{AuraColor.FJORD.fg} bold"), # pointer used in select and checkbox prompts
("highlighted", f"{AuraColor.FJORD.fg} bold"), # pointed-at choice in select and checkbox prompts
("pointer", _QUESTIONARY_POINTER_HIGHLIGHT), # pointer used in select and checkbox prompts
("highlighted", _QUESTIONARY_POINTER_HIGHLIGHT), # pointed-at choice in select and checkbox prompts
("selected", AuraColor.NORDIC.fg), # style for a selected item of a checkbox
("separator", AuraColor.MOUNTAIN.fg), # separator in lists
("instruction", ""), # user instructions for select, rawselect, checkbox
("text", ""), # plain text
("disabled", f"{AuraColor.MOUNTAIN.fg} italic"), # disabled choices for select and checkbox prompts
]
)


class ToolkitQuestion:
"""Namespace for questionary prompts using :data:`QUESTIONARY_STYLE` (CDF-27852).

- :meth:`select` — single-choice lists.
- :meth:`checkbox_follow_pointer` — checkbox UI where the checked row follows the highlight.
"""

@staticmethod
def select(*args: Any, **kwargs: Any) -> questionary.Question:
kwargs.setdefault("style", QUESTIONARY_STYLE)
return questionary.select(*args, **kwargs)

@staticmethod
def checkbox_follow_pointer(*args: Any, **kwargs: Any) -> questionary.Question:
kwargs.setdefault("style", QUESTIONARY_STYLE)
from cognite_toolkit._cdf_tk.ui_checkbox_follow_pointer import _checkbox_follow_pointer

return _checkbox_follow_pointer(*args, **kwargs)


def checkbox_follow_pointer(*args: Any, **kwargs: Any) -> questionary.Question:
"""Checkbox prompt where the checked row follows the highlight (alias of :meth:`ToolkitQuestion.checkbox_follow_pointer`)."""
return ToolkitQuestion.checkbox_follow_pointer(*args, **kwargs)


def apply_questionary_toolkit_defaults() -> None:
"""Merge Toolkit questionary styles into library defaults for every prompt."""
merged = cast(PTStyle, merge_styles([qc.DEFAULT_STYLE, QUESTIONARY_STYLE]))
qc.DEFAULT_STYLE = merged
qstyles.DEFAULT_STYLE = merged
248 changes: 248 additions & 0 deletions cognite_toolkit/_cdf_tk/ui_checkbox_follow_pointer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""Checkbox prompt where the checked row tracks the keyboard highlight.

Implementation module for :meth:`ToolkitQuestion.checkbox_follow_pointer`.
"""

import string
from collections.abc import Callable, Sequence
from typing import Any, Union

from prompt_toolkit.application import Application
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.styles import Style
from questionary import utils
from questionary.constants import DEFAULT_QUESTION_PREFIX, DEFAULT_SELECTED_POINTER, INVALID_INPUT
from questionary.prompts import common
from questionary.prompts.common import Choice, InquirerControl, Separator
from questionary.question import Question
from questionary.styles import merge_styles_default


class PointerSyncedInquirerControl(InquirerControl):
"""Keep ``selected_options`` equal to the highlighted row after each move."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
if self.is_selection_valid():
self._sync_selection_to_pointer()

def select_next(self) -> None:
super().select_next()
self._sync_selection_to_pointer()

def select_previous(self) -> None:
super().select_previous()
self._sync_selection_to_pointer()

def _sync_selection_to_pointer(self) -> None:
if not self.is_selection_valid():
return
choice = self.get_pointed_at()
self.selected_options = [choice.value]


def _checkbox_follow_pointer(
message: str,
choices: Sequence[Union[str, Choice, dict[str, Any]]],
default: str | None = None,
validate: Callable[[list[str]], Union[bool, str]] = lambda a: True,
qmark: str = DEFAULT_QUESTION_PREFIX,
pointer: str | None = DEFAULT_SELECTED_POINTER,
style: Style | None = None,
initial_choice: Union[str, Choice, dict[str, Any]] | None = None,
use_arrow_keys: bool = True,
use_jk_keys: bool = True,
use_emacs_keys: bool = True,
use_search_filter: Union[str, bool, None] = False,
instruction: str | None = None,
show_description: bool = True,
**kwargs: Any,
) -> Question:
"""Like ``questionary.checkbox``, but moving the pointer updates the selection."""
if not (use_arrow_keys or use_jk_keys or use_emacs_keys):
raise ValueError("Some option to move the selection is required. Arrow keys or j/k or Emacs keys.")

if use_jk_keys and use_search_filter:
raise ValueError("Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix.")

merged_style = merge_styles_default(
[
Style([("bottom-toolbar", "noreverse")]),
style,
]
)

if not callable(validate):
raise ValueError("validate must be callable")

ic = PointerSyncedInquirerControl(
choices,
default,
pointer=pointer,
initial_choice=initial_choice,
show_description=show_description,
)

def get_prompt_tokens() -> list[tuple[str, str]]:
tokens = []

tokens.append(("class:qmark", qmark))
tokens.append(("class:question", f" {message} "))

if ic.is_answered:
nbr_selected = len(ic.selected_options)
if nbr_selected == 0:
tokens.append(("class:answer", "done"))
elif nbr_selected == 1:
if isinstance(ic.get_selected_values()[0].title, list):
ts = ic.get_selected_values()[0].title
tokens.append(
(
"class:answer",
"".join([token[1] for token in ts]), # type:ignore
)
)
else:
tokens.append(
(
"class:answer",
f"[{ic.get_selected_values()[0].title}]",
)
)
else:
tokens.append(("class:answer", f"done ({nbr_selected} selections)"))
else:
if instruction is not None:
tokens.append(("class:instruction", instruction))
else:
tokens.append(
(
"class:instruction",
"(Arrow keys move; highlighted row is selected; "
"<space> toggles; "
f"<{'ctrl-a' if use_search_filter else 'a'}> all; "
f"<{'ctrl-i' if use_search_filter else 'i'}> invert"
f"{', type to filter' if use_search_filter else ''})",
)
)
return tokens

def get_selected_values() -> list[Any]:
return [c.value for c in ic.get_selected_values()]

def perform_validation(selected_values: list[str]) -> bool:
verdict = validate(selected_values)
valid = verdict is True

if not valid:
if verdict is False:
error_text = INVALID_INPUT
else:
error_text = str(verdict)

error_message = FormattedText([("class:validation-toolbar", error_text)])

ic.error_message = (
error_message if not valid and ic.submission_attempted else None # type: ignore[assignment]
)

return valid

layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs)

bindings = KeyBindings()

@bindings.add(Keys.ControlQ, eager=True)
@bindings.add(Keys.ControlC, eager=True)
def _(event):
event.app.exit(exception=KeyboardInterrupt, style="class:aborting")

@bindings.add(" ", eager=True)
def toggle(_event):
pointed_choice = ic.get_pointed_at().value
if pointed_choice in ic.selected_options:
ic.selected_options.remove(pointed_choice)
else:
ic.selected_options.append(pointed_choice)

perform_validation(get_selected_values())

@bindings.add(Keys.ControlI if use_search_filter else "i", eager=True)
def invert(_event):
inverted_selection = [
c.value
for c in ic.choices
if not isinstance(c, Separator) and c.value not in ic.selected_options and not c.disabled
]
ic.selected_options = inverted_selection

perform_validation(get_selected_values())

@bindings.add(Keys.ControlA if use_search_filter else "a", eager=True)
def all(_event):
all_selected = True
for c in ic.choices:
if not isinstance(c, Separator) and c.value not in ic.selected_options and not c.disabled:
ic.selected_options.append(c.value)
all_selected = False
if all_selected:
ic.selected_options = []

perform_validation(get_selected_values())

def move_cursor_down(event):
ic.select_next()
while not ic.is_selection_valid():
ic.select_next()

def move_cursor_up(event):
ic.select_previous()
while not ic.is_selection_valid():
ic.select_previous()

if use_search_filter:

def search_filter(event):
ic.add_search_character(event.key_sequence[0].key)

for character in string.printable:
if character in string.whitespace:
continue
bindings.add(character, eager=True)(search_filter)
bindings.add(Keys.Backspace, eager=True)(search_filter)

if use_arrow_keys:
bindings.add(Keys.Down, eager=True)(move_cursor_down)
bindings.add(Keys.Up, eager=True)(move_cursor_up)

if use_jk_keys:
bindings.add("j", eager=True)(move_cursor_down)
bindings.add("k", eager=True)(move_cursor_up)

if use_emacs_keys:
bindings.add(Keys.ControlN, eager=True)(move_cursor_down)
bindings.add(Keys.ControlP, eager=True)(move_cursor_up)

@bindings.add(Keys.ControlM, eager=True)
def set_answer(event):
selected_values = get_selected_values()
ic.submission_attempted = True

if perform_validation(selected_values):
ic.is_answered = True
event.app.exit(result=selected_values)

@bindings.add(Keys.Any)
def other(_event):
"""Disallow inserting other text."""

return Question(
Application(
layout=layout,
key_bindings=bindings,
style=merged_style,
**utils.used_kwargs(kwargs, Application.__init__),
)
)
Loading
Loading