diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 607da5f7..03fdbc7c 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* The SQL panel in `.app()` is now an editable code editor. Users can tweak the generated SQL directly and apply it with Ctrl/Cmd+Enter or by clicking away — no extra button required. The editor stays in sync when the LLM updates the query or the active table changes. (#265) + * `QueryChat()` now supports **multiple related tables**. Register additional tables with `add_table()` and the LLM can reason across all of them — joins, cross-table filters, aggregations. Per-table reactive state (`df()`, `sql()`, `title()`) is accessible via `qc_vals.table("name")` on the value returned by `server()`. For SQLAlchemy engines and Ibis backends, `add_tables()` registers all tables (or a named subset) in a single call. (#195) ```python diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index ced3361f..7fae2205 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -5,7 +5,6 @@ from narwhals.stable.v1.typing import IntoDataFrameT, IntoFrameT, IntoLazyFrameT from shiny.express._stub_session import ExpressStubSession from shiny.session import get_current_session -from shinychat import output_markdown_stream from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui @@ -297,8 +296,11 @@ def app_ui(request): ui.output_text("query_title", inline=True), class_="d-flex align-items-center gap-2", ), - ui.output_ui("ui_reset", inline=True), - class_="hstack gap-3", + ui.div( + ui.output_ui("ui_reset", inline=True), + class_="ms-auto", + ), + class_="hstack gap-3 w-100", ), ), ui.output_ui("sql_output"), @@ -321,7 +323,7 @@ def app_ui(request): def app_server(input: Inputs, output: Outputs, session: Session): self._mark_server_initialized() if enable_bookmarking: - session.bookmark.exclude.append("reset_query") + session.bookmark.exclude.extend(["reset_query", "sql_editor"]) vals = mod_server( self.id, data_sources=dict(self._data_sources), @@ -368,16 +370,35 @@ def dt(): # Collect lazy sources (LazyFrame, Ibis Table) to eager DataFrame return as_narwhals(vals.table(active_table_name()).df()) + def sql_text_for_editor(name: str) -> str: + return vals.table(name).sql() or f"SELECT * FROM {name}" + @render.ui def sql_output(): name = active_table_name() - sql_value = vals.table(name).sql() or f"SELECT * FROM {name}" - sql_code = f"```sql\n{sql_value}\n```" - return output_markdown_stream( - "sql_code", - content=sql_code, - auto_scroll=False, - width="100%", + with reactive.isolate(): + sql_text = sql_text_for_editor(name) + return ui.input_code_editor( + "sql_editor", + value=sql_text, + language="sql", + line_numbers=False, + height="auto", + ) + + @reactive.effect + def sync_sql_editor(): + name = active_table_name() + ui.update_code_editor("sql_editor", value=sql_text_for_editor(name)) + + @reactive.effect + @reactive.event(input.sql_editor) + def _(): + name = active_table_name() + query = input.sql_editor() + default_query = f"SELECT * FROM {name}" + vals._tables[name].sql.set( + query if query and query.strip() != default_query else None ) return App(app_ui, app_server, bookmark_store=bookmark_store) diff --git a/pkg-py/tests/playwright/test_01_hello_app.py b/pkg-py/tests/playwright/test_01_hello_app.py index bfc9e6a2..28912065 100644 --- a/pkg-py/tests/playwright/test_01_hello_app.py +++ b/pkg-py/tests/playwright/test_01_hello_app.py @@ -45,8 +45,8 @@ def test_welcome_message_appears(self) -> None: def test_default_sql_query_shown(self) -> None: """INIT-03: SQL panel shows default query.""" - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text("SELECT * FROM titanic") + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value(re.compile(r"SELECT \* FROM titanic")) def test_data_table_populated(self) -> None: """INIT-04: Table shows data rows.""" @@ -85,8 +85,8 @@ def test_submit_via_send_button(self) -> None: self.chat.send_user_input(method="click") # SQL should filter by sex = 'female' - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*sex.*=.*['\"]?female['\"]?", re.IGNORECASE), timeout=60000, ) @@ -97,8 +97,8 @@ def test_submit_via_click_survivors(self) -> None: self.chat.send_user_input(method="click") # SQL should filter by survived = 1 (or TRUE) - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*survived.*=.*(1|TRUE)", re.IGNORECASE), timeout=60000 ) @@ -110,8 +110,8 @@ def test_filter_query(self) -> None: self.chat.send_user_input(method="click") # SQL should filter by survived = 1 (or TRUE) - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*survived.*=.*(1|TRUE)", re.IGNORECASE), timeout=60000 ) @@ -144,8 +144,8 @@ def test_latest_message_after_query(self) -> None: self.chat.send_user_input(method="click") # SQL should filter by class/pclass = 1 or 'First' - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*(p?class).*=.*(1|['\"]First['\"])", re.IGNORECASE), timeout=60000, ) @@ -176,8 +176,8 @@ def test_table_updates_on_query(self) -> None: self.chat.send_user_input(method="click") # SQL should filter by class/pclass = 1 or 'First' - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*(p?class).*=.*(1|['\"]First['\"])", re.IGNORECASE), timeout=60000, ) @@ -195,8 +195,8 @@ def test_sql_updates_on_query(self) -> None: self.chat.send_user_input(method="click") # SQL should filter by sex = 'male' - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*sex.*=.*['\"]?male['\"]?", re.IGNORECASE), timeout=60000 ) @@ -207,8 +207,8 @@ def test_apply_filter_button_re_applies_filter(self) -> None: self.chat.set_user_input("Show only first class passengers") self.chat.send_user_input(method="click") - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text( + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value( re.compile(r"WHERE.*(p?class).*=.*(1|['\"]First['\"])", re.IGNORECASE), timeout=60000, ) diff --git a/pkg-py/tests/playwright/test_02_prompt_app.py b/pkg-py/tests/playwright/test_02_prompt_app.py index 76ac596b..4454b107 100644 --- a/pkg-py/tests/playwright/test_02_prompt_app.py +++ b/pkg-py/tests/playwright/test_02_prompt_app.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING import pytest @@ -42,8 +43,8 @@ def test_custom_greeting_appears(self) -> None: def test_default_sql_query_shown(self) -> None: """INIT-03: SQL panel shows default query.""" - sql_code = self.page.locator("pre code").first - expect(sql_code).to_contain_text("SELECT * FROM titanic") + sql_code = self.page.locator("bslib-code-editor#sql_editor textarea") + expect(sql_code).to_have_value(re.compile(r"SELECT \* FROM titanic")) def test_chat_input_visible(self) -> None: """INIT-04: Chat input is visible.""" diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 601b391d..7f8ba58a 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -2,6 +2,8 @@ ## New features +* The SQL panel in `querychat_app()` is now an editable code editor. Users can tweak the generated SQL directly and apply it with Ctrl/Cmd+Enter or by clicking away — no extra button required. The editor stays in sync when the LLM updates the query or the active table changes. (#265) + * `QueryChat$new()` now supports **multiple related tables**. Register additional tables with `$add_table()` and the LLM can reason across all of them — joins, cross-table filters, aggregations. Per-table reactive state (`$df()`, `$sql()`, `$title()`) is accessible via `qc_vals$table("name")` on the list returned by `$server()`. For DBI connections, `$add_tables()` registers all tables (or a named subset) in a single call. (#195) ```r diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index d5a7303b..9e0731bc 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -730,7 +730,7 @@ QueryChat <- R6::R6Class( style = bslib::css(max_height = "33%"), bslib::card_header( shiny::div( - class = "hstack", + class = "hstack w-100", shiny::div( bsicons::bs_icon("terminal-fill"), shiny::textOutput("query_title", inline = TRUE) @@ -764,7 +764,11 @@ QueryChat <- R6::R6Class( } server <- function(input, output, session) { - shiny::setBookmarkExclude(c("close_btn", "reset_query")) + shiny::setBookmarkExclude(c( + "close_btn", + "reset_query", + "sql_editor" + )) enable_bookmarking <- bookmark_store %in% c("url", "server") qc_vals <- self$server(enable_bookmarking = enable_bookmarking) @@ -809,20 +813,41 @@ QueryChat <- R6::R6Class( ) }) + sql_text_for_editor <- function(name) { + sql <- qc_vals$.tables[[name]]$sql() + if (shiny::isTruthy(sql)) sql else paste("SELECT * FROM", name) + } + output$sql_output <- shiny::renderUI({ name <- active_table_name() - sql <- qc_vals$.tables[[name]]$sql() - sql_text <- if (shiny::isTruthy(sql)) { - sql - } else { - paste("SELECT * FROM", name) - } - sql_code <- paste(c("```sql", sql_text, "```"), collapse = "\n") - shinychat::output_markdown_stream( - "sql_code", - content = sql_code, - auto_scroll = FALSE, - width = "100%" + sql_text <- shiny::isolate(sql_text_for_editor(name)) + bslib::input_code_editor( + "sql_editor", + value = sql_text, + language = "sql", + line_numbers = FALSE, + height = "auto" + ) + }) + + shiny::observe(label = "sync_sql_editor", { + name <- active_table_name() + bslib::update_code_editor( + "sql_editor", + value = sql_text_for_editor(name) + ) + }) + + shiny::observeEvent(input$sql_editor, label = "on_sql_editor", { + name <- active_table_name() + query <- input$sql_editor + default_query <- paste("SELECT * FROM", name) + qc_vals$.tables[[name]]$sql( + if (nzchar(query %||% "") && trimws(query) != default_query) { + query + } else { + NULL + } ) })