sqlsaber 0.20.0__tar.gz → 0.22.0__tar.gz

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.

Potentially problematic release.


This version of sqlsaber might be problematic. Click here for more details.

Files changed (107) hide show
  1. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/PKG-INFO +1 -1
  2. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/changelog.md +17 -0
  3. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/getting-started.mdx +17 -9
  4. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/queries.mdx +6 -3
  5. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/index.mdx +2 -2
  6. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/pyproject.toml +1 -1
  7. sqlsaber-0.22.0/sqlsaber.gif +0 -0
  8. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/pydantic_ai_agent.py +5 -0
  9. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/display.py +180 -13
  10. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/interactive.py +10 -8
  11. sqlsaber-0.22.0/src/sqlsaber/cli/streaming.py +176 -0
  12. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/database/connection.py +105 -12
  13. sqlsaber-0.22.0/tests/test_database/test_timeout.py +124 -0
  14. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/uv.lock +902 -902
  15. sqlsaber-0.20.0/sqlsaber.gif +0 -0
  16. sqlsaber-0.20.0/src/sqlsaber/cli/streaming.py +0 -133
  17. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.github/workflows/claude-code-review.yml +0 -0
  18. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.github/workflows/claude.yml +0 -0
  19. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.github/workflows/deploy-docs.yml +0 -0
  20. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.github/workflows/publish.yml +0 -0
  21. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.github/workflows/test.yml +0 -0
  22. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.gitignore +0 -0
  23. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/.python-version +0 -0
  24. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/AGENT.md +0 -0
  25. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/CLAUDE.md +0 -0
  26. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/LICENSE +0 -0
  27. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/README.md +0 -0
  28. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/.gitignore +0 -0
  29. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/.vscode/extensions.json +0 -0
  30. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/.vscode/launch.json +0 -0
  31. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/CLAUDE.md +0 -0
  32. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/astro.config.mjs +0 -0
  33. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/package-lock.json +0 -0
  34. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/package.json +0 -0
  35. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/public/CNAME +0 -0
  36. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/public/favicon.svg +0 -0
  37. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/assets/sqlsaber-hero.svg +0 -0
  38. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/authentication.mdx +0 -0
  39. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/database-setup.mdx +0 -0
  40. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/memory.mdx +0 -0
  41. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/models.mdx +0 -0
  42. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/threads.md +0 -0
  43. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/installation.mdx +0 -0
  44. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content/docs/reference/commands.md +0 -0
  45. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/content.config.ts +0 -0
  46. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/src/styles/global.css +0 -0
  47. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/docs/tsconfig.json +0 -0
  48. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/legislators.db +0 -0
  49. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/pytest.ini +0 -0
  50. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/sqlsaber.svg +0 -0
  51. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/__init__.py +0 -0
  52. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/__main__.py +0 -0
  53. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/__init__.py +0 -0
  54. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/base.py +0 -0
  55. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/mcp.py +0 -0
  56. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/__init__.py +0 -0
  57. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/auth.py +0 -0
  58. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/commands.py +0 -0
  59. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/completers.py +0 -0
  60. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/database.py +0 -0
  61. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/memory.py +0 -0
  62. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/models.py +0 -0
  63. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/threads.py +0 -0
  64. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/__init__.py +0 -0
  65. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/api_keys.py +0 -0
  66. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/auth.py +0 -0
  67. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/database.py +0 -0
  68. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/oauth_flow.py +0 -0
  69. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/oauth_tokens.py +0 -0
  70. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/providers.py +0 -0
  71. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/config/settings.py +0 -0
  72. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/database/__init__.py +0 -0
  73. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/database/resolver.py +0 -0
  74. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/database/schema.py +0 -0
  75. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/mcp/__init__.py +0 -0
  76. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/mcp/mcp.py +0 -0
  77. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/memory/__init__.py +0 -0
  78. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/memory/manager.py +0 -0
  79. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/memory/storage.py +0 -0
  80. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/threads/__init__.py +0 -0
  81. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/threads/storage.py +0 -0
  82. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/__init__.py +0 -0
  83. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/base.py +0 -0
  84. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/enums.py +0 -0
  85. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/instructions.py +0 -0
  86. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/registry.py +0 -0
  87. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/sql_tools.py +0 -0
  88. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/__init__.py +0 -0
  89. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/conftest.py +0 -0
  90. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_cli/__init__.py +0 -0
  91. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_cli/test_auth_reset.py +0 -0
  92. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_cli/test_commands.py +0 -0
  93. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_cli/test_threads.py +0 -0
  94. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_config/__init__.py +0 -0
  95. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_config/test_database.py +0 -0
  96. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_config/test_oauth.py +0 -0
  97. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_config/test_providers.py +0 -0
  98. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_config/test_settings.py +0 -0
  99. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_database/__init__.py +0 -0
  100. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_database/test_connection.py +0 -0
  101. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_database_resolver.py +0 -0
  102. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_threads_storage.py +0 -0
  103. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_tools/__init__.py +0 -0
  104. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_tools/test_base.py +0 -0
  105. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_tools/test_instructions.py +0 -0
  106. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_tools/test_registry.py +0 -0
  107. {sqlsaber-0.20.0 → sqlsaber-0.22.0}/tests/test_tools/test_sql_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: SQLsaber - Open-source agentic SQL assistant
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -7,6 +7,23 @@ All notable changes to SQLsaber will be documented here.
7
7
 
8
8
  ### Unreleased
9
9
 
10
+ ### v0.22.0 - 2025-09-15
11
+
12
+ #### Added
13
+
14
+ - Query timeout protection to prevent runaway queries
15
+ - 30-second timeout applied to all database operations by default
16
+ - Both client-side and server-side timeout enforcement where supported (PostgreSQL, MySQL)
17
+ - Per-query timeout override parameter for edge cases
18
+ - Automatic rollback of transactions when queries timeout
19
+
20
+ ### v0.21.0 - 2025-09-15
21
+
22
+ #### Changed
23
+
24
+ - Use Responses API for OpenAI models
25
+ - Stream markdown while streaming response from models
26
+
10
27
  ---
11
28
 
12
29
  ### v0.20.0 - 2025-09-10
@@ -3,8 +3,7 @@ title: Getting Started
3
3
  description: Your first steps with SQLsaber
4
4
  ---
5
5
 
6
- import { Aside } from '@astrojs/starlight/components';
7
-
6
+ import { Aside } from "@astrojs/starlight/components";
8
7
 
9
8
  Welcome to SQLsaber! This guide will walk you through setting up SQLsaber and running your first query.
10
9
 
@@ -27,15 +26,18 @@ saber auth setup
27
26
  ```
28
27
 
29
28
  This will prompt you to:
29
+
30
30
  - Choose a provider (We recommend Anthropic)
31
31
  - Enter your API key
32
32
 
33
33
  <Aside type="tip">
34
- If you have a Claude Pro or Max subscription, you can use it with SQLsaber. It is one of the most cost-effective options.
34
+ If you have a Claude Pro or Max subscription, you can use it with SQLsaber. It
35
+ is one of the most cost-effective options.
35
36
  </Aside>
36
37
 
37
38
  <Aside type="note">
38
- If you choose a provider other than Anthropic, please select the appropriate model for the provider by running `saber models set`.
39
+ If you choose a provider other than Anthropic, please select the appropriate
40
+ model for the provider by running `saber models set`.
39
41
  </Aside>
40
42
 
41
43
  #### 2. Add Your Database
@@ -47,10 +49,11 @@ saber db add my-database
47
49
  ```
48
50
 
49
51
  This interactive command will ask you for:
52
+
50
53
  - Database type
51
- - PostgreSQL
52
- - MySQL
53
- - SQLite
54
+ - PostgreSQL
55
+ - MySQL
56
+ - SQLite
54
57
  - Connection details (host, port, database name, username)
55
58
  - SSL configuration (if needed)
56
59
 
@@ -63,6 +66,7 @@ curl -L -o legislators.db https://github.com/SarthakJariwala/sqlsaber/raw/refs/h
63
66
  # Add it to SQLsaber
64
67
  saber db add legislators --type sqlite --database ./legislators.db
65
68
  ```
69
+
66
70
  </Aside>
67
71
 
68
72
  #### 3. Your First Query
@@ -82,14 +86,15 @@ Try asking a question:
82
86
  ```
83
87
 
84
88
  SQLsaber will:
89
+
85
90
  1. Analyze your database schema
86
91
  2. Generate appropriate SQL
87
92
  3. Execute the query
88
93
  4. Present the results and analysis
89
94
 
90
95
  <script
91
- src="https://asciinema.org/a/739273.js"
92
- id="asciicast-739273"
96
+ src="https://asciinema.org/a/739399.js"
97
+ id="asciicast-739399"
93
98
  async="true"
94
99
  ></script>
95
100
 
@@ -142,18 +147,21 @@ Now that you have SQLsaber running, explore these features:
142
147
  #### Common Issues
143
148
 
144
149
  **"No database connections configured"**
150
+
145
151
  ```bash
146
152
  saber db list # Check configured databases
147
153
  saber db add my-db # Add a new database
148
154
  ```
149
155
 
150
156
  **"Authentication not configured"**
157
+
151
158
  ```bash
152
159
  saber auth status # Check auth status
153
160
  saber auth setup # Set up authentication
154
161
  ```
155
162
 
156
163
  **"Database connection failed"**
164
+
157
165
  ```bash
158
166
  saber db test my-database # Test the connection
159
167
  ```
@@ -3,7 +3,7 @@ title: Running Queries
3
3
  description: Learn different ways to query your data with SQLsaber
4
4
  ---
5
5
 
6
- import { Aside } from '@astrojs/starlight/components';
6
+ import { Aside } from "@astrojs/starlight/components";
7
7
 
8
8
  SQLsaber offers multiple ways to query your database using natural language. This guide covers all the different query modes and their use cases.
9
9
 
@@ -16,8 +16,8 @@ saber
16
16
  ```
17
17
 
18
18
  <script
19
- src="https://asciinema.org/a/739273.js"
20
- id="asciicast-739273"
19
+ src="https://asciinema.org/a/739399.js"
20
+ id="asciicast-739399"
21
21
  async="true"
22
22
  ></script>
23
23
 
@@ -30,6 +30,7 @@ saber "How many orders were placed last week?"
30
30
  ```
31
31
 
32
32
  Useful for:
33
+
33
34
  - Shell scripting
34
35
  - Quick checks
35
36
  - Automated reporting
@@ -50,6 +51,7 @@ curl -s https://api.example.com/query | saber
50
51
  ```
51
52
 
52
53
  Useful for:
54
+
53
55
  - Shell scripting
54
56
  - Batch processing
55
57
  - Integration with other tools
@@ -82,6 +84,7 @@ Type `@` followed by a table name to get autocomplete suggestions:
82
84
  ```
83
85
 
84
86
  Supports schema-aware completions:
87
+
85
88
  ```
86
89
  > @pub[TAB] → @public.customers
87
90
  ```
@@ -25,8 +25,8 @@ import {
25
25
  } from "@astrojs/starlight/components";
26
26
 
27
27
  <script
28
- src="https://asciinema.org/a/739273.js"
29
- id="asciicast-739273"
28
+ src="https://asciinema.org/a/739399.js"
29
+ id="asciicast-739399"
30
30
  async="true"
31
31
  ></script>
32
32
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlsaber"
3
- version = "0.20.0"
3
+ version = "0.22.0"
4
4
  description = "SQLsaber - Open-source agentic SQL assistant"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
Binary file
@@ -8,6 +8,7 @@ import httpx
8
8
  from pydantic_ai import Agent, RunContext
9
9
  from pydantic_ai.models.anthropic import AnthropicModel
10
10
  from pydantic_ai.models.google import GoogleModel
11
+ from pydantic_ai.models.openai import OpenAIResponsesModel
11
12
  from pydantic_ai.providers.anthropic import AnthropicProvider
12
13
  from pydantic_ai.providers.google import GoogleProvider
13
14
 
@@ -79,6 +80,10 @@ def build_sqlsaber_agent(
79
80
  provider_obj = AnthropicProvider(api_key="placeholder", http_client=http_client)
80
81
  model_obj = AnthropicModel(model_name_only, provider=provider_obj)
81
82
  agent = Agent(model_obj, name="sqlsaber")
83
+ elif provider == "openai":
84
+ # Use OpenAI Responses Model for structured output capabilities
85
+ model_obj = OpenAIResponsesModel(model_name_only)
86
+ agent = Agent(model_obj, name="sqlsaber")
82
87
  else:
83
88
  agent = Agent(cfg.model_name, name="sqlsaber")
84
89
 
@@ -1,12 +1,167 @@
1
- """Display utilities for the CLI interface."""
1
+ """Display utilities for the CLI interface.
2
+
3
+ All rendering occurs on the event loop thread.
4
+ Streaming segments use Live Markdown; transient status and SQL blocks are also
5
+ rendered with Live.
6
+ """
2
7
 
3
8
  import json
9
+ from typing import Sequence, Type
4
10
 
5
- from rich.console import Console
6
- from rich.markdown import Markdown
11
+ from pydantic_ai.messages import ModelResponsePart, TextPart
12
+ from rich.columns import Columns
13
+ from rich.console import Console, ConsoleOptions, RenderResult
14
+ from rich.live import Live
15
+ from rich.markdown import CodeBlock, Markdown
7
16
  from rich.panel import Panel
17
+ from rich.spinner import Spinner
8
18
  from rich.syntax import Syntax
9
19
  from rich.table import Table
20
+ from rich.text import Text
21
+
22
+
23
+ class _SimpleCodeBlock(CodeBlock):
24
+ def __rich_console__(
25
+ self, console: Console, options: ConsoleOptions
26
+ ) -> RenderResult:
27
+ code = str(self.text).rstrip()
28
+ yield Syntax(
29
+ code,
30
+ self.lexer_name,
31
+ theme=self.theme,
32
+ background_color="default",
33
+ word_wrap=True,
34
+ )
35
+
36
+
37
+ class LiveMarkdownRenderer:
38
+ """Handles Live markdown rendering with segment separation.
39
+
40
+ Supports different segment kinds: 'assistant', 'thinking', 'sql'.
41
+ Adds visible paragraph breaks between segments and renders code fences
42
+ with nicer formatting.
43
+ """
44
+
45
+ _patched_fences = False
46
+
47
+ def __init__(self, console: Console):
48
+ self.console = console
49
+ self._live: Live | None = None
50
+ self._status_live: Live | None = None
51
+ self._buffer: str = ""
52
+ self._current_kind: Type[ModelResponsePart] | None = None
53
+
54
+ def prepare_code_blocks(self) -> None:
55
+ """Patch rich Markdown fence rendering once for nicer code blocks."""
56
+ if LiveMarkdownRenderer._patched_fences:
57
+ return
58
+ # Guard with class check to avoid re-patching if already applied
59
+ if Markdown.elements.get("fence") is not _SimpleCodeBlock:
60
+ Markdown.elements["fence"] = _SimpleCodeBlock
61
+ LiveMarkdownRenderer._patched_fences = True
62
+
63
+ def ensure_segment(self, kind: Type[ModelResponsePart]) -> None:
64
+ """
65
+ Ensure a markdown Live segment is active for the given kind.
66
+
67
+ When switching kinds, end the previous segment and add a paragraph break.
68
+ """
69
+ # If a transient status is showing, clear it first (no paragraph break)
70
+ if self._status_live is not None:
71
+ self.end_status()
72
+ if self._live is not None and self._current_kind == kind:
73
+ return
74
+ if self._live is not None:
75
+ self.end()
76
+ self.paragraph_break()
77
+
78
+ self._start()
79
+ self._current_kind = kind
80
+
81
+ def append(self, text: str | None) -> None:
82
+ """Append text to the current markdown segment and refresh."""
83
+ if not text:
84
+ return
85
+ if self._live is None:
86
+ # default to assistant if no segment was ensured
87
+ self.ensure_segment(TextPart)
88
+
89
+ self._buffer += text
90
+ self._live.update(Markdown(self._buffer))
91
+
92
+ def end(self) -> None:
93
+ """Finalize and stop the current Live segment, if any."""
94
+ if self._live is None:
95
+ return
96
+ if self._buffer:
97
+ self._live.update(Markdown(self._buffer))
98
+ self._live.stop()
99
+ self._live = None
100
+ self._buffer = ""
101
+ self._current_kind = None
102
+
103
+ def end_if_active(self) -> None:
104
+ self.end()
105
+
106
+ def paragraph_break(self) -> None:
107
+ self.console.print()
108
+
109
+ def start_sql_block(self, sql: str) -> None:
110
+ """Render a SQL block using a transient Live markdown segment."""
111
+ if not sql or not isinstance(sql, str) or not sql.strip():
112
+ return
113
+ # Separate from surrounding content
114
+ self.end_if_active()
115
+ self.paragraph_break()
116
+ self._buffer = f"```sql\n{sql}\n```"
117
+ # Use context manager to auto-stop and persist final render
118
+ with Live(
119
+ Markdown(self._buffer),
120
+ console=self.console,
121
+ vertical_overflow="visible",
122
+ refresh_per_second=12,
123
+ ):
124
+ pass
125
+
126
+ def start_status(self, message: str = "Crunching data...") -> None:
127
+ """Show a transient status line with a spinner until streaming starts."""
128
+ if self._status_live is not None:
129
+ # Update existing status text
130
+ self._status_live.update(self._status_renderable(message))
131
+ return
132
+ live = Live(
133
+ self._status_renderable(message),
134
+ console=self.console,
135
+ transient=True, # disappear when stopped
136
+ refresh_per_second=12,
137
+ )
138
+ self._status_live = live
139
+ live.start()
140
+
141
+ def end_status(self) -> None:
142
+ live = self._status_live
143
+ if live is None:
144
+ return
145
+ live.stop()
146
+ self._status_live = None
147
+
148
+ def _status_renderable(self, message: str):
149
+ spinner = Spinner("dots", style="yellow")
150
+ text = Text(f" {message}", style="yellow")
151
+ return Columns([spinner, text], expand=False)
152
+
153
+ def _start(self, initial_markdown: str = "") -> None:
154
+ if self._live is not None:
155
+ self.end()
156
+ self._buffer = initial_markdown or ""
157
+ live = Live(
158
+ Markdown(self._buffer),
159
+ console=self.console,
160
+ vertical_overflow="visible",
161
+ refresh_per_second=12,
162
+ )
163
+ self._live = live
164
+ live.start()
10
165
 
11
166
 
12
167
  class DisplayManager:
@@ -14,10 +169,11 @@ class DisplayManager:
14
169
 
15
170
  def __init__(self, console: Console):
16
171
  self.console = console
172
+ self.live = LiveMarkdownRenderer(console)
17
173
 
18
174
  def _create_table(
19
175
  self,
20
- columns: list,
176
+ columns: Sequence[str | dict[str, str]],
21
177
  header_style: str = "bold blue",
22
178
  title: str | None = None,
23
179
  ) -> Table:
@@ -34,17 +190,24 @@ class DisplayManager:
34
190
 
35
191
  def show_tool_executing(self, tool_name: str, tool_input: dict):
36
192
  """Display tool execution details."""
37
- self.console.print(f"\n[yellow]🔧 Using tool: {tool_name}[/yellow]")
193
+ # Normalized leading blank line before tool headers
194
+ self.show_newline()
38
195
  if tool_name == "list_tables":
39
- self.console.print("[dim] → Discovering available tables[/dim]")
196
+ self.console.print(
197
+ "[dim bold]:gear: Discovering available tables[/dim bold]"
198
+ )
40
199
  elif tool_name == "introspect_schema":
41
200
  pattern = tool_input.get("table_pattern", "all tables")
42
- self.console.print(f"[dim] → Examining schema for: {pattern}[/dim]")
201
+ self.console.print(
202
+ f"[dim bold]:gear: Examining schema for: {pattern}[/dim bold]"
203
+ )
43
204
  elif tool_name == "execute_sql":
205
+ # For streaming, we render SQL via LiveMarkdownRenderer; keep Syntax
206
+ # rendering for threads show/resume. Controlled by include_sql flag.
44
207
  query = tool_input.get("query", "")
45
- self.console.print("\n[bold green]Executing SQL:[/bold green]")
208
+ self.console.print("[dim bold]:gear: Executing SQL:[/dim bold]")
46
209
  self.show_newline()
47
- syntax = Syntax(query, "sql")
210
+ syntax = Syntax(query, "sql", background_color="default", word_wrap=True)
48
211
  self.console.print(syntax)
49
212
 
50
213
  def show_text_stream(self, text: str):
@@ -99,10 +262,12 @@ class DisplayManager:
99
262
  """Display a newline for spacing."""
100
263
  self.console.print()
101
264
 
102
- def show_table_list(self, tables_data: str):
265
+ def show_table_list(self, tables_data: str | dict):
103
266
  """Display the results from list_tables tool."""
104
267
  try:
105
- data = json.loads(tables_data)
268
+ data = (
269
+ json.loads(tables_data) if isinstance(tables_data, str) else tables_data
270
+ )
106
271
 
107
272
  # Handle error case
108
273
  if "error" in data:
@@ -143,10 +308,12 @@ class DisplayManager:
143
308
  except Exception as e:
144
309
  self.show_error(f"Error displaying table list: {str(e)}")
145
310
 
146
- def show_schema_info(self, schema_data: str):
311
+ def show_schema_info(self, schema_data: str | dict):
147
312
  """Display the results from introspect_schema tool."""
148
313
  try:
149
- data = json.loads(schema_data)
314
+ data = (
315
+ json.loads(schema_data) if isinstance(schema_data, str) else schema_data
316
+ )
150
317
 
151
318
  # Handle error case
152
319
  if "error" in data:
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  from pathlib import Path
5
+ from textwrap import dedent
5
6
 
6
7
  import platformdirs
7
8
  from prompt_toolkit import PromptSession
@@ -10,6 +11,7 @@ from prompt_toolkit.patch_stdout import patch_stdout
10
11
  from prompt_toolkit.styles import Style
11
12
  from pydantic_ai import Agent
12
13
  from rich.console import Console
14
+ from rich.markdown import Markdown
13
15
  from rich.panel import Panel
14
16
 
15
17
  from sqlsaber.cli.completers import (
@@ -102,14 +104,14 @@ class InteractiveSession:
102
104
  )
103
105
  )
104
106
  self.console.print(
105
- "\n",
106
- "[dim] > Use '/clear' to reset conversation",
107
- "[dim] > Use 'Ctrl+D', '/exit' or '/quit' to leave[/dim]",
108
- "[dim] > Use 'Ctrl+C' to interrupt and return to prompt\n\n",
109
- "[dim] > Start message with '#' to add something to agent's memory for this database",
110
- "[dim] > Type '@' to get table name completions",
111
- "[dim] > Press 'Esc-Enter' or 'Meta-Enter' to submit your question",
112
- sep="\n",
107
+ Markdown(
108
+ dedent("""
109
+ - Use `/` for slash commands
110
+ - Type `@` to get table name completions
111
+ - Start message with `#` to add something to agent's memory
112
+ - Use `Ctrl+C` to interrupt and `Ctrl+D` to exit
113
+ """)
114
+ )
113
115
  )
114
116
 
115
117
  self.console.print(
@@ -0,0 +1,176 @@
1
+ """Streaming query handling for the CLI (pydantic-ai based).
2
+
3
+ This module uses DisplayManager's LiveMarkdownRenderer to stream Markdown
4
+ incrementally as the agent outputs tokens. Tool calls and results are
5
+ rendered via DisplayManager helpers.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ from functools import singledispatchmethod
11
+ from typing import AsyncIterable
12
+
13
+ from pydantic_ai import Agent, RunContext
14
+ from pydantic_ai.messages import (
15
+ AgentStreamEvent,
16
+ FunctionToolCallEvent,
17
+ FunctionToolResultEvent,
18
+ PartDeltaEvent,
19
+ PartStartEvent,
20
+ TextPart,
21
+ TextPartDelta,
22
+ ThinkingPart,
23
+ ThinkingPartDelta,
24
+ )
25
+ from rich.console import Console
26
+
27
+ from sqlsaber.cli.display import DisplayManager
28
+
29
+
30
+ class StreamingQueryHandler:
31
+ """
32
+ Handles streaming query execution and display using pydantic-ai events.
33
+
34
+ Uses DisplayManager.live to render Markdown incrementally as text streams in.
35
+ """
36
+
37
+ def __init__(self, console: Console):
38
+ self.console = console
39
+ self.display = DisplayManager(console)
40
+
41
+ async def _event_stream_handler(
42
+ self, ctx: RunContext, event_stream: AsyncIterable[AgentStreamEvent]
43
+ ) -> None:
44
+ """
45
+ Handle pydantic-ai streaming events and update Live Markdown via DisplayManager.
46
+ """
47
+
48
+ async for event in event_stream:
49
+ await self.on_event(event, ctx)
50
+
51
+ # --- Event routing via singledispatchmethod ---------------------------------------
52
+ @singledispatchmethod
53
+ async def on_event(
54
+ self, event: AgentStreamEvent, ctx: RunContext
55
+ ) -> None: # default
56
+ return
57
+
58
+ @on_event.register
59
+ async def _(self, event: PartStartEvent, ctx: RunContext) -> None:
60
+ if isinstance(event.part, TextPart):
61
+ self.display.live.ensure_segment(TextPart)
62
+ self.display.live.append(event.part.content)
63
+ elif isinstance(event.part, ThinkingPart):
64
+ self.display.live.ensure_segment(ThinkingPart)
65
+ self.display.live.append(event.part.content)
66
+
67
+ @on_event.register
68
+ async def _(self, event: PartDeltaEvent, ctx: RunContext) -> None:
69
+ d = event.delta
70
+ if isinstance(d, TextPartDelta):
71
+ delta = d.content_delta or ""
72
+ if delta:
73
+ self.display.live.ensure_segment(TextPart)
74
+ self.display.live.append(delta)
75
+ elif isinstance(d, ThinkingPartDelta):
76
+ delta = d.content_delta or ""
77
+ if delta:
78
+ self.display.live.ensure_segment(ThinkingPart)
79
+ self.display.live.append(delta)
80
+
81
+ @on_event.register
82
+ async def _(self, event: FunctionToolCallEvent, ctx: RunContext) -> None:
83
+ # Clear any status/markdown Live so tool output sits between
84
+ self.display.live.end_status()
85
+ self.display.live.end_if_active()
86
+ args = event.part.args_as_dict()
87
+
88
+ # Special handling: display SQL via Live as markdown code block
89
+ if event.part.tool_name == "execute_sql":
90
+ query = args.get("query") or ""
91
+ if isinstance(query, str) and query.strip():
92
+ self.display.live.start_sql_block(query)
93
+ else:
94
+ self.display.show_tool_executing(event.part.tool_name, args)
95
+
96
+ @on_event.register
97
+ async def _(self, event: FunctionToolResultEvent, ctx: RunContext) -> None:
98
+ # Route tool result to appropriate display
99
+ tool_name = event.result.tool_name
100
+ content = event.result.content
101
+ if tool_name == "list_tables":
102
+ self.display.show_table_list(content)
103
+ elif tool_name == "introspect_schema":
104
+ self.display.show_schema_info(content)
105
+ elif tool_name == "execute_sql":
106
+ data = {}
107
+ if isinstance(content, str):
108
+ try:
109
+ data = json.loads(content)
110
+ except (json.JSONDecodeError, TypeError) as exc:
111
+ try:
112
+ self.console.log(f"Malformed execute_sql result: {exc}")
113
+ except Exception:
114
+ pass
115
+ elif isinstance(content, dict):
116
+ data = content
117
+ if isinstance(data, dict) and data.get("success") and data.get("results"):
118
+ self.display.show_query_results(data["results"]) # type: ignore[arg-type]
119
+ # Add a blank line after tool output to separate from next segment
120
+ self.display.show_newline()
121
+ # Show status while agent sends a follow-up request to the model
122
+ self.display.live.start_status("Crunching data...")
123
+
124
+ async def execute_streaming_query(
125
+ self,
126
+ user_query: str,
127
+ agent: Agent,
128
+ cancellation_token: asyncio.Event | None = None,
129
+ message_history: list | None = None,
130
+ ):
131
+ # Prepare nicer code block rendering for Markdown
132
+ self.display.live.prepare_code_blocks()
133
+ try:
134
+ # If Anthropic OAuth, inject SQLsaber instructions before the first user prompt
135
+ prepared_prompt: str | list[str] = user_query
136
+ is_oauth = bool(getattr(agent, "_sqlsaber_is_oauth", False))
137
+ no_history = not message_history
138
+ if is_oauth and no_history:
139
+ ib = getattr(agent, "_sqlsaber_instruction_builder", None)
140
+ mm = getattr(agent, "_sqlsaber_memory_manager", None)
141
+ db_type = getattr(agent, "_sqlsaber_db_type", "database")
142
+ db_name = getattr(agent, "_sqlsaber_database_name", None)
143
+ instructions = (
144
+ ib.build_instructions(db_type=db_type) if ib is not None else ""
145
+ )
146
+ mem = (
147
+ mm.format_memories_for_prompt(db_name)
148
+ if (mm is not None and db_name)
149
+ else ""
150
+ )
151
+ parts = [p for p in (instructions, mem) if p and str(p).strip()]
152
+ if parts:
153
+ injected = "\n\n".join(parts)
154
+ prepared_prompt = [injected, user_query]
155
+
156
+ # Show a transient status until events start streaming
157
+ self.display.live.start_status("Crunching data...")
158
+
159
+ # Run the agent with our event stream handler
160
+ run = await agent.run(
161
+ prepared_prompt,
162
+ message_history=message_history,
163
+ event_stream_handler=self._event_stream_handler,
164
+ )
165
+ return run
166
+ except asyncio.CancelledError:
167
+ # Show interruption message outside of Live
168
+ self.display.show_newline()
169
+ self.console.print("[yellow]Query interrupted[/yellow]")
170
+ return None
171
+ finally:
172
+ # End any active status and live markdown segments
173
+ try:
174
+ self.display.live.end_status()
175
+ finally:
176
+ self.display.live.end_if_active()