From 8a87435c58e7cfc18f1732ce71507f904cc0826b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 26 Jun 2026 12:19:06 -0400 Subject: [PATCH 1/6] feat: editable SQL query in \$app() Replace the read-only SQL markdown display with a `bslib::input_code_editor()` that lets users edit the SQL directly. The editor submits on blur or Ctrl/Cmd+Enter. - `sync_sql_editor` observer keeps the editor in sync via `update_code_editor()` when the LLM updates SQL, the query is reset, or the active table switches - `on_sql_editor` observer applies user edits to `sql_val()`; treats the default `SELECT * FROM ` as NULL to prevent the Reset button from reappearing after a reset (update_code_editor triggers input$sql_editor on the server) - Editor excluded from bookmarking in both R and Python Closes #70 --- pkg-py/src/querychat/_shiny.py | 43 +++++++++++++++++++++++-------- pkg-r/R/QueryChat.R | 46 +++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 25 deletions(-) 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-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index d5a7303b..a1b0cd5e 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,34 @@ 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 ) }) From cb293c8dcc71811a21a25ceac55a1a70b6b54fb5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 26 Jun 2026 12:32:44 -0400 Subject: [PATCH 2/6] style(pkg-r): air format QueryChat.R --- pkg-r/R/QueryChat.R | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index a1b0cd5e..9e0731bc 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -832,7 +832,10 @@ QueryChat <- R6::R6Class( shiny::observe(label = "sync_sql_editor", { name <- active_table_name() - bslib::update_code_editor("sql_editor", value = sql_text_for_editor(name)) + bslib::update_code_editor( + "sql_editor", + value = sql_text_for_editor(name) + ) }) shiny::observeEvent(input$sql_editor, label = "on_sql_editor", { @@ -840,7 +843,11 @@ QueryChat <- R6::R6Class( 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 + if (nzchar(query %||% "") && trimws(query) != default_query) { + query + } else { + NULL + } ) }) From 1146719774f048d5f981a22e589457b7508821b8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 26 Jun 2026 12:43:32 -0400 Subject: [PATCH 3/6] fix(pkg-py): update E2E selectors for CodeMirror editor `pre code` no longer exists; SQL is now rendered in `input_code_editor` which uses a `.cm-content` element. --- pkg-py/tests/playwright/test_01_hello_app.py | 16 ++++++++-------- pkg-py/tests/playwright/test_02_prompt_app.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg-py/tests/playwright/test_01_hello_app.py b/pkg-py/tests/playwright/test_01_hello_app.py index bfc9e6a2..8173be61 100644 --- a/pkg-py/tests/playwright/test_01_hello_app.py +++ b/pkg-py/tests/playwright/test_01_hello_app.py @@ -45,7 +45,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text("SELECT * FROM titanic") def test_data_table_populated(self) -> None: @@ -85,7 +85,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( re.compile(r"WHERE.*sex.*=.*['\"]?female['\"]?", re.IGNORECASE), timeout=60000, @@ -97,7 +97,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( re.compile(r"WHERE.*survived.*=.*(1|TRUE)", re.IGNORECASE), timeout=60000 ) @@ -110,7 +110,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( re.compile(r"WHERE.*survived.*=.*(1|TRUE)", re.IGNORECASE), timeout=60000 ) @@ -144,7 +144,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( re.compile(r"WHERE.*(p?class).*=.*(1|['\"]First['\"])", re.IGNORECASE), timeout=60000, @@ -176,7 +176,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( re.compile(r"WHERE.*(p?class).*=.*(1|['\"]First['\"])", re.IGNORECASE), timeout=60000, @@ -195,7 +195,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( re.compile(r"WHERE.*sex.*=.*['\"]?male['\"]?", re.IGNORECASE), timeout=60000 ) @@ -207,7 +207,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text( 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..07462adf 100644 --- a/pkg-py/tests/playwright/test_02_prompt_app.py +++ b/pkg-py/tests/playwright/test_02_prompt_app.py @@ -42,7 +42,7 @@ 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 + sql_code = self.page.locator(".cm-content").first expect(sql_code).to_contain_text("SELECT * FROM titanic") def test_chat_input_visible(self) -> None: From eeeedb350e224ee9aba3280a1d09b79ddc1bbe5c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 26 Jun 2026 13:08:50 -0400 Subject: [PATCH 4/6] fix(pkg-py): fix E2E selectors for bslib code editor bslib's input_code_editor renders as a custom element using prism-code-editor (not CodeMirror). The editable value lives in the textarea inside the element; switch from to_contain_text() to to_have_value() accordingly. --- pkg-py/tests/playwright/test_01_hello_app.py | 32 +++++++++---------- pkg-py/tests/playwright/test_02_prompt_app.py | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pkg-py/tests/playwright/test_01_hello_app.py b/pkg-py/tests/playwright/test_01_hello_app.py index 8173be61..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(".cm-content").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(".cm-content").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(".cm-content").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(".cm-content").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(".cm-content").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(".cm-content").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(".cm-content").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(".cm-content").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 07462adf..a116abfd 100644 --- a/pkg-py/tests/playwright/test_02_prompt_app.py +++ b/pkg-py/tests/playwright/test_02_prompt_app.py @@ -42,8 +42,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(".cm-content").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.""" From 3b3bfe73fbf6b279efd94c63a332d0c60dcd767e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 26 Jun 2026 13:09:02 -0400 Subject: [PATCH 5/6] fix(pkg-py): add missing re import in test_02_prompt_app --- pkg-py/tests/playwright/test_02_prompt_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg-py/tests/playwright/test_02_prompt_app.py b/pkg-py/tests/playwright/test_02_prompt_app.py index a116abfd..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 From 00ebf3f61c236519505480504fee7492ac926d4e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 26 Jun 2026 13:17:10 -0400 Subject: [PATCH 6/6] docs: add NEWS and CHANGELOG bullets for editable SQL editor --- pkg-py/CHANGELOG.md | 2 ++ pkg-r/NEWS.md | 2 ++ 2 files changed, 4 insertions(+) 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-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