From b4cb1f1d3686d32f6565d2a74bd9ef7b620cbf86 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:56:55 +0800 Subject: [PATCH 01/75] docs: add RFC 0008 and specifications for AI TUI mode --- docs/ai.md | 16 ++++++++++++ docs/architecture.md | 5 +++- docs/cli-spec.md | 31 +++++++++++++++++++++++ docs/rfcs/0008-ai-tui.md | 54 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 docs/rfcs/0008-ai-tui.md diff --git a/docs/ai.md b/docs/ai.md index 0a31991..801db0e 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -105,3 +105,19 @@ xsql web ``` Web UI 复用 xsql 的 profile、SSH、只读策略和结构化错误契约,但其 HTTP API 面向浏览器,不等同于 MCP 协议。 + +## AI TUI 交互模式 (xsql-ai) +xsql 提供了交互式 AI 终端模式 `xsql-ai`(也可通过 `xsql ai` 运行)。用户只需在终端以自然语言发问,AI 结合当前数据库 Schema 结构自动构建对应的 SQL 查询,并在 TUI 中提供交互预览与安全执行: + +```bash +# 启动交互式 TUI +xsql-ai --profile dev +xsql ai --profile dev +``` + +### 快捷键操作 +- `Enter`: 提交自然语言需求给 AI +- `Ctrl+E`: 确认并安全执行当前生成预览的 SQL +- `Ctrl+R`: 切换到 SQL 文本手工微调模式 +- `Esc` / `Ctrl+C`: 退出 AI 模式 + diff --git a/docs/architecture.md b/docs/architecture.md index 5e640e1..b590660 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,9 +6,12 @@ ## 建议目录结构 ``` -/cmd/xsql # CLI 入口 +/cmd/xsql # CLI 主程序入口 +/cmd/xsql-ai # AI TUI 独立程序入口 /internal/app # 应用编排(解析参数->执行->返回结构化结果) /internal/config # 配置加载/合并/校验 + profiles +/internal/ai # AI LLM 客户端与 Context Prompt 组装 +/internal/tui # Bubbletea TUI 交互式终端UI实现 /internal/secret # keyring/加密/明文兼容 /internal/db # driver registry + 执行引擎 /internal/db/mysql # MySQL 驱动实现 diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 5a96834..3eb5b1f 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -546,6 +546,37 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au - 写操作需要显式设置 `unsafe_allow_write: true` - Streamable HTTP 传输要求鉴权,请在请求中提供 `Authorization: Bearer ` 头 +### `xsql ai` / `xsql-ai` + +启动交互式 AI 终端模式(TUI)或单次 AI SQL 查询能力。通过自然语言与 AI 对话,由 AI 基于当前数据库的 Schema 结构自动构建 SQL 查询,并在终端进行可视化预览与安全执行。 + +```bash +# 启动交互式 TUI 模式 +xsql ai --profile dev +# 或者使用独立二进制程序 +xsql-ai --profile dev + +# 指定 AI 模型和服务地址 +xsql-ai --profile dev --model deepseek-coder --base-url https://api.deepseek.com/v1 + +# 单次自然语言提问模式 +xsql-ai --profile dev "查一下近7天注册的用户数量" +``` + +**Flags:** +| Flag | 默认值 | 说明 | +|------|--------|------| +| `--profile` | - | Profile 名称(必需) | +| `--model` | `gpt-4o` | AI 模型名称(配置项:`ai.model`,ENV:`XSQL_AI_MODEL`) | +| `--base-url` | `https://api.openai.com/v1` | AI 服务端 Base URL(配置项:`ai.base_url`,ENV:`XSQL_AI_BASE_URL`) | +| `--api-key` | - | AI 服务 API Key(配置项:`ai.api_key`,ENV:`XSQL_AI_API_KEY`) | +| `--prompt` | - | 单次自然语言提问 Prompt(也可通过命令行位置参数传入) | +| `--unsafe-allow-write` | false | 允许写操作(绕过只读保护) | + +**安全与只读说明:** +- 默认启用只读 protection。AI 生成的 SQL 语句在运行(按 `Ctrl+E`)时将统一提交由底层只读策略审计及事务级只读操作校验。 +- API Key 在配置中支持 `keyring:ai_key` 密码存储。 + ## 参数来源优先级 - CLI > ENV > Config diff --git a/docs/rfcs/0008-ai-tui.md b/docs/rfcs/0008-ai-tui.md new file mode 100644 index 0000000..65b2c0d --- /dev/null +++ b/docs/rfcs/0008-ai-tui.md @@ -0,0 +1,54 @@ +# RFC 0008: AI TUI Interactive Mode and Standalone `xsql-ai` Binary + +Status: Accepted + +## 摘要 +本 RFC 提出为 `xsql` 增加交互式 AI 模式(TUI)及独立终端可执行程序 `cmd/xsql-ai`。用户可以通过自然语言在终端提问,AI 自动结合当前数据库的 Schema 结构生成相应的 SQL 语句,并在 TUI 界面中提供可视化预览、编辑与一键安全执行。 + +## 背景 / 动机 +- **当前痛点**:用户在面对复杂的数据库表结构时,手写 SQL 查询门槛较高,传统 CLI 查询缺少交互式的自然语言转 SQL 辅助。 +- **目标**: + 1. 提供独立终端程序 `xsql-ai` 及主程序子命令 `xsql ai`。 + 2. 支持标准 OpenAI 兼容接口配置(兼容 OpenAI, DeepSeek, Ollama 等)。 + 3. 自动抽取 Profile 对应的数据库 Schema,作为上下文送给 LLM 生成对应 DB 语法的 SQL。 + 4. 采用 Charm (Bubbletea / Lipgloss) 编写现代、美观、响应式的 TUI 界面,支持 SQL 预览、编辑与快捷执行。 + 5. 严格保留 `xsql` 既有的只读策略保护(双重只读拦截:SQL 静态检测 + 事务级 READ ONLY)。 +- **非目标**: + - 本 RFC 不试图在 CLI 内实现大模型训练或复杂的全表数据检索,仅聚焦于辅助 SQL 编写与交互式查询执行。 + +## 方案(Proposed) + +### 用户视角(CLI/配置/输出) +1. **独立程序与 CLI 命令**: + - 独立可执行程序:`xsql-ai -p [--prompt "query"]` + - 主程序子命令:`xsql ai -p ` +2. **配置文件扩展**: + 在 `xsql.yaml` 中新增 `ai` 配置块: + ```yaml + ai: + provider: openai + base_url: https://api.openai.com/v1 + api_key: keyring:ai_key + model: gpt-4o + max_tokens: 2048 + ``` +3. **优先级与凭据**: + - 合并优先级:`CLI flags > ENV (XSQL_AI_API_KEY, XSQL_AI_BASE_URL, XSQL_AI_MODEL) > Config`。 + - API Key 支持 `keyring:` 引用。 + +### 技术设计(Architecture) +- **涉及模块**: + - `cmd/xsql-ai/main.go`:独立 CLI 程序入口。 + - `cmd/xsql/ai.go`:`xsql ai` 适配入口。 + - `internal/config`:配置类型扩展与解析。 + - `internal/ai`:OpenAI HTTP 客户端、Prompt 组装服务。 + - `internal/tui`:Bubbletea UI 架构(Header、Viewport、SQL Preview Card、Textarea)。 + - `internal/app`:复用 `DumpSchema` 与 `Query`。 + +### 安全与隐私(Security/Privacy) +- 默认严格只读策略,除非显式设置 `--unsafe-allow-write`,否则不允许写 SQL 执行。 +- 不会把明文 API Key 输出在日志或错细节中。 + +### 测试计划(Test Plan) +- **单元测试**:配置合并解析、Prompt 组装、OpenAI 响应解析、TUI 状态机逻辑测试。 +- **E2E 测试**:在 `tests/e2e/ai_test.go` 中启动 Mock HTTP API Server 并通过 IO Pipe 输入按键模拟全闭环交互。 From 07e884037ce1a3b1773271cd1ff65d39d4094dc2 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:58:11 +0800 Subject: [PATCH 02/75] feat: add AIConfig and resolution logic to internal/config --- internal/config/resolve.go | 37 ++++++++++++++++++++- internal/config/resolve_test.go | 58 +++++++++++++++++++++++++++++++++ internal/config/types.go | 22 +++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/internal/config/resolve.go b/internal/config/resolve.go index b9e4f1e..accc0e2 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -102,5 +102,40 @@ func Resolve(opts Options) (Resolved, *errors.XError) { format = opts.CLIFormat } - return Resolved{ConfigPath: cfgPath, ProfileName: profile, Format: format, Profile: selectedProfile}, nil + // 5) Merge AI Config: CLI > ENV > Config > Default + aiConfig := cfg.AI + if aiConfig.Provider == "" { + aiConfig.Provider = "openai" + } + if aiConfig.BaseURL == "" { + aiConfig.BaseURL = "https://api.openai.com/v1" + } + if aiConfig.Model == "" { + aiConfig.Model = "gpt-4o" + } + if aiConfig.MaxTokens == 0 { + aiConfig.MaxTokens = 2048 + } + + if opts.EnvAIBaseURL != "" { + aiConfig.BaseURL = opts.EnvAIBaseURL + } + if opts.EnvAIModel != "" { + aiConfig.Model = opts.EnvAIModel + } + if opts.EnvAIAPIKey != "" { + aiConfig.APIKey = opts.EnvAIAPIKey + } + + if opts.CLIAIBaseURLSet && opts.CLIAIBaseURL != "" { + aiConfig.BaseURL = opts.CLIAIBaseURL + } + if opts.CLIAIModelSet && opts.CLIAIModel != "" { + aiConfig.Model = opts.CLIAIModel + } + if opts.CLIAIAPIKeySet && opts.CLIAIAPIKey != "" { + aiConfig.APIKey = opts.CLIAIAPIKey + } + + return Resolved{ConfigPath: cfgPath, ProfileName: profile, Format: format, Profile: selectedProfile, AI: aiConfig}, nil } diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index ceee2ba..a281b63 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -216,3 +216,61 @@ func TestResolve_Port_PreservedWhenSpecified(t *testing.T) { t.Errorf("expected port 13306 to be preserved, got %d", got.Profile.Port) } } + +func TestResolve_AIConfigPrecedence(t *testing.T) { + tmp := t.TempDir() + cfg := []byte(`ai: + base_url: "https://config.api.com" + model: "config-model" + api_key: "config-key" +`) + path := filepath.Join(tmp, "xsql.yaml") + if err := os.WriteFile(path, cfg, 0o600); err != nil { + t.Fatal(err) + } + + // 1. Config values + got, xe := Resolve(Options{WorkDir: tmp, HomeDir: tmp}) + if xe != nil { + t.Fatal(xe) + } + if got.AI.BaseURL != "https://config.api.com" || got.AI.Model != "config-model" || got.AI.APIKey != "config-key" { + t.Fatalf("unexpected AI config from yaml: %+v", got.AI) + } + + // 2. ENV overrides config + got, xe = Resolve(Options{ + WorkDir: tmp, + HomeDir: tmp, + EnvAIBaseURL: "https://env.api.com", + EnvAIModel: "env-model", + EnvAIAPIKey: "env-key", + }) + if xe != nil { + t.Fatal(xe) + } + if got.AI.BaseURL != "https://env.api.com" || got.AI.Model != "env-model" || got.AI.APIKey != "env-key" { + t.Fatalf("unexpected AI config from env: %+v", got.AI) + } + + // 3. CLI overrides ENV & Config + got, xe = Resolve(Options{ + WorkDir: tmp, + HomeDir: tmp, + EnvAIBaseURL: "https://env.api.com", + EnvAIModel: "env-model", + EnvAIAPIKey: "env-key", + CLIAIBaseURL: "https://cli.api.com", + CLIAIBaseURLSet: true, + CLIAIModel: "cli-model", + CLIAIModelSet: true, + CLIAIAPIKey: "cli-key", + CLIAIAPIKeySet: true, + }) + if xe != nil { + t.Fatal(xe) + } + if got.AI.BaseURL != "https://cli.api.com" || got.AI.Model != "cli-model" || got.AI.APIKey != "cli-key" { + t.Fatalf("unexpected AI config from cli: %+v", got.AI) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index a07d5db..15dbe7b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -10,6 +10,16 @@ type File struct { MCP MCPConfig `yaml:"mcp" json:"mcp"` Web WebConfig `yaml:"web" json:"web"` Stats stats.StatsConfig `yaml:"stats" json:"stats"` + AI AIConfig `yaml:"ai" json:"ai"` +} + +// AIConfig defines the AI LLM service configuration. +type AIConfig struct { + Provider string `yaml:"provider" json:"provider"` // default "openai" + BaseURL string `yaml:"base_url" json:"base_url"` // default "https://api.openai.com/v1" + APIKey string `yaml:"api_key" json:"api_key"` // supports keyring:xxx reference + Model string `yaml:"model" json:"model"` // default "gpt-4o" + MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // default 2048 } // SSHProxy defines a reusable SSH proxy configuration. @@ -84,6 +94,7 @@ type Resolved struct { ProfileName string Format string Profile Profile // full profile for query use + AI AIConfig } type Options struct { @@ -96,9 +107,20 @@ type Options struct { CLIFormat string CLIFormatSet bool + // CLI AI + CLIAIModel string + CLIAIModelSet bool + CLIAIBaseURL string + CLIAIBaseURLSet bool + CLIAIAPIKey string + CLIAIAPIKeySet bool + // ENV (injected by caller for testability) EnvProfile string EnvFormat string + EnvAIModel string + EnvAIBaseURL string + EnvAIAPIKey string // HomeDir is used for default path resolution (auto-detected if empty). HomeDir string From bad7ba79ce87c05b8d845dcf247272db68d9b317 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:00:03 +0800 Subject: [PATCH 03/75] feat: add AI client, prompt builder and service in internal/ai --- go.mod | 15 +++++ go.sum | 33 +++++++++++ internal/ai/client.go | 114 ++++++++++++++++++++++++++++++++++++ internal/ai/prompt.go | 39 ++++++++++++ internal/ai/service.go | 78 ++++++++++++++++++++++++ internal/ai/service_test.go | 89 ++++++++++++++++++++++++++++ 6 files changed, 368 insertions(+) create mode 100644 internal/ai/client.go create mode 100644 internal/ai/prompt.go create mode 100644 internal/ai/service.go create mode 100644 internal/ai/service_test.go diff --git a/go.mod b/go.mod index b58e683..d2aac90 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,28 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/bubbletea v1.3.4 // indirect + github.com/charmbracelet/lipgloss v1.0.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect diff --git a/go.sum b/go.sum index 153b2a8..637715f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,17 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -7,6 +19,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -31,10 +45,27 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -65,6 +96,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/internal/ai/client.go b/internal/ai/client.go new file mode 100644 index 0000000..10c8967 --- /dev/null +++ b/internal/ai/client.go @@ -0,0 +1,114 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/errors" +) + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +type ChatCompletionChoice struct { + Message ChatMessage `json:"message"` +} + +type ChatCompletionResponse struct { + Choices []ChatCompletionChoice `json:"choices"` + Error *struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error,omitempty"` +} + +type Client struct { + cfg config.AIConfig + httpClient *http.Client +} + +func NewClient(cfg config.AIConfig, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{ + cfg: cfg, + httpClient: httpClient, + } +} + +func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (string, *errors.XError) { + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + url := fmt.Sprintf("%s/chat/completions", baseURL) + + reqBody := ChatCompletionRequest{ + Model: c.cfg.Model, + Messages: messages, + MaxTokens: c.cfg.MaxTokens, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return "", errors.New(errors.CodeInternal, "failed to marshal AI request", map[string]any{"err": err.Error()}) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return "", errors.New(errors.CodeInternal, "failed to create AI HTTP request", map[string]any{"err": err.Error()}) + } + + req.Header.Set("Content-Type", "application/json") + if c.cfg.APIKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.cfg.APIKey)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", errors.New(errors.CodeDBConnectFailed, "failed to connect to AI service", map[string]any{"err": err.Error(), "url": url}) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.New(errors.CodeInternal, "failed to read AI response", map[string]any{"err": err.Error()}) + } + + if resp.StatusCode != http.StatusOK { + return "", errors.New(errors.CodeDBExecFailed, "AI provider returned non-200 error", map[string]any{ + "status": resp.StatusCode, + "body": string(respBytes), + }) + } + + var chatResp ChatCompletionResponse + if err := json.Unmarshal(respBytes, &chatResp); err != nil { + return "", errors.New(errors.CodeInternal, "failed to parse AI response JSON", map[string]any{"err": err.Error()}) + } + + if chatResp.Error != nil { + return "", errors.New(errors.CodeDBExecFailed, "AI provider returned error", map[string]any{ + "message": chatResp.Error.Message, + "code": chatResp.Error.Code, + }) + } + + if len(chatResp.Choices) == 0 { + return "", errors.New(errors.CodeInternal, "AI provider returned empty choices", nil) + } + + return chatResp.Choices[0].Message.Content, nil +} diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go new file mode 100644 index 0000000..ffecdcc --- /dev/null +++ b/internal/ai/prompt.go @@ -0,0 +1,39 @@ +package ai + +import ( + "encoding/json" + "fmt" + + "github.com/zx06/xsql/internal/db" +) + +const SystemPromptTemplate = `You are an expert AI SQL generator for the %s database. +Your job is to convert natural language requests into correct, efficient SQL queries based on the provided database schema. + +DATABASE SCHEMA: +%s + +IMPORTANT RULES: +1. Generate valid %s SQL ONLY. +2. Default to READ-ONLY SELECT queries unless explicitly instructed otherwise. +3. Your response MUST be valid JSON containing two keys: "sql" and "explanation". + Format: + { + "sql": "SELECT * FROM users WHERE active = true;", + "explanation": "Retrieves all active users from the users table." + } +4. Do NOT wrap JSON in code block ticks if possible, or wrap in standard JSON. +5. If the request cannot be answered by the schema, set "sql": "" and explain in "explanation".` + +func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo) string { + schemaJSON := "{}" + if schemaInfo != nil { + if bytes, err := json.MarshalIndent(schemaInfo, "", " "); err == nil { + schemaJSON = string(bytes) + } + } + if dbType == "" { + dbType = "MySQL/PostgreSQL" + } + return fmt.Sprintf(SystemPromptTemplate, dbType, schemaJSON, dbType) +} diff --git a/internal/ai/service.go b/internal/ai/service.go new file mode 100644 index 0000000..dfa754c --- /dev/null +++ b/internal/ai/service.go @@ -0,0 +1,78 @@ +package ai + +import ( + "context" + "encoding/json" + "regexp" + "strings" + + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/errors" +) + +type SQLResponse struct { + SQL string `json:"sql"` + Explanation string `json:"explanation"` +} + +type Service struct { + client *Client +} + +func NewService(cfg config.AIConfig, client *Client) *Service { + if client == nil { + client = NewClient(cfg, nil) + } + return &Service{ + client: client, + } +} + +func (s *Service) GenerateSQL(ctx context.Context, userPrompt string, schemaInfo *db.SchemaInfo, dbType string) (*SQLResponse, *errors.XError) { + systemPrompt := BuildSystemPrompt(dbType, schemaInfo) + + messages := []ChatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + } + + content, xe := s.client.ChatCompletion(ctx, messages) + if xe != nil { + return nil, xe + } + + return parseSQLResponse(content) +} + +var codeBlockRegex = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```") + +func parseSQLResponse(content string) (*SQLResponse, *errors.XError) { + cleaned := strings.TrimSpace(content) + if matches := codeBlockRegex.FindStringSubmatch(cleaned); len(matches) > 1 { + cleaned = strings.TrimSpace(matches[1]) + } + + var resp SQLResponse + if err := json.Unmarshal([]byte(cleaned), &resp); err == nil { + resp.SQL = strings.TrimSpace(resp.SQL) + resp.Explanation = strings.TrimSpace(resp.Explanation) + return &resp, nil + } + + // Fallback if AI returned raw SQL or raw text + if strings.HasPrefix(strings.ToUpper(cleaned), "SELECT") || + strings.HasPrefix(strings.ToUpper(cleaned), "WITH") || + strings.HasPrefix(strings.ToUpper(cleaned), "SHOW") || + strings.HasPrefix(strings.ToUpper(cleaned), "EXPLAIN") { + return &SQLResponse{ + SQL: cleaned, + Explanation: "Generated SQL based on request.", + }, nil + } + + return &SQLResponse{ + SQL: "", + Explanation: cleaned, + }, nil +} diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go new file mode 100644 index 0000000..f8da948 --- /dev/null +++ b/internal/ai/service_test.go @@ -0,0 +1,89 @@ +package ai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/db" +) + +func TestBuildSystemPrompt(t *testing.T) { + schema := &db.SchemaInfo{ + Database: "testdb", + Tables: []db.Table{ + { + Name: "users", + Columns: []db.Column{ + {Name: "id", Type: "int", PrimaryKey: true}, + {Name: "name", Type: "varchar"}, + }, + }, + }, + } + + prompt := BuildSystemPrompt("mysql", schema) + if prompt == "" { + t.Fatal("expected non-empty prompt") + } +} + +func TestGenerateSQL_MockHTTP(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) + } + + resp := ChatCompletionResponse{ + Choices: []ChatCompletionChoice{ + { + Message: ChatMessage{ + Role: "assistant", + Content: `{"sql": "SELECT id, name FROM users;", "explanation": "Queries all users."}`, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer mockServer.Close() + + cfg := config.AIConfig{ + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + Model: "gpt-4o", + } + + client := NewClient(cfg, mockServer.Client()) + service := NewService(cfg, client) + + res, xe := service.GenerateSQL(context.Background(), "show users", nil, "mysql") + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + + if res.SQL != "SELECT id, name FROM users;" { + t.Errorf("expected SQL 'SELECT id, name FROM users;', got %q", res.SQL) + } + if res.Explanation != "Queries all users." { + t.Errorf("expected explanation 'Queries all users.', got %q", res.Explanation) + } +} + +func TestParseSQLResponse_Fallback(t *testing.T) { + resp, xe := parseSQLResponse("SELECT * FROM users") + if xe != nil { + t.Fatal(xe) + } + if resp.SQL != "SELECT * FROM users" { + t.Errorf("expected raw SQL fallback, got %q", resp.SQL) + } +} From a603c5a2cc236868199270ff839bfcb07f1fed01 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:01:03 +0800 Subject: [PATCH 04/75] feat: add interactive Bubbletea TUI engine in internal/tui --- go.mod | 7 +- go.sum | 4 + internal/tui/components.go | 59 ++++++++ internal/tui/model.go | 301 +++++++++++++++++++++++++++++++++++++ internal/tui/model_test.go | 107 +++++++++++++ internal/tui/styles.go | 65 ++++++++ 6 files changed, 540 insertions(+), 3 deletions(-) create mode 100644 internal/tui/components.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/model_test.go create mode 100644 internal/tui/styles.go diff --git a/go.mod b/go.mod index d2aac90..e07aa74 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/zx06/xsql go 1.25.0 require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.0.0 github.com/go-sql-driver/mysql v1.10.0 github.com/google/jsonschema-go v0.4.3 github.com/jackc/pgx/v5 v5.9.2 @@ -17,10 +20,8 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect - github.com/charmbracelet/bubbletea v1.3.4 // indirect - github.com/charmbracelet/lipgloss v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect diff --git a/go.sum b/go.sum index 637715f..d037970 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= diff --git a/internal/tui/components.go b/internal/tui/components.go new file mode 100644 index 0000000..ed09d4f --- /dev/null +++ b/internal/tui/components.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/zx06/xsql/internal/db" +) + +func FormatTableResult(result *db.QueryResult) string { + if result == nil || len(result.Columns) == 0 { + return "(No data returned)" + } + + var sb strings.Builder + widths := make([]int, len(result.Columns)) + for i, col := range result.Columns { + widths[i] = len(col) + } + + for _, row := range result.Rows { + for i, col := range result.Columns { + val := fmt.Sprintf("%v", row[col]) + if len(val) > widths[i] { + widths[i] = len(val) + } + } + } + + // Print Headers + var headerRow []string + var lineRow []string + for i, col := range result.Columns { + headerRow = append(headerRow, fmt.Sprintf("%-*s", widths[i], col)) + lineRow = append(lineRow, strings.Repeat("-", widths[i])) + } + sb.WriteString(strings.Join(headerRow, " ") + "\n") + sb.WriteString(strings.Join(lineRow, " ") + "\n") + + // Print Rows (up to 50 rows) + maxRows := len(result.Rows) + if maxRows > 50 { + maxRows = 50 + } + for i := 0; i < maxRows; i++ { + var rowValues []string + for j, col := range result.Columns { + val := fmt.Sprintf("%v", result.Rows[i][col]) + rowValues = append(rowValues, fmt.Sprintf("%-*s", widths[j], val)) + } + sb.WriteString(strings.Join(rowValues, " ") + "\n") + } + + if len(result.Rows) > 50 { + sb.WriteString(fmt.Sprintf("\n... and %d more rows\n", len(result.Rows)-50)) + } + + return sb.String() +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..e6f368b --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,301 @@ +package tui + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/zx06/xsql/internal/ai" + "github.com/zx06/xsql/internal/app" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/errors" +) + +type State int + +const ( + StateLoadingSchema State = iota + StateIdle + StateThinking + StateSQLReady + StateExecuting +) + +// Msg types +type schemaLoadedMsg struct { + schema *db.SchemaInfo + err *errors.XError +} + +type sqlGeneratedMsg struct { + response *ai.SQLResponse + err *errors.XError +} + +type queryExecutedMsg struct { + result *db.QueryResult + err *errors.XError +} + +type Model struct { + opts config.Options + aiService *ai.Service + profile config.Profile + profileName string + unsafeAllowWrite bool + + state State + schemaInfo *db.SchemaInfo + currentSQL string + explanation string + messages []string + err error + + textarea textarea.Model + viewport viewport.Model + spinner spinner.Model + + editingSQL bool + width int + height int +} + +func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { + ta := textarea.New() + ta.Placeholder = "Ask AI to generate a SQL query (e.g. 'Show top 10 users')..." + ta.Focus() + ta.CharLimit = 1000 + ta.SetWidth(80) + ta.SetHeight(3) + + vp := viewport.New(80, 15) + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(PrimaryColor) + + if initialPrompt != "" { + ta.SetValue(initialPrompt) + } + + return Model{ + opts: opts, + aiService: aiService, + profile: resolved.Profile, + profileName: resolved.ProfileName, + unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, + state: StateLoadingSchema, + textarea: ta, + viewport: vp, + spinner: s, + messages: []string{}, + width: 80, + height: 24, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + m.loadSchemaCmd(), + ) +} + +func (m Model) loadSchemaCmd() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + info, xe := app.DumpSchema(ctx, app.SchemaDumpRequest{ + Profile: m.profile, + AllowPlaintext: m.profile.AllowPlaintext, + SkipHostKeyCheck: m.profile.SSHConfig != nil && m.profile.SSHConfig.SkipHostKey, + }) + return schemaLoadedMsg{schema: info, err: xe} + } +} + +func (m Model) generateSQLCmd(prompt string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + resp, xe := m.aiService.GenerateSQL(ctx, prompt, m.schemaInfo, m.profile.DB) + return sqlGeneratedMsg{response: resp, err: xe} + } +} + +func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + res, xe := app.Query(ctx, app.QueryRequest{ + Profile: m.profile, + SQL: sqlStr, + AllowPlaintext: m.profile.AllowPlaintext, + SkipHostKeyCheck: m.profile.SSHConfig != nil && m.profile.SSHConfig.SkipHostKey, + UnsafeAllowWrite: m.unsafeAllowWrite, + }) + return queryExecutedMsg{result: res, err: xe} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.textarea.SetWidth(msg.Width - 4) + m.viewport.Width = msg.Width - 4 + m.viewport.Height = max(5, msg.Height-14) + + case schemaLoadedMsg: + if msg.err != nil { + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("Failed to load schema: %v", msg.err))) + } else { + m.schemaInfo = msg.schema + } + m.state = StateIdle + m.viewport.SetContent(strings.Join(m.messages, "\n")) + + case sqlGeneratedMsg: + if msg.err != nil { + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("AI Error: %v", msg.err))) + m.state = StateIdle + } else { + m.currentSQL = msg.response.SQL + m.explanation = msg.response.Explanation + m.messages = append(m.messages, AIResponseStyle.Render("AI: "+msg.response.Explanation)) + if msg.response.SQL != "" { + m.state = StateSQLReady + } else { + m.state = StateIdle + } + } + m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.GotoBottom() + + case queryExecutedMsg: + if msg.err != nil { + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) + } else if msg.result != nil { + m.messages = append(m.messages, SubHeaderStyle.Render(fmt.Sprintf("Execution Success (%d rows returned):", len(msg.result.Rows)))) + m.messages = append(m.messages, FormatTableResult(msg.result)) + } + m.state = StateIdle + m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.GotoBottom() + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + + case tea.KeyCtrlE: // Execute current SQL + if m.state == StateSQLReady && m.currentSQL != "" { + m.state = StateExecuting + m.messages = append(m.messages, SubHeaderStyle.Render("Executing: ")+m.currentSQL) + m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.GotoBottom() + return m, m.executeSQLCmd(m.currentSQL) + } + + case tea.KeyCtrlR: // Toggle Edit SQL mode + if m.state == StateSQLReady { + m.editingSQL = !m.editingSQL + if m.editingSQL { + m.textarea.SetValue(m.currentSQL) + } + } + + case tea.KeyEnter: // Send prompt or confirm edited SQL + if m.editingSQL { + m.currentSQL = m.textarea.Value() + m.editingSQL = false + m.textarea.Reset() + return m, nil + } + + prompt := strings.TrimSpace(m.textarea.Value()) + if prompt != "" && (m.state == StateIdle || m.state == StateSQLReady) { + m.messages = append(m.messages, UserPromptStyle.Render("You: ")+prompt) + m.textarea.Reset() + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.GotoBottom() + return m, m.generateSQLCmd(prompt) + } + } + } + + if !m.editingSQL { + var taCmd tea.Cmd + m.textarea, taCmd = m.textarea.Update(msg) + cmds = append(cmds, taCmd) + } + + var vpCmd tea.Cmd + m.viewport, vpCmd = m.viewport.Update(msg) + cmds = append(cmds, vpCmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + var sb strings.Builder + + // 1. Header + modeBadge := BadgeReadOnly.Render("READ-ONLY") + if m.unsafeAllowWrite { + modeBadge = BadgeReadWrite.Render("READ-WRITE") + } + header := fmt.Sprintf(" xsql AI | Profile: %s (%s) | %s ", m.profileName, m.profile.DB, modeBadge) + sb.WriteString(HeaderStyle.Width(m.width).Render(header) + "\n\n") + + // 2. Main Viewport (Messages & Results) + sb.WriteString(m.viewport.View() + "\n\n") + + // 3. State & SQL Preview Box + switch m.state { + case StateLoadingSchema: + sb.WriteString(m.spinner.View() + " Loading database schema...\n") + case StateThinking: + sb.WriteString(m.spinner.View() + " AI is generating SQL...\n") + case StateExecuting: + sb.WriteString(m.spinner.View() + " Executing SQL query...\n") + case StateSQLReady: + sqlContent := m.currentSQL + if sqlContent == "" { + sqlContent = "(No SQL generated)" + } + preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("SQL Preview (Press Ctrl+E to Execute, Ctrl+R to Edit):"), sqlContent) + sb.WriteString(SQLBoxStyle.Width(m.width - 4).Render(preview) + "\n") + } + + // 4. Input Area & Footer + if m.editingSQL { + sb.WriteString(SubHeaderStyle.Render("Edit SQL (Press Enter to Save):") + "\n") + } + sb.WriteString(m.textarea.View() + "\n") + + help := "Enter: Send Prompt | Ctrl+E: Execute SQL | Ctrl+R: Edit SQL | Esc/Ctrl+C: Quit" + sb.WriteString(HelpStyle.Render(help)) + + return sb.String() +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..9eb2260 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,107 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/zx06/xsql/internal/ai" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/db" +) + +func TestTUI_Model_StateTransitions(t *testing.T) { + resolved := config.Resolved{ + ProfileName: "dev", + Profile: config.Profile{ + DB: "mysql", + }, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + + // Initial State should be StateLoadingSchema + if m.state != StateLoadingSchema { + t.Fatalf("expected initial state StateLoadingSchema, got %v", m.state) + } + + // 1. Send schemaLoadedMsg -> transition to StateIdle + updated, _ := m.Update(schemaLoadedMsg{ + schema: &db.SchemaInfo{Database: "testdb"}, + }) + m = updated.(Model) + if m.state != StateIdle { + t.Fatalf("expected state StateIdle, got %v", m.state) + } + + // 2. Send sqlGeneratedMsg -> transition to StateSQLReady + updated, _ = m.Update(sqlGeneratedMsg{ + response: &ai.SQLResponse{ + SQL: "SELECT * FROM users;", + Explanation: "Returns all users.", + }, + }) + m = updated.(Model) + if m.state != StateSQLReady { + t.Fatalf("expected state StateSQLReady, got %v", m.state) + } + if m.currentSQL != "SELECT * FROM users;" { + t.Errorf("expected SQL 'SELECT * FROM users;', got %q", m.currentSQL) + } + + // 3. Test View Output Rendering + viewStr := m.View() + if !strings.Contains(viewStr, "xsql AI") { + t.Errorf("expected view to contain header 'xsql AI', got:\n%s", viewStr) + } + if !strings.Contains(viewStr, "READ-ONLY") { + t.Errorf("expected view to contain READ-ONLY badge, got:\n%s", viewStr) + } + if !strings.Contains(viewStr, "SELECT * FROM users;") { + t.Errorf("expected view to contain SQL Preview, got:\n%s", viewStr) + } + + // 4. Test KeyMsg Ctrl+E -> transition to StateExecuting + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) + m = updated.(Model) + if m.state != StateExecuting { + t.Fatalf("expected state StateExecuting after Ctrl+E, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected non-nil Cmd for executeSQLCmd") + } + + // 5. Send queryExecutedMsg -> transition to StateIdle + updated, _ = m.Update(queryExecutedMsg{ + result: &db.QueryResult{ + Columns: []string{"id", "name"}, + Rows: []map[string]any{{"id": 1, "name": "Alice"}}, + }, + }) + m = updated.(Model) + if m.state != StateIdle { + t.Fatalf("expected state StateIdle after query executed, got %v", m.state) + } + + // View output should contain query result + viewStr = m.View() + if !strings.Contains(viewStr, "Alice") { + t.Errorf("expected view to contain result 'Alice', got:\n%s", viewStr) + } +} + +func TestFormatTableResult(t *testing.T) { + res := &db.QueryResult{ + Columns: []string{"id", "username"}, + Rows: []map[string]any{ + {"id": 1, "username": "admin"}, + {"id": 2, "username": "guest"}, + }, + } + + formatted := FormatTableResult(res) + if !strings.Contains(formatted, "admin") || !strings.Contains(formatted, "guest") { + t.Errorf("formatted table result missing row data:\n%s", formatted) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..d29ce18 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,65 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + // Palette Colors + PrimaryColor = lipgloss.Color("#7D56F4") + SecondaryColor = lipgloss.Color("#04B575") + AccentColor = lipgloss.Color("#FF75B5") + WarningColor = lipgloss.Color("#FF9E3B") + ErrorColor = lipgloss.Color("#FF5370") + MutedColor = lipgloss.Color("#565F89") + BgDark = lipgloss.Color("#1A1B26") + + // Header Styles + HeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(PrimaryColor). + Padding(0, 1) + + SubHeaderStyle = lipgloss.NewStyle(). + Foreground(SecondaryColor). + Bold(true) + + BadgeReadOnly = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(SecondaryColor). + Padding(0, 1) + + BadgeReadWrite = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(WarningColor). + Padding(0, 1) + + // SQL Preview Box + SQLBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(0, 1). + MarginTop(1). + MarginBottom(1) + + SQLTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(AccentColor) + + // Help / Footer + HelpStyle = lipgloss.NewStyle(). + Foreground(MutedColor) + + // Chat Messages + UserPromptStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(SecondaryColor) + + AIResponseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C0CAF5")) + + ErrorMsgStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ErrorColor) +) From bdcb849c492a2b294c851b73e6fde35e91824f33 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:02:44 +0800 Subject: [PATCH 05/75] feat: add xsql-ai binary entrypoint and xsql ai subcommand --- cmd/xsql-ai/main.go | 95 ++++++++++++++++++++++++++++++++++++++++ cmd/xsql-ai/main_test.go | 26 +++++++++++ cmd/xsql/ai.go | 83 +++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 cmd/xsql-ai/main.go create mode 100644 cmd/xsql-ai/main_test.go create mode 100644 cmd/xsql/ai.go diff --git a/cmd/xsql-ai/main.go b/cmd/xsql-ai/main.go new file mode 100644 index 0000000..02df3d7 --- /dev/null +++ b/cmd/xsql-ai/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/zx06/xsql/internal/ai" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/secret" + "github.com/zx06/xsql/internal/tui" +) + +type AIFlags struct { + ConfigPath string + Profile string + Model string + BaseURL string + APIKey string + UnsafeAllowWrite bool + Prompt string +} + +func main() { + flags := &AIFlags{} + + rootCmd := &cobra.Command{ + Use: "xsql-ai [PROMPT]", + Short: "xsql AI interactive database query tool (TUI)", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + flags.Prompt = strings.Join(args, " ") + } + return runAI(cmd, flags) + }, + } + + rootCmd.Flags().StringVar(&flags.ConfigPath, "config", "", "Config file path (YAML)") + rootCmd.Flags().StringVarP(&flags.Profile, "profile", "p", "", "Profile name (required)") + rootCmd.Flags().StringVar(&flags.Model, "model", "", "AI model name (default: gpt-4o)") + rootCmd.Flags().StringVar(&flags.BaseURL, "base-url", "", "AI service base URL") + rootCmd.Flags().StringVar(&flags.APIKey, "api-key", "", "AI service API key") + rootCmd.Flags().BoolVar(&flags.UnsafeAllowWrite, "unsafe-allow-write", false, "Allow write operations (bypasses read-only protection)") + rootCmd.Flags().StringVar(&flags.Prompt, "prompt", "", "Initial prompt for AI query") + + _ = rootCmd.MarkFlagRequired("profile") + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func runAI(cmd *cobra.Command, flags *AIFlags) error { + opts := config.Options{ + ConfigPath: flags.ConfigPath, + CLIProfile: flags.Profile, + CLIProfileSet: flags.Profile != "", + CLIAIModel: flags.Model, + CLIAIModelSet: cmd.Flags().Changed("model"), + CLIAIBaseURL: flags.BaseURL, + CLIAIBaseURLSet: cmd.Flags().Changed("base-url"), + CLIAIAPIKey: flags.APIKey, + CLIAIAPIKeySet: cmd.Flags().Changed("api-key"), + } + + resolved, xe := config.Resolve(opts) + if xe != nil { + return fmt.Errorf("config error [%s]: %s", xe.Code, xe.Message) + } + + // Resolve API key if keyring reference or plaintext + apiKey := resolved.AI.APIKey + if secret.IsKeyringRef(apiKey) { + resolvedKey, xe := secret.Resolve(apiKey, secret.Options{AllowPlaintext: true}) + if xe == nil { + apiKey = resolvedKey + } + } + resolved.AI.APIKey = apiKey + + aiClient := ai.NewClient(resolved.AI, nil) + aiService := ai.NewService(resolved.AI, aiClient) + + model := tui.NewModel(opts, resolved, aiService, flags.Prompt, flags.UnsafeAllowWrite) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running TUI: %w", err) + } + + return nil +} diff --git a/cmd/xsql-ai/main_test.go b/cmd/xsql-ai/main_test.go new file mode 100644 index 0000000..5eab84f --- /dev/null +++ b/cmd/xsql-ai/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "testing" +) + +func TestAIFlags_Parsing(t *testing.T) { + flags := &AIFlags{ + Profile: "dev", + Model: "gpt-4o", + BaseURL: "https://api.openai.com/v1", + APIKey: "sk-test", + UnsafeAllowWrite: true, + Prompt: "Count users", + } + + if flags.Profile != "dev" { + t.Errorf("expected profile=dev, got %s", flags.Profile) + } + if !flags.UnsafeAllowWrite { + t.Error("expected UnsafeAllowWrite to be true") + } + if flags.Prompt != "Count users" { + t.Errorf("expected prompt='Count users', got %s", flags.Prompt) + } +} diff --git a/cmd/xsql/ai.go b/cmd/xsql/ai.go new file mode 100644 index 0000000..ff221c5 --- /dev/null +++ b/cmd/xsql/ai.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/zx06/xsql/internal/ai" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/secret" + "github.com/zx06/xsql/internal/tui" +) + +type CmdAIFlags struct { + Model string + BaseURL string + APIKey string + UnsafeAllowWrite bool + Prompt string +} + +func NewAICommand() *cobra.Command { + flags := &CmdAIFlags{} + + cmd := &cobra.Command{ + Use: "ai [PROMPT]", + Short: "Interactive AI terminal mode (TUI) to write and execute SQL", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + flags.Prompt = args[0] + } + return runCmdAI(cmd, flags) + }, + } + + cmd.Flags().StringVar(&flags.Model, "model", "", "AI model name (default: gpt-4o)") + cmd.Flags().StringVar(&flags.BaseURL, "base-url", "", "AI service base URL") + cmd.Flags().StringVar(&flags.APIKey, "api-key", "", "AI service API key") + cmd.Flags().BoolVar(&flags.UnsafeAllowWrite, "unsafe-allow-write", false, "Allow write operations (bypasses read-only protection)") + cmd.Flags().StringVar(&flags.Prompt, "prompt", "", "Initial prompt for AI query") + + return cmd +} + +func runCmdAI(cmd *cobra.Command, flags *CmdAIFlags) error { + opts := config.Options{ + ConfigPath: GlobalConfig.ConfigStr, + CLIProfile: GlobalConfig.ProfileStr, + CLIProfileSet: cmd.Flags().Changed("profile") || GlobalConfig.ProfileStr != "", + CLIAIModel: flags.Model, + CLIAIModelSet: cmd.Flags().Changed("model"), + CLIAIBaseURL: flags.BaseURL, + CLIAIBaseURLSet: cmd.Flags().Changed("base-url"), + CLIAIAPIKey: flags.APIKey, + CLIAIAPIKeySet: cmd.Flags().Changed("api-key"), + } + + resolved, xe := config.Resolve(opts) + if xe != nil { + return xe + } + + apiKey := resolved.AI.APIKey + if secret.IsKeyringRef(apiKey) { + if resolvedKey, xe := secret.Resolve(apiKey, secret.Options{AllowPlaintext: true}); xe == nil { + apiKey = resolvedKey + } + } + resolved.AI.APIKey = apiKey + + aiClient := ai.NewClient(resolved.AI, nil) + aiService := ai.NewService(resolved.AI, aiClient) + + model := tui.NewModel(opts, resolved, aiService, flags.Prompt, flags.UnsafeAllowWrite) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running TUI: %w", err) + } + + return nil +} From b57e3b6b1bcd8769ecdfddde2765cf6822ba58aa Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:05:37 +0800 Subject: [PATCH 06/75] test: add E2E tests for AI service and TUI terminal mode --- .gitignore | 1 + tests/e2e/ai_test.go | 154 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/e2e/ai_test.go diff --git a/.gitignore b/.gitignore index 0b6494c..373e214 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage*.txt coverage*.html coverage.out stats_coverage.out +bin/ diff --git a/tests/e2e/ai_test.go b/tests/e2e/ai_test.go new file mode 100644 index 0000000..f886359 --- /dev/null +++ b/tests/e2e/ai_test.go @@ -0,0 +1,154 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/zx06/xsql/internal/ai" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/tui" +) + +func TestE2E_AI_Service_With_MockOpenAI(t *testing.T) { + // 1. Setup Mock OpenAI Server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer test-e2e-key" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req ai.ChatCompletionRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + // Assert System prompt contains schema context + hasSystem := false + for _, msg := range req.Messages { + if msg.Role == "system" && strings.Contains(msg.Content, "DATABASE SCHEMA") { + hasSystem = true + break + } + } + if !hasSystem { + t.Errorf("system prompt missing schema context: %+v", req.Messages) + } + + resp := ai.ChatCompletionResponse{ + Choices: []ai.ChatCompletionChoice{ + { + Message: ai.ChatMessage{ + Role: "assistant", + Content: `{"sql": "SELECT COUNT(*) FROM users;", "explanation": "Returns total user count."}`, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // 2. Setup AI Config and Service + aiCfg := config.AIConfig{ + Provider: "openai", + BaseURL: server.URL, + APIKey: "test-e2e-key", + Model: "gpt-4o", + MaxTokens: 2048, + } + + client := ai.NewClient(aiCfg, server.Client()) + service := ai.NewService(aiCfg, client) + + mockSchema := &db.SchemaInfo{ + Database: "e2e_db", + Tables: []db.Table{ + { + Name: "users", + Columns: []db.Column{ + {Name: "id", Type: "bigint", PrimaryKey: true}, + }, + }, + }, + } + + // 3. Test GenerateSQL + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sqlResp, xe := service.GenerateSQL(ctx, "How many users exist?", mockSchema, "mysql") + if xe != nil { + t.Fatalf("unexpected error generating SQL: %v", xe) + } + + if sqlResp.SQL != "SELECT COUNT(*) FROM users;" { + t.Errorf("expected SQL 'SELECT COUNT(*) FROM users;', got %q", sqlResp.SQL) + } + if sqlResp.Explanation != "Returns total user count." { + t.Errorf("expected explanation 'Returns total user count.', got %q", sqlResp.Explanation) + } +} + +func TestE2E_AI_TUI_Terminal_Pipe(t *testing.T) { + // Setup temporary xsql config file + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "xsql.yaml") + cfgContent := `profiles: + dev: + db: mysql + host: 127.0.0.1 + port: 3306 + user: root + database: test +ai: + base_url: "https://mock.api.com" + model: "test-model" + api_key: "test-key" +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { + t.Fatal(err) + } + + resolved, xe := config.Resolve(config.Options{ConfigPath: cfgPath, CLIProfile: "dev", CLIProfileSet: true}) + if xe != nil { + t.Fatalf("failed to resolve config: %v", xe) + } + + aiService := ai.NewService(resolved.AI, nil) + model := tui.NewModel(config.Options{}, resolved, aiService, "Show total users", false) + + inBuf := bytes.NewBufferString("\n") // Press Enter + outBuf := &bytes.Buffer{} + + p := tea.NewProgram(model, tea.WithInput(inBuf), tea.WithOutput(outBuf)) + + go func() { + time.Sleep(100 * time.Millisecond) + p.Quit() + }() + + if _, err := p.Run(); err != nil { + t.Fatalf("TUI program run failed: %v", err) + } + + outputStr := outBuf.String() + if !strings.Contains(outputStr, "xsql AI") { + t.Errorf("expected TUI terminal output to contain header 'xsql AI', got:\n%s", outputStr) + } +} From 32e9d673b42a321b30bbe4c94a8322e2b8f70a81 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:13:11 +0800 Subject: [PATCH 07/75] fix: register db drivers for xsql and xsql-ai binaries --- cmd/xsql-ai/main.go | 2 ++ cmd/xsql/root.go | 2 ++ internal/ai/prompt.go | 3 ++- internal/app/conn.go | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/xsql-ai/main.go b/cmd/xsql-ai/main.go index 02df3d7..ac73e03 100644 --- a/cmd/xsql-ai/main.go +++ b/cmd/xsql-ai/main.go @@ -10,6 +10,8 @@ import ( "github.com/zx06/xsql/internal/ai" "github.com/zx06/xsql/internal/config" + _ "github.com/zx06/xsql/internal/db/mysql" + _ "github.com/zx06/xsql/internal/db/pg" "github.com/zx06/xsql/internal/secret" "github.com/zx06/xsql/internal/tui" ) diff --git a/cmd/xsql/root.go b/cmd/xsql/root.go index b229b27..20abf94 100644 --- a/cmd/xsql/root.go +++ b/cmd/xsql/root.go @@ -6,6 +6,8 @@ import ( "github.com/spf13/cobra" "github.com/zx06/xsql/internal/config" + _ "github.com/zx06/xsql/internal/db/mysql" + _ "github.com/zx06/xsql/internal/db/pg" "github.com/zx06/xsql/internal/errors" "github.com/zx06/xsql/internal/stats" ) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index ffecdcc..0e10eff 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -23,7 +23,8 @@ IMPORTANT RULES: "explanation": "Retrieves all active users from the users table." } 4. Do NOT wrap JSON in code block ticks if possible, or wrap in standard JSON. -5. If the request cannot be answered by the schema, set "sql": "" and explain in "explanation".` +5. If the request asks for general database metadata or listing tables/columns (e.g. 'show tables', 'what tables exist'), generate standard SQL (e.g. 'SHOW TABLES;' for MySQL, or 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\';' for PostgreSQL) even if the provided schema is empty. +6. If the request genuinely cannot be answered by the schema, set "sql": "" and explain in "explanation".` func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo) string { schemaJSON := "{}" diff --git a/internal/app/conn.go b/internal/app/conn.go index 9e1496c..c76b480 100644 --- a/internal/app/conn.go +++ b/internal/app/conn.go @@ -7,6 +7,8 @@ import ( "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/db" + _ "github.com/zx06/xsql/internal/db/mysql" + _ "github.com/zx06/xsql/internal/db/pg" "github.com/zx06/xsql/internal/errors" "github.com/zx06/xsql/internal/secret" "github.com/zx06/xsql/internal/ssh" From f14f8a597ae872309106a99a5c4baf2f12119a5b Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:17:00 +0800 Subject: [PATCH 08/75] style: upgrade TUI aesthetics with Lipgloss rounded table, tags and code blocks --- internal/tui/components.go | 82 +++++++++++++++++++++++++------------- internal/tui/model.go | 52 ++++++++++++------------ internal/tui/styles.go | 41 ++++++++++++++----- 3 files changed, 111 insertions(+), 64 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index ed09d4f..7a8f0d0 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -4,55 +4,81 @@ import ( "fmt" "strings" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/zx06/xsql/internal/db" ) +var ( + TableHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + Padding(0, 1) + + TableCellStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C0CAF5")). + Padding(0, 1) + + TableNilStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#565F89")). + Italic(true). + Padding(0, 1) + + TableBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3B4261")) +) + +// FormatTableResult renders a beautiful terminal box table for SQL query results. func FormatTableResult(result *db.QueryResult) string { if result == nil || len(result.Columns) == 0 { - return "(No data returned)" + return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") } - var sb strings.Builder - widths := make([]int, len(result.Columns)) - for i, col := range result.Columns { - widths[i] = len(col) + if len(result.Rows) == 0 { + return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(0 rows returned)") } - for _, row := range result.Rows { - for i, col := range result.Columns { - val := fmt.Sprintf("%v", row[col]) - if len(val) > widths[i] { - widths[i] = len(val) - } - } - } + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(TableBorderStyle). + Headers(result.Columns...) - // Print Headers - var headerRow []string - var lineRow []string - for i, col := range result.Columns { - headerRow = append(headerRow, fmt.Sprintf("%-*s", widths[i], col)) - lineRow = append(lineRow, strings.Repeat("-", widths[i])) - } - sb.WriteString(strings.Join(headerRow, " ") + "\n") - sb.WriteString(strings.Join(lineRow, " ") + "\n") + // Configure header styling + t.StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return TableHeaderStyle + } + return TableCellStyle + }) - // Print Rows (up to 50 rows) + // Add data rows (limit to 50 rows for viewport cleanliness) maxRows := len(result.Rows) if maxRows > 50 { maxRows = 50 } + for i := 0; i < maxRows; i++ { var rowValues []string - for j, col := range result.Columns { - val := fmt.Sprintf("%v", result.Rows[i][col]) - rowValues = append(rowValues, fmt.Sprintf("%-*s", widths[j], val)) + for _, col := range result.Columns { + val := result.Rows[i][col] + if val == nil { + rowValues = append(rowValues, TableNilStyle.Render("NULL")) + } else { + valStr := fmt.Sprintf("%v", val) + // Clean formatting for time string or long text + rowValues = append(rowValues, valStr) + } } - sb.WriteString(strings.Join(rowValues, " ") + "\n") + t.Row(rowValues...) } + var sb strings.Builder + sb.WriteString(t.Render()) + if len(result.Rows) > 50 { - sb.WriteString(fmt.Sprintf("\n... and %d more rows\n", len(result.Rows)-50)) + moreStr := fmt.Sprintf("\n... and %d more rows (truncated for performance)", len(result.Rows)-50) + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(moreStr)) } return sb.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index e6f368b..8f78d9a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -56,7 +56,6 @@ type Model struct { currentSQL string explanation string messages []string - err error textarea textarea.Model viewport viewport.Model @@ -69,11 +68,11 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() - ta.Placeholder = "Ask AI to generate a SQL query (e.g. 'Show top 10 users')..." + ta.Placeholder = "Ask AI to write a SQL query (e.g. 'Show top 10 users')..." ta.Focus() ta.CharLimit = 1000 ta.SetWidth(80) - ta.SetHeight(3) + ta.SetHeight(2) vp := viewport.New(80, 15) @@ -169,25 +168,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.currentSQL = msg.response.SQL m.explanation = msg.response.Explanation - m.messages = append(m.messages, AIResponseStyle.Render("AI: "+msg.response.Explanation)) + + aiMsg := AITagStyle.Render("🤖 AI") + " " + AIResponseStyle.Render(msg.response.Explanation) + m.messages = append(m.messages, aiMsg) + if msg.response.SQL != "" { m.state = StateSQLReady } else { m.state = StateIdle } } - m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() case queryExecutedMsg: if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) } else if msg.result != nil { - m.messages = append(m.messages, SubHeaderStyle.Render(fmt.Sprintf("Execution Success (%d rows returned):", len(msg.result.Rows)))) + statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) + m.messages = append(m.messages, statusLine) m.messages = append(m.messages, FormatTableResult(msg.result)) } m.state = StateIdle - m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() case spinner.TickMsg: @@ -203,8 +206,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlE: // Execute current SQL if m.state == StateSQLReady && m.currentSQL != "" { m.state = StateExecuting - m.messages = append(m.messages, SubHeaderStyle.Render("Executing: ")+m.currentSQL) - m.viewport.SetContent(strings.Join(m.messages, "\n")) + execLine := ExecutingTagStyle.Render("⚡ Executing") + " " + SQLCodeStyle.Render(m.currentSQL) + m.messages = append(m.messages, execLine) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) } @@ -227,10 +231,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { prompt := strings.TrimSpace(m.textarea.Value()) if prompt != "" && (m.state == StateIdle || m.state == StateSQLReady) { - m.messages = append(m.messages, UserPromptStyle.Render("You: ")+prompt) + userLine := UserTagStyle.Render("👤 YOU") + " " + prompt + m.messages = append(m.messages, userLine) m.textarea.Reset() m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.generateSQLCmd(prompt) } @@ -253,7 +258,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) View() string { var sb strings.Builder - // 1. Header + // 1. Header Bar modeBadge := BadgeReadOnly.Render("READ-ONLY") if m.unsafeAllowWrite { modeBadge = BadgeReadWrite.Render("READ-WRITE") @@ -264,26 +269,26 @@ func (m Model) View() string { // 2. Main Viewport (Messages & Results) sb.WriteString(m.viewport.View() + "\n\n") - // 3. State & SQL Preview Box + // 3. State Status & SQL Preview Card switch m.state { case StateLoadingSchema: sb.WriteString(m.spinner.View() + " Loading database schema...\n") case StateThinking: - sb.WriteString(m.spinner.View() + " AI is generating SQL...\n") + sb.WriteString(m.spinner.View() + " AI is analyzing schema and generating SQL...\n") case StateExecuting: sb.WriteString(m.spinner.View() + " Executing SQL query...\n") case StateSQLReady: - sqlContent := m.currentSQL - if sqlContent == "" { - sqlContent = "(No SQL generated)" + sqlContent := SQLCodeStyle.Render(m.currentSQL) + if m.currentSQL == "" { + sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") } - preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("SQL Preview (Press Ctrl+E to Execute, Ctrl+R to Edit):"), sqlContent) + preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Press Ctrl+E to Execute, Ctrl+R to Edit):"), sqlContent) sb.WriteString(SQLBoxStyle.Width(m.width - 4).Render(preview) + "\n") } - // 4. Input Area & Footer + // 4. Input Area & Footer Hints if m.editingSQL { - sb.WriteString(SubHeaderStyle.Render("Edit SQL (Press Enter to Save):") + "\n") + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Press Enter to Apply Changes):") + "\n") } sb.WriteString(m.textarea.View() + "\n") @@ -292,10 +297,3 @@ func (m Model) View() string { return sb.String() } - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index d29ce18..89b109a 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -10,6 +10,7 @@ var ( WarningColor = lipgloss.Color("#FF9E3B") ErrorColor = lipgloss.Color("#FF5370") MutedColor = lipgloss.Color("#565F89") + CyanColor = lipgloss.Color("#7AA2F7") BgDark = lipgloss.Color("#1A1B26") // Header Styles @@ -19,10 +20,6 @@ var ( Background(PrimaryColor). Padding(0, 1) - SubHeaderStyle = lipgloss.NewStyle(). - Foreground(SecondaryColor). - Bold(true) - BadgeReadOnly = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). @@ -39,6 +36,7 @@ var ( SQLBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(PrimaryColor). + Background(lipgloss.Color("#1F2335")). Padding(0, 1). MarginTop(1). MarginBottom(1) @@ -47,19 +45,44 @@ var ( Bold(true). Foreground(AccentColor) + SQLCodeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7AA2F7")) + // Help / Footer HelpStyle = lipgloss.NewStyle(). - Foreground(MutedColor) + Foreground(MutedColor). + MarginTop(1) // Chat Messages - UserPromptStyle = lipgloss.NewStyle(). + UserTagStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#1A1B26")). + Background(SecondaryColor). + Padding(0, 1) + + AITagStyle = lipgloss.NewStyle(). Bold(true). - Foreground(SecondaryColor) + Foreground(lipgloss.Color("#FFFFFF")). + Background(PrimaryColor). + Padding(0, 1) + + ExecutingTagStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#1A1B26")). + Background(WarningColor). + Padding(0, 1) + + SuccessBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(SecondaryColor) AIResponseStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C0CAF5")) + Foreground(lipgloss.Color("#C0CAF5")). + PaddingLeft(1) ErrorMsgStyle = lipgloss.NewStyle(). Bold(true). - Foreground(ErrorColor) + Foreground(ErrorColor). + PaddingLeft(1) ) From 8a35d2788f018cc0c59106448b5c8cd652b90f9f Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:20:00 +0800 Subject: [PATCH 09/75] feat: auto execute initial prompt on TUI startup --- internal/tui/model.go | 18 +++++++++++++----- internal/tui/model_test.go | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8f78d9a..7dc08d6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -50,6 +50,7 @@ type Model struct { profile config.Profile profileName string unsafeAllowWrite bool + initialPrompt string state State schemaInfo *db.SchemaInfo @@ -80,16 +81,13 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(PrimaryColor) - if initialPrompt != "" { - ta.SetValue(initialPrompt) - } - return Model{ opts: opts, aiService: aiService, profile: resolved.Profile, profileName: resolved.ProfileName, unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, + initialPrompt: strings.TrimSpace(initialPrompt), state: StateLoadingSchema, textarea: ta, viewport: vp, @@ -158,8 +156,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.schemaInfo = msg.schema } + if m.initialPrompt != "" { + prompt := m.initialPrompt + m.initialPrompt = "" + userLine := UserTagStyle.Render("👤 YOU") + " " + prompt + m.messages = append(m.messages, userLine) + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.generateSQLCmd(prompt) + } m.state = StateIdle - m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) case sqlGeneratedMsg: if msg.err != nil { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 9eb2260..2e76be3 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -105,3 +105,25 @@ func TestFormatTableResult(t *testing.T) { t.Errorf("formatted table result missing row data:\n%s", formatted) } } + +func TestTUI_Model_InitialPromptAutoExecute(t *testing.T) { + resolved := config.Resolved{ + ProfileName: "dev", + Profile: config.Profile{DB: "mysql"}, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "Show total users", false) + + // When schemaLoadedMsg arrives, initialPrompt should automatically trigger StateThinking + updated, cmd := m.Update(schemaLoadedMsg{ + schema: &db.SchemaInfo{Database: "testdb"}, + }) + m = updated.(Model) + + if m.state != StateThinking { + t.Fatalf("expected state StateThinking when initialPrompt provided, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected non-nil Cmd for generateSQLCmd from initial prompt") + } +} From d471e37f0e043a6e6832488f246326af477f3a6c Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:20:59 +0800 Subject: [PATCH 10/75] fix: prevent TUI table border alignment breakage with single-line cell sanitization and width truncation --- internal/tui/components.go | 66 ++++++++++++++++++++++++++++++++++---- internal/tui/model_test.go | 12 +++++-- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 7a8f0d0..5159bf2 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -29,6 +29,8 @@ var ( Foreground(lipgloss.Color("#3B4261")) ) +const MaxColumnWidth = 28 // Max character width per cell to prevent border alignment breakage + // FormatTableResult renders a beautiful terminal box table for SQL query results. func FormatTableResult(result *db.QueryResult) string { if result == nil || len(result.Columns) == 0 { @@ -39,10 +41,24 @@ func FormatTableResult(result *db.QueryResult) string { return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(0 rows returned)") } + // Dynamic column width allocation + maxCellWidth := MaxColumnWidth + if len(result.Columns) > 10 { + maxCellWidth = 20 + } else if len(result.Columns) > 15 { + maxCellWidth = 15 + } + + // Truncate and clean column headers + headers := make([]string, len(result.Columns)) + for i, col := range result.Columns { + headers[i] = sanitizeCell(col, maxCellWidth) + } + t := table.New(). Border(lipgloss.RoundedBorder()). BorderStyle(TableBorderStyle). - Headers(result.Columns...) + Headers(headers...) // Configure header styling t.StyleFunc(func(row, col int) lipgloss.Style { @@ -58,6 +74,8 @@ func FormatTableResult(result *db.QueryResult) string { maxRows = 50 } + hasTruncatedCell := false + for i := 0; i < maxRows; i++ { var rowValues []string for _, col := range result.Columns { @@ -65,9 +83,11 @@ func FormatTableResult(result *db.QueryResult) string { if val == nil { rowValues = append(rowValues, TableNilStyle.Render("NULL")) } else { - valStr := fmt.Sprintf("%v", val) - // Clean formatting for time string or long text - rowValues = append(rowValues, valStr) + cellStr, wasTruncated := sanitizeCellWithStatus(val, maxCellWidth) + if wasTruncated { + hasTruncatedCell = true + } + rowValues = append(rowValues, cellStr) } } t.Row(rowValues...) @@ -76,10 +96,44 @@ func FormatTableResult(result *db.QueryResult) string { var sb strings.Builder sb.WriteString(t.Render()) + var footerNotes []string if len(result.Rows) > 50 { - moreStr := fmt.Sprintf("\n... and %d more rows (truncated for performance)", len(result.Rows)-50) - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(moreStr)) + footerNotes = append(footerNotes, fmt.Sprintf("... and %d more rows", len(result.Rows)-50)) + } + if hasTruncatedCell { + footerNotes = append(footerNotes, "long text truncated with '...' for table alignment") + } + + if len(footerNotes) > 0 { + noteStr := "\n(" + strings.Join(footerNotes, " | ") + ")" + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(noteStr)) } return sb.String() } + +func sanitizeCell(val any, maxLen int) string { + res, _ := sanitizeCellWithStatus(val, maxLen) + return res +} + +func sanitizeCellWithStatus(val any, maxLen int) (string, bool) { + if val == nil { + return "NULL", false + } + s := fmt.Sprintf("%v", val) + // Replace all line breaks with spaces so the box border line never breaks + s = strings.ReplaceAll(s, "\r\n", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + s = strings.TrimSpace(s) + + runes := []rune(s) + if len(runes) > maxLen { + if maxLen <= 3 { + return string(runes[:maxLen]), true + } + return string(runes[:maxLen-3]) + "...", true + } + return s, false +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 2e76be3..bee572b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -93,10 +93,10 @@ func TestTUI_Model_StateTransitions(t *testing.T) { func TestFormatTableResult(t *testing.T) { res := &db.QueryResult{ - Columns: []string{"id", "username"}, + Columns: []string{"id", "username", "extra_json"}, Rows: []map[string]any{ - {"id": 1, "username": "admin"}, - {"id": 2, "username": "guest"}, + {"id": 1, "username": "admin", "extra_json": "{\n \"key\": \"very long value that exceeds column limit\"\n}"}, + {"id": 2, "username": "guest", "extra_json": nil}, }, } @@ -104,6 +104,12 @@ func TestFormatTableResult(t *testing.T) { if !strings.Contains(formatted, "admin") || !strings.Contains(formatted, "guest") { t.Errorf("formatted table result missing row data:\n%s", formatted) } + if !strings.Contains(formatted, "NULL") { + t.Errorf("expected NULL representation for nil value, got:\n%s", formatted) + } + if strings.Contains(formatted, "\n \"key\"") { + t.Errorf("expected newlines inside cells to be sanitized, got:\n%s", formatted) + } } func TestTUI_Model_InitialPromptAutoExecute(t *testing.T) { From 9c9d2808cb1103b196460bb146fef183ca7b791a Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:22:44 +0800 Subject: [PATCH 11/75] feat: add full untruncated vertical view toggle (Ctrl+V) for query results --- internal/tui/components.go | 67 +++++++++++++++++++++++++++++++++++++- internal/tui/model.go | 34 +++++++++++++++---- internal/tui/model_test.go | 8 +++++ 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 5159bf2..4004df8 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -27,6 +27,17 @@ var ( TableBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#3B4261")) + + FieldKeyStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7AA2F7")) + + FieldValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C0CAF5")) + + RecordDividerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) ) const MaxColumnWidth = 28 // Max character width per cell to prevent border alignment breakage @@ -101,7 +112,7 @@ func FormatTableResult(result *db.QueryResult) string { footerNotes = append(footerNotes, fmt.Sprintf("... and %d more rows", len(result.Rows)-50)) } if hasTruncatedCell { - footerNotes = append(footerNotes, "long text truncated with '...' for table alignment") + footerNotes = append(footerNotes, "long text truncated; press Ctrl+V for Full Vertical View") } if len(footerNotes) > 0 { @@ -112,6 +123,60 @@ func FormatTableResult(result *db.QueryResult) string { return sb.String() } +// FormatVerticalResult renders SQL query results in full vertical (psql \x expanded) format with NO truncation. +func FormatVerticalResult(result *db.QueryResult) string { + if result == nil || len(result.Columns) == 0 { + return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") + } + + if len(result.Rows) == 0 { + return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(0 rows returned)") + } + + maxKeyLen := 0 + for _, col := range result.Columns { + if len(col) > maxKeyLen { + maxKeyLen = len(col) + } + } + + maxRows := len(result.Rows) + if maxRows > 50 { + maxRows = 50 + } + + var sb strings.Builder + for i := 0; i < maxRows; i++ { + divider := fmt.Sprintf("━ Record %d of %d ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", i+1, len(result.Rows)) + sb.WriteString(RecordDividerStyle.Render(divider) + "\n") + + for _, col := range result.Columns { + val := result.Rows[i][col] + keyStr := FieldKeyStyle.Render(fmt.Sprintf("%-*s", maxKeyLen, col)) + + if val == nil { + sb.WriteString(fmt.Sprintf(" %s : %s\n", keyStr, TableNilStyle.Render("NULL"))) + } else { + valStr := fmt.Sprintf("%v", val) + // Full display with indentation for multiline text + if strings.Contains(valStr, "\n") { + indented := strings.ReplaceAll(valStr, "\n", "\n ") + sb.WriteString(fmt.Sprintf(" %s :\n %s\n", keyStr, FieldValueStyle.Render(indented))) + } else { + sb.WriteString(fmt.Sprintf(" %s : %s\n", keyStr, FieldValueStyle.Render(valStr))) + } + } + } + sb.WriteString("\n") + } + + if len(result.Rows) > 50 { + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(fmt.Sprintf("... and %d more rows truncated\n", len(result.Rows)-50))) + } + + return sb.String() +} + func sanitizeCell(val any, maxLen int) string { res, _ := sanitizeCellWithStatus(val, maxLen) return res diff --git a/internal/tui/model.go b/internal/tui/model.go index 7dc08d6..cb807a0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -52,11 +52,13 @@ type Model struct { unsafeAllowWrite bool initialPrompt string - state State - schemaInfo *db.SchemaInfo - currentSQL string - explanation string - messages []string + state State + schemaInfo *db.SchemaInfo + currentSQL string + explanation string + messages []string + lastResult *db.QueryResult + verticalView bool textarea textarea.Model viewport viewport.Model @@ -193,9 +195,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) } else if msg.result != nil { + m.lastResult = msg.result statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) m.messages = append(m.messages, statusLine) - m.messages = append(m.messages, FormatTableResult(msg.result)) + + formatted := FormatTableResult(msg.result) + if m.verticalView { + formatted = FormatVerticalResult(msg.result) + } + m.messages = append(m.messages, formatted) } m.state = StateIdle m.viewport.SetContent(strings.Join(m.messages, "\n\n")) @@ -211,6 +219,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit + case tea.KeyCtrlV: // Toggle Vertical (psql \x) full untruncated view + if m.lastResult != nil && len(m.messages) > 0 { + m.verticalView = !m.verticalView + formatted := FormatTableResult(m.lastResult) + if m.verticalView { + formatted = FormatVerticalResult(m.lastResult) + } + m.messages[len(m.messages)-1] = formatted + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + } + case tea.KeyCtrlE: // Execute current SQL if m.state == StateSQLReady && m.currentSQL != "" { m.state = StateExecuting @@ -300,7 +320,7 @@ func (m Model) View() string { } sb.WriteString(m.textarea.View() + "\n") - help := "Enter: Send Prompt | Ctrl+E: Execute SQL | Ctrl+R: Edit SQL | Esc/Ctrl+C: Quit" + help := "Enter: Send Prompt | Ctrl+E: Execute SQL | Ctrl+R: Edit SQL | Ctrl+V: Toggle Full Vertical View (\\x) | Esc: Quit" sb.WriteString(HelpStyle.Render(help)) return sb.String() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index bee572b..1b6778c 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -110,6 +110,14 @@ func TestFormatTableResult(t *testing.T) { if strings.Contains(formatted, "\n \"key\"") { t.Errorf("expected newlines inside cells to be sanitized, got:\n%s", formatted) } + + vertFormatted := FormatVerticalResult(res) + if !strings.Contains(vertFormatted, "Record 1 of 2") { + t.Errorf("expected vertical view header, got:\n%s", vertFormatted) + } + if !strings.Contains(vertFormatted, "very long value that exceeds column limit") { + t.Errorf("expected vertical view to display untruncated multiline text, got:\n%s", vertFormatted) + } } func TestTUI_Model_InitialPromptAutoExecute(t *testing.T) { From 9c98c3de6740b16661e02ab8f3144fbf0ff35c84 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:22:54 +0800 Subject: [PATCH 12/75] docs: add Ctrl+V vertical view toggle shortcut in docs/ai.md --- docs/ai.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ai.md b/docs/ai.md index 801db0e..5a2c4d9 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -119,5 +119,6 @@ xsql ai --profile dev - `Enter`: 提交自然语言需求给 AI - `Ctrl+E`: 确认并安全执行当前生成预览的 SQL - `Ctrl+R`: 切换到 SQL 文本手工微调模式 +- `Ctrl+V`: 一键切换全量垂直展开查看模式 (`psql \x` 全字段无截断展示) - `Esc` / `Ctrl+C`: 退出 AI 模式 From 74f7e84819d4beb3b531705481ab201b6494795e Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:24:55 +0800 Subject: [PATCH 13/75] feat: support Shift+Tab shortcut to toggle between Auto-Execute and Manual-Approve modes --- internal/tui/model.go | 25 +++++++++++++++++++++++-- internal/tui/model_test.go | 35 +++++++++++++++++++++++++++++++++++ internal/tui/styles.go | 12 ++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index cb807a0..ccef109 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -51,6 +51,7 @@ type Model struct { profileName string unsafeAllowWrite bool initialPrompt string + autoExecute bool state State schemaInfo *db.SchemaInfo @@ -90,6 +91,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi profileName: resolved.ProfileName, unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), + autoExecute: false, state: StateLoadingSchema, textarea: ta, viewport: vp, @@ -183,6 +185,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, aiMsg) if msg.response.SQL != "" { + if m.autoExecute { + m.state = StateExecuting + execLine := ExecutingTagStyle.Render("⚡ Auto-Executing") + " " + SQLCodeStyle.Render(m.currentSQL) + m.messages = append(m.messages, execLine) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.executeSQLCmd(m.currentSQL) + } m.state = StateSQLReady } else { m.state = StateIdle @@ -219,6 +229,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit + case tea.KeyShiftTab: // Toggle Auto-Execute vs Manual-Approve mode + m.autoExecute = !m.autoExecute + case tea.KeyCtrlV: // Toggle Vertical (psql \x) full untruncated view if m.lastResult != nil && len(m.messages) > 0 { m.verticalView = !m.verticalView @@ -291,7 +304,11 @@ func (m Model) View() string { if m.unsafeAllowWrite { modeBadge = BadgeReadWrite.Render("READ-WRITE") } - header := fmt.Sprintf(" xsql AI | Profile: %s (%s) | %s ", m.profileName, m.profile.DB, modeBadge) + execModeBadge := BadgeManualApprove.Render("MANUAL-APPROVE") + if m.autoExecute { + execModeBadge = BadgeAutoExec.Render("AUTO-EXECUTE") + } + header := fmt.Sprintf(" xsql AI | Profile: %s (%s) | %s | Mode: %s ", m.profileName, m.profile.DB, modeBadge, execModeBadge) sb.WriteString(HeaderStyle.Width(m.width).Render(header) + "\n\n") // 2. Main Viewport (Messages & Results) @@ -320,7 +337,11 @@ func (m Model) View() string { } sb.WriteString(m.textarea.View() + "\n") - help := "Enter: Send Prompt | Ctrl+E: Execute SQL | Ctrl+R: Edit SQL | Ctrl+V: Toggle Full Vertical View (\\x) | Esc: Quit" + execModeHint := "MANUAL" + if m.autoExecute { + execModeHint = "AUTO" + } + help := fmt.Sprintf("Enter: Send | Ctrl+E: Exec SQL | Ctrl+R: Edit | Ctrl+V: Vertical View | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) sb.WriteString(HelpStyle.Render(help)) return sb.String() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 1b6778c..86347dd 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -141,3 +141,38 @@ func TestTUI_Model_InitialPromptAutoExecute(t *testing.T) { t.Fatal("expected non-nil Cmd for generateSQLCmd from initial prompt") } } + +func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { + resolved := config.Resolved{ + ProfileName: "dev", + Profile: config.Profile{DB: "mysql"}, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + + if m.autoExecute { + t.Fatal("expected autoExecute to be false by default") + } + + // Press Shift+Tab -> toggle to autoExecute = true + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + m = updated.(Model) + if !m.autoExecute { + t.Fatal("expected autoExecute to be true after Shift+Tab") + } + + // Send sqlGeneratedMsg -> should automatically transition to StateExecuting + updated, cmd := m.Update(sqlGeneratedMsg{ + response: &ai.SQLResponse{ + SQL: "SELECT * FROM users;", + Explanation: "Returns users.", + }, + }) + m = updated.(Model) + if m.state != StateExecuting { + t.Fatalf("expected state StateExecuting when autoExecute is true, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected non-nil executeSQLCmd for auto-execution") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 89b109a..4bb7384 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -32,6 +32,18 @@ var ( Background(WarningColor). Padding(0, 1) + BadgeAutoExec = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(PrimaryColor). + Padding(0, 1) + + BadgeManualApprove = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#C0CAF5")). + Background(lipgloss.Color("#3B4261")). + Padding(0, 1) + // SQL Preview Box SQLBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). From dea22cc6553c903129d132de77eb7a17fadcc6d0 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:25:03 +0800 Subject: [PATCH 14/75] docs: add Shift+Tab shortcut description in docs/ai.md --- docs/ai.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ai.md b/docs/ai.md index 5a2c4d9..7a2e9f3 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -117,6 +117,7 @@ xsql ai --profile dev ### 快捷键操作 - `Enter`: 提交自然语言需求给 AI +- `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Ctrl+E`: 确认并安全执行当前生成预览的 SQL - `Ctrl+R`: 切换到 SQL 文本手工微调模式 - `Ctrl+V`: 一键切换全量垂直展开查看模式 (`psql \x` 全字段无截断展示) From 7700df8fe5341c3b27964dd8f24052f8789ac2c3 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:30:53 +0800 Subject: [PATCH 15/75] feat: implement strict single-line no-wrap table with horizontal column scrolling via left/right arrow keys --- internal/tui/components.go | 107 +++++++++++++++++++++++++++++++------ internal/tui/model.go | 40 ++++++++++---- internal/tui/model_test.go | 2 +- 3 files changed, 122 insertions(+), 27 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 4004df8..97dd676 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -38,12 +38,16 @@ var ( RecordDividerStyle = lipgloss.NewStyle(). Bold(true). Foreground(PrimaryColor) + + ScrollBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FF75B5")) ) -const MaxColumnWidth = 28 // Max character width per cell to prevent border alignment breakage +const MaxColumnWidth = 24 // Max character width per cell to keep grid compact -// FormatTableResult renders a beautiful terminal box table for SQL query results. -func FormatTableResult(result *db.QueryResult) string { +// FormatTableResult renders a beautiful terminal box table for SQL query results with horizontal column scrolling. +func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) string { if result == nil || len(result.Columns) == 0 { return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") } @@ -52,18 +56,83 @@ func FormatTableResult(result *db.QueryResult) string { return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(0 rows returned)") } - // Dynamic column width allocation - maxCellWidth := MaxColumnWidth - if len(result.Columns) > 10 { - maxCellWidth = 20 - } else if len(result.Columns) > 15 { - maxCellWidth = 15 + if termWidth <= 20 { + termWidth = 80 } - // Truncate and clean column headers - headers := make([]string, len(result.Columns)) + // Calculate maximum width needed for each column + totalCols := len(result.Columns) + if colOffset >= totalCols { + colOffset = totalCols - 1 + } + if colOffset < 0 { + colOffset = 0 + } + + colWidths := make([]int, totalCols) for i, col := range result.Columns { - headers[i] = sanitizeCell(col, maxCellWidth) + w := len(col) + if w > MaxColumnWidth { + w = MaxColumnWidth + } + // Inspect sample rows for width calculation + sampleCount := len(result.Rows) + if sampleCount > 20 { + sampleCount = 20 + } + for r := 0; r < sampleCount; r++ { + val := result.Rows[r][col] + if val != nil { + cellStr := fmt.Sprintf("%v", val) + cellStr = strings.ReplaceAll(cellStr, "\n", " ") + runesLen := len([]rune(cellStr)) + if runesLen > w { + w = runesLen + } + } + } + if w > MaxColumnWidth { + w = MaxColumnWidth + } + if w < 6 { + w = 6 + } + // Add padding (2 chars) + border (1 char) + colWidths[i] = w + 3 + } + + // Determine visible column range [startCol, endCol) that fits within termWidth - 6 + availWidth := termWidth - 8 + if availWidth < 30 { + availWidth = 30 + } + + startCol := colOffset + endCol := startCol + accumWidth := 0 + + for i := startCol; i < totalCols; i++ { + if accumWidth+colWidths[i] > availWidth && endCol > startCol { + break + } + accumWidth += colWidths[i] + endCol = i + 1 + } + + visibleCols := result.Columns[startCol:endCol] + + // Truncate and clean visible headers + headers := make([]string, len(visibleCols)) + for i, col := range visibleCols { + colIdx := startCol + i + headerText := sanitizeCell(col, colWidths[colIdx]-3) + if i == 0 && startCol > 0 { + headerText = "◀ " + headerText + } + if i == len(visibleCols)-1 && endCol < totalCols { + headerText = headerText + fmt.Sprintf(" ▶(+%d)", totalCols-endCol) + } + headers[i] = headerText } t := table.New(). @@ -89,12 +158,13 @@ func FormatTableResult(result *db.QueryResult) string { for i := 0; i < maxRows; i++ { var rowValues []string - for _, col := range result.Columns { + for idx, col := range visibleCols { + colIdx := startCol + idx val := result.Rows[i][col] if val == nil { rowValues = append(rowValues, TableNilStyle.Render("NULL")) } else { - cellStr, wasTruncated := sanitizeCellWithStatus(val, maxCellWidth) + cellStr, wasTruncated := sanitizeCellWithStatus(val, colWidths[colIdx]-3) if wasTruncated { hasTruncatedCell = true } @@ -109,10 +179,13 @@ func FormatTableResult(result *db.QueryResult) string { var footerNotes []string if len(result.Rows) > 50 { - footerNotes = append(footerNotes, fmt.Sprintf("... and %d more rows", len(result.Rows)-50)) + footerNotes = append(footerNotes, fmt.Sprintf("... %d more rows", len(result.Rows)-50)) + } + if startCol > 0 || endCol < totalCols { + footerNotes = append(footerNotes, fmt.Sprintf("Showing cols %d-%d of %d (Use ←/→ keys to scroll columns)", startCol+1, endCol, totalCols)) } if hasTruncatedCell { - footerNotes = append(footerNotes, "long text truncated; press Ctrl+V for Full Vertical View") + footerNotes = append(footerNotes, "press Ctrl+V for Full Vertical View") } if len(footerNotes) > 0 { @@ -153,7 +226,7 @@ func FormatVerticalResult(result *db.QueryResult) string { for _, col := range result.Columns { val := result.Rows[i][col] keyStr := FieldKeyStyle.Render(fmt.Sprintf("%-*s", maxKeyLen, col)) - + if val == nil { sb.WriteString(fmt.Sprintf(" %s : %s\n", keyStr, TableNilStyle.Render("NULL"))) } else { diff --git a/internal/tui/model.go b/internal/tui/model.go index ccef109..6207404 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -60,6 +60,7 @@ type Model struct { messages []string lastResult *db.QueryResult verticalView bool + colOffset int textarea textarea.Model viewport viewport.Model @@ -92,6 +93,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, + colOffset: 0, state: StateLoadingSchema, textarea: ta, viewport: vp, @@ -143,6 +145,19 @@ func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { } } +func (m *Model) renderLastResult() { + if m.lastResult == nil || len(m.messages) == 0 { + return + } + formatted := FormatTableResult(m.lastResult, m.colOffset, m.width) + if m.verticalView { + formatted = FormatVerticalResult(m.lastResult) + } + m.messages[len(m.messages)-1] = formatted + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -206,10 +221,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) } else if msg.result != nil { m.lastResult = msg.result + m.colOffset = 0 statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) m.messages = append(m.messages, statusLine) - formatted := FormatTableResult(msg.result) + formatted := FormatTableResult(msg.result, m.colOffset, m.width) if m.verticalView { formatted = FormatVerticalResult(msg.result) } @@ -229,19 +245,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit + case tea.KeyLeft: + if m.lastResult != nil && m.colOffset > 0 { + m.colOffset-- + m.renderLastResult() + } + + case tea.KeyRight: + if m.lastResult != nil && m.colOffset < len(m.lastResult.Columns)-1 { + m.colOffset++ + m.renderLastResult() + } + case tea.KeyShiftTab: // Toggle Auto-Execute vs Manual-Approve mode m.autoExecute = !m.autoExecute case tea.KeyCtrlV: // Toggle Vertical (psql \x) full untruncated view if m.lastResult != nil && len(m.messages) > 0 { m.verticalView = !m.verticalView - formatted := FormatTableResult(m.lastResult) - if m.verticalView { - formatted = FormatVerticalResult(m.lastResult) - } - m.messages[len(m.messages)-1] = formatted - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() + m.renderLastResult() } case tea.KeyCtrlE: // Execute current SQL @@ -341,7 +363,7 @@ func (m Model) View() string { if m.autoExecute { execModeHint = "AUTO" } - help := fmt.Sprintf("Enter: Send | Ctrl+E: Exec SQL | Ctrl+R: Edit | Ctrl+V: Vertical View | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + help := fmt.Sprintf("Enter: Send | ←/→: Scroll Cols | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical View | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) sb.WriteString(HelpStyle.Render(help)) return sb.String() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 86347dd..c717dc4 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -100,7 +100,7 @@ func TestFormatTableResult(t *testing.T) { }, } - formatted := FormatTableResult(res) + formatted := FormatTableResult(res, 0, 80) if !strings.Contains(formatted, "admin") || !strings.Contains(formatted, "guest") { t.Errorf("formatted table result missing row data:\n%s", formatted) } From a87acb53b6b8c86e4b9ebffe86020288c6dde54d Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:31:01 +0800 Subject: [PATCH 16/75] docs: add arrow keys horizontal column scrolling description in docs/ai.md --- docs/ai.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ai.md b/docs/ai.md index 7a2e9f3..e886ffc 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -117,6 +117,7 @@ xsql ai --profile dev ### 快捷键操作 - `Enter`: 提交自然语言需求给 AI +- `←` / `→`: 横向平滑滚动查看宽表隐藏的列 - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Ctrl+E`: 确认并安全执行当前生成预览的 SQL - `Ctrl+R`: 切换到 SQL 文本手工微调模式 From b504ab20cd2da9a7d18715c59417fd52a92509a7 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:36:08 +0800 Subject: [PATCH 17/75] feat: support PgUp/PgDn/Up/Down viewport scrolling and limit single table height to 12 rows for optimal viewport visibility --- internal/tui/components.go | 10 +++++----- internal/tui/model.go | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 97dd676..518d9a2 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -148,10 +148,10 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str return TableCellStyle }) - // Add data rows (limit to 50 rows for viewport cleanliness) + // Add data rows (limit to 12 rows for optimal viewport visibility) maxRows := len(result.Rows) - if maxRows > 50 { - maxRows = 50 + if maxRows > 12 { + maxRows = 12 } hasTruncatedCell := false @@ -178,8 +178,8 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str sb.WriteString(t.Render()) var footerNotes []string - if len(result.Rows) > 50 { - footerNotes = append(footerNotes, fmt.Sprintf("... %d more rows", len(result.Rows)-50)) + if len(result.Rows) > 12 { + footerNotes = append(footerNotes, fmt.Sprintf("showing 1-12 of %d rows (Press PgUp/PgDn to scroll, Ctrl+V for Full View)", len(result.Rows))) } if startCol > 0 || endCol < totalCols { footerNotes = append(footerNotes, fmt.Sprintf("Showing cols %d-%d of %d (Use ←/→ keys to scroll columns)", startCol+1, endCol, totalCols)) diff --git a/internal/tui/model.go b/internal/tui/model.go index 6207404..908f459 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -257,6 +257,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.renderLastResult() } + case tea.KeyPgUp: + m.viewport.LineUp(6) + return m, nil + + case tea.KeyPgDown: + m.viewport.LineDown(6) + return m, nil + + case tea.KeyUp: + if !m.editingSQL { + m.viewport.LineUp(1) + return m, nil + } + + case tea.KeyDown: + if !m.editingSQL { + m.viewport.LineDown(1) + return m, nil + } + case tea.KeyShiftTab: // Toggle Auto-Execute vs Manual-Approve mode m.autoExecute = !m.autoExecute @@ -363,7 +383,7 @@ func (m Model) View() string { if m.autoExecute { execModeHint = "AUTO" } - help := fmt.Sprintf("Enter: Send | ←/→: Scroll Cols | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical View | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + help := fmt.Sprintf("Enter: Send | ←/→: Cols | PgUp/PgDn: Scroll | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) sb.WriteString(HelpStyle.Render(help)) return sb.String() From 3e3eb20b6bf6ec3684dd71625f5f1b897eb49115 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:36:19 +0800 Subject: [PATCH 18/75] docs: add PgUp/PgDn viewport scrolling shortcut in docs/ai.md --- docs/ai.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ai.md b/docs/ai.md index e886ffc..538fee2 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -118,6 +118,7 @@ xsql ai --profile dev ### 快捷键操作 - `Enter`: 提交自然语言需求给 AI - `←` / `→`: 横向平滑滚动查看宽表隐藏的列 +- `PgUp` / `PgDn` / `↑` / `↓`: 垂直上下自由滚动查看历史对话与表格全文 - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Ctrl+E`: 确认并安全执行当前生成预览的 SQL - `Ctrl+R`: 切换到 SQL 文本手工微调模式 From 31fcc9a97c6abc735e5af25a008fd2701bee68af Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:42:42 +0800 Subject: [PATCH 19/75] feat: support PgUp/PgDn row pagination for large query results in TUI table --- internal/tui/components.go | 48 +++++++++++++++++++++++--------------- internal/tui/model.go | 19 ++++++++++++--- internal/tui/model_test.go | 2 +- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 518d9a2..10e6998 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -44,10 +44,13 @@ var ( Foreground(lipgloss.Color("#FF75B5")) ) -const MaxColumnWidth = 24 // Max character width per cell to keep grid compact +const ( + MaxColumnWidth = 24 // Max character width per cell to keep grid compact + PageRowSize = 12 // Rows per page +) -// FormatTableResult renders a beautiful terminal box table for SQL query results with horizontal column scrolling. -func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) string { +// FormatTableResult renders a beautiful terminal box table for SQL query results with column scrolling and row pagination. +func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, termWidth int) string { if result == nil || len(result.Columns) == 0 { return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") } @@ -60,6 +63,14 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str termWidth = 80 } + totalRows := len(result.Rows) + if rowOffset >= totalRows { + rowOffset = (totalRows - 1) / PageRowSize * PageRowSize + } + if rowOffset < 0 { + rowOffset = 0 + } + // Calculate maximum width needed for each column totalCols := len(result.Columns) if colOffset >= totalCols { @@ -75,12 +86,12 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str if w > MaxColumnWidth { w = MaxColumnWidth } - // Inspect sample rows for width calculation - sampleCount := len(result.Rows) - if sampleCount > 20 { - sampleCount = 20 + // Inspect current page rows for width calculation + endR := rowOffset + PageRowSize + if endR > totalRows { + endR = totalRows } - for r := 0; r < sampleCount; r++ { + for r := rowOffset; r < endR; r++ { val := result.Rows[r][col] if val != nil { cellStr := fmt.Sprintf("%v", val) @@ -97,11 +108,10 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str if w < 6 { w = 6 } - // Add padding (2 chars) + border (1 char) colWidths[i] = w + 3 } - // Determine visible column range [startCol, endCol) that fits within termWidth - 6 + // Determine visible column range [startCol, endCol) that fits within termWidth - 8 availWidth := termWidth - 8 if availWidth < 30 { availWidth = 30 @@ -148,15 +158,15 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str return TableCellStyle }) - // Add data rows (limit to 12 rows for optimal viewport visibility) - maxRows := len(result.Rows) - if maxRows > 12 { - maxRows = 12 + // Add page rows [rowOffset, min(totalRows, rowOffset+PageRowSize)) + endRow := rowOffset + PageRowSize + if endRow > totalRows { + endRow = totalRows } hasTruncatedCell := false - for i := 0; i < maxRows; i++ { + for i := rowOffset; i < endRow; i++ { var rowValues []string for idx, col := range visibleCols { colIdx := startCol + idx @@ -178,14 +188,14 @@ func FormatTableResult(result *db.QueryResult, colOffset int, termWidth int) str sb.WriteString(t.Render()) var footerNotes []string - if len(result.Rows) > 12 { - footerNotes = append(footerNotes, fmt.Sprintf("showing 1-12 of %d rows (Press PgUp/PgDn to scroll, Ctrl+V for Full View)", len(result.Rows))) + if totalRows > PageRowSize { + footerNotes = append(footerNotes, fmt.Sprintf("rows %d-%d of %d (Press n/p for Next/Prev Page)", rowOffset+1, endRow, totalRows)) } if startCol > 0 || endCol < totalCols { - footerNotes = append(footerNotes, fmt.Sprintf("Showing cols %d-%d of %d (Use ←/→ keys to scroll columns)", startCol+1, endCol, totalCols)) + footerNotes = append(footerNotes, fmt.Sprintf("cols %d-%d of %d (Use ←/→ keys to scroll cols)", startCol+1, endCol, totalCols)) } if hasTruncatedCell { - footerNotes = append(footerNotes, "press Ctrl+V for Full Vertical View") + footerNotes = append(footerNotes, "press Ctrl+V for Full View") } if len(footerNotes) > 0 { diff --git a/internal/tui/model.go b/internal/tui/model.go index 908f459..5ea564d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -61,6 +61,7 @@ type Model struct { lastResult *db.QueryResult verticalView bool colOffset int + rowOffset int textarea textarea.Model viewport viewport.Model @@ -94,6 +95,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi initialPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, colOffset: 0, + rowOffset: 0, state: StateLoadingSchema, textarea: ta, viewport: vp, @@ -149,7 +151,7 @@ func (m *Model) renderLastResult() { if m.lastResult == nil || len(m.messages) == 0 { return } - formatted := FormatTableResult(m.lastResult, m.colOffset, m.width) + formatted := FormatTableResult(m.lastResult, m.colOffset, m.rowOffset, m.width) if m.verticalView { formatted = FormatVerticalResult(m.lastResult) } @@ -222,10 +224,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if msg.result != nil { m.lastResult = msg.result m.colOffset = 0 + m.rowOffset = 0 statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) m.messages = append(m.messages, statusLine) - formatted := FormatTableResult(msg.result, m.colOffset, m.width) + formatted := FormatTableResult(msg.result, m.colOffset, m.rowOffset, m.width) if m.verticalView { formatted = FormatVerticalResult(msg.result) } @@ -258,10 +261,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyPgUp: + if m.lastResult != nil && m.rowOffset >= PageRowSize { + m.rowOffset -= PageRowSize + m.renderLastResult() + return m, nil + } m.viewport.LineUp(6) return m, nil case tea.KeyPgDown: + if m.lastResult != nil && m.rowOffset+PageRowSize < len(m.lastResult.Rows) { + m.rowOffset += PageRowSize + m.renderLastResult() + return m, nil + } m.viewport.LineDown(6) return m, nil @@ -383,7 +396,7 @@ func (m Model) View() string { if m.autoExecute { execModeHint = "AUTO" } - help := fmt.Sprintf("Enter: Send | ←/→: Cols | PgUp/PgDn: Scroll | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + help := fmt.Sprintf("Enter: Send | ←/→: Cols | PgUp/PgDn: Page Rows | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) sb.WriteString(HelpStyle.Render(help)) return sb.String() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index c717dc4..d82176f 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -100,7 +100,7 @@ func TestFormatTableResult(t *testing.T) { }, } - formatted := FormatTableResult(res, 0, 80) + formatted := FormatTableResult(res, 0, 0, 80) if !strings.Contains(formatted, "admin") || !strings.Contains(formatted, "guest") { t.Errorf("formatted table result missing row data:\n%s", formatted) } From de505f81f028f85174611318a5cc264df19958a5 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:42:51 +0800 Subject: [PATCH 20/75] docs: update PgUp/PgDn row pagination shortcut description in docs/ai.md --- docs/ai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai.md b/docs/ai.md index 538fee2..7d396a3 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -118,7 +118,7 @@ xsql ai --profile dev ### 快捷键操作 - `Enter`: 提交自然语言需求给 AI - `←` / `→`: 横向平滑滚动查看宽表隐藏的列 -- `PgUp` / `PgDn` / `↑` / `↓`: 垂直上下自由滚动查看历史对话与表格全文 +- `PgUp` / `PgDn`: 结果行数据向上/向下翻页查看(每次翻页 12 行) - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Ctrl+E`: 确认并安全执行当前生成预览的 SQL - `Ctrl+R`: 切换到 SQL 文本手工微调模式 From bf37e1653e95d6d25ddba2586dd425603123f4cf Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:49:42 +0800 Subject: [PATCH 21/75] feat: support Tab key focus switching and independent scrolling for historical query tables --- internal/tui/components.go | 25 ++++++-- internal/tui/model.go | 116 +++++++++++++++++++++++++------------ internal/tui/model_test.go | 2 +- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 10e6998..806b1d7 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -28,6 +28,9 @@ var ( TableBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#3B4261")) + ActiveTableBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7AA2F7")) + FieldKeyStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7AA2F7")) @@ -50,7 +53,7 @@ const ( ) // FormatTableResult renders a beautiful terminal box table for SQL query results with column scrolling and row pagination. -func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, termWidth int) string { +func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, termWidth int, isActive bool) string { if result == nil || len(result.Columns) == 0 { return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") } @@ -145,9 +148,14 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter headers[i] = headerText } + borderStyle := TableBorderStyle + if isActive { + borderStyle = ActiveTableBorderStyle + } + t := table.New(). Border(lipgloss.RoundedBorder()). - BorderStyle(TableBorderStyle). + BorderStyle(borderStyle). Headers(headers...) // Configure header styling @@ -188,11 +196,14 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter sb.WriteString(t.Render()) var footerNotes []string + if isActive { + footerNotes = append(footerNotes, "[FOCUSED]") + } if totalRows > PageRowSize { - footerNotes = append(footerNotes, fmt.Sprintf("rows %d-%d of %d (Press n/p for Next/Prev Page)", rowOffset+1, endRow, totalRows)) + footerNotes = append(footerNotes, fmt.Sprintf("rows %d-%d of %d (Press PgUp/PgDn for Page)", rowOffset+1, endRow, totalRows)) } if startCol > 0 || endCol < totalCols { - footerNotes = append(footerNotes, fmt.Sprintf("cols %d-%d of %d (Use ←/→ keys to scroll cols)", startCol+1, endCol, totalCols)) + footerNotes = append(footerNotes, fmt.Sprintf("cols %d-%d of %d (Use ←/→ keys for Cols)", startCol+1, endCol, totalCols)) } if hasTruncatedCell { footerNotes = append(footerNotes, "press Ctrl+V for Full View") @@ -200,7 +211,11 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter if len(footerNotes) > 0 { noteStr := "\n(" + strings.Join(footerNotes, " | ") + ")" - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(noteStr)) + if isActive { + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7AA2F7")).Bold(true).Render(noteStr)) + } else { + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(noteStr)) + } } return sb.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index 5ea564d..e00e46e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -44,6 +44,14 @@ type queryExecutedMsg struct { err *errors.XError } +type TableState struct { + Result *db.QueryResult + MsgIndex int + ColOffset int + RowOffset int + VerticalView bool +} + type Model struct { opts config.Options aiService *ai.Service @@ -58,10 +66,8 @@ type Model struct { currentSQL string explanation string messages []string - lastResult *db.QueryResult - verticalView bool - colOffset int - rowOffset int + tableStates []TableState + activeTable int textarea textarea.Model viewport viewport.Model @@ -74,7 +80,7 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() - ta.Placeholder = "Ask AI to write a SQL query (e.g. 'Show top 10 users')..." + ta.Placeholder = "Ask AI to write a SQL query (e.g. 'Show top 10 users')...." ta.Focus() ta.CharLimit = 1000 ta.SetWidth(80) @@ -94,8 +100,8 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, - colOffset: 0, - rowOffset: 0, + tableStates: []TableState{}, + activeTable: -1, state: StateLoadingSchema, textarea: ta, viewport: vp, @@ -147,17 +153,20 @@ func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { } } -func (m *Model) renderLastResult() { - if m.lastResult == nil || len(m.messages) == 0 { +func (m *Model) renderTableState(idx int, isActive bool) { + if idx < 0 || idx >= len(m.tableStates) { + return + } + ts := &m.tableStates[idx] + if ts.MsgIndex < 0 || ts.MsgIndex >= len(m.messages) { return } - formatted := FormatTableResult(m.lastResult, m.colOffset, m.rowOffset, m.width) - if m.verticalView { - formatted = FormatVerticalResult(m.lastResult) + formatted := FormatTableResult(ts.Result, ts.ColOffset, ts.RowOffset, m.width, isActive) + if ts.VerticalView { + formatted = FormatVerticalResult(ts.Result) } - m.messages[len(m.messages)-1] = formatted + m.messages[ts.MsgIndex] = formatted m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -222,16 +231,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) } else if msg.result != nil { - m.lastResult = msg.result - m.colOffset = 0 - m.rowOffset = 0 statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) m.messages = append(m.messages, statusLine) - formatted := FormatTableResult(msg.result, m.colOffset, m.rowOffset, m.width) - if m.verticalView { - formatted = FormatVerticalResult(msg.result) + // Remove focus from previous active table + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + m.renderTableState(m.activeTable, false) } + + ts := TableState{ + Result: msg.result, + MsgIndex: len(m.messages), + ColOffset: 0, + RowOffset: 0, + VerticalView: false, + } + m.tableStates = append(m.tableStates, ts) + m.activeTable = len(m.tableStates) - 1 + + formatted := FormatTableResult(msg.result, 0, 0, m.width, true) m.messages = append(m.messages, formatted) } m.state = StateIdle @@ -248,32 +266,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit + case tea.KeyTab: // Toggle focus between tables in history + if len(m.tableStates) > 1 && !m.editingSQL { + oldIdx := m.activeTable + m.activeTable = (m.activeTable + 1) % len(m.tableStates) + m.renderTableState(oldIdx, false) + m.renderTableState(m.activeTable, true) + return m, nil + } + case tea.KeyLeft: - if m.lastResult != nil && m.colOffset > 0 { - m.colOffset-- - m.renderLastResult() + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + ts := &m.tableStates[m.activeTable] + if ts.ColOffset > 0 { + ts.ColOffset-- + m.renderTableState(m.activeTable, true) + } } case tea.KeyRight: - if m.lastResult != nil && m.colOffset < len(m.lastResult.Columns)-1 { - m.colOffset++ - m.renderLastResult() + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + ts := &m.tableStates[m.activeTable] + if ts.ColOffset < len(ts.Result.Columns)-1 { + ts.ColOffset++ + m.renderTableState(m.activeTable, true) + } } case tea.KeyPgUp: - if m.lastResult != nil && m.rowOffset >= PageRowSize { - m.rowOffset -= PageRowSize - m.renderLastResult() - return m, nil + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + ts := &m.tableStates[m.activeTable] + if ts.RowOffset >= PageRowSize { + ts.RowOffset -= PageRowSize + m.renderTableState(m.activeTable, true) + return m, nil + } } m.viewport.LineUp(6) return m, nil case tea.KeyPgDown: - if m.lastResult != nil && m.rowOffset+PageRowSize < len(m.lastResult.Rows) { - m.rowOffset += PageRowSize - m.renderLastResult() - return m, nil + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + ts := &m.tableStates[m.activeTable] + if ts.RowOffset+PageRowSize < len(ts.Result.Rows) { + ts.RowOffset += PageRowSize + m.renderTableState(m.activeTable, true) + return m, nil + } } m.viewport.LineDown(6) return m, nil @@ -294,9 +333,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.autoExecute = !m.autoExecute case tea.KeyCtrlV: // Toggle Vertical (psql \x) full untruncated view - if m.lastResult != nil && len(m.messages) > 0 { - m.verticalView = !m.verticalView - m.renderLastResult() + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + ts := &m.tableStates[m.activeTable] + ts.VerticalView = !ts.VerticalView + m.renderTableState(m.activeTable, true) } case tea.KeyCtrlE: // Execute current SQL @@ -396,7 +436,7 @@ func (m Model) View() string { if m.autoExecute { execModeHint = "AUTO" } - help := fmt.Sprintf("Enter: Send | ←/→: Cols | PgUp/PgDn: Page Rows | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + help := fmt.Sprintf("Enter: Send | Tab: Focus Table | ←/→: Cols | PgUp/PgDn: Page Rows | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) sb.WriteString(HelpStyle.Render(help)) return sb.String() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d82176f..849e053 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -100,7 +100,7 @@ func TestFormatTableResult(t *testing.T) { }, } - formatted := FormatTableResult(res, 0, 0, 80) + formatted := FormatTableResult(res, 0, 0, 80, true) if !strings.Contains(formatted, "admin") || !strings.Contains(formatted, "guest") { t.Errorf("formatted table result missing row data:\n%s", formatted) } From e9d2ef8d096d9952926744163e4e8e84bc59754e Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:52:03 +0800 Subject: [PATCH 22/75] docs: add Tab table focus switching shortcut description in docs/ai.md --- docs/ai.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 7d396a3..92e447b 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -117,8 +117,9 @@ xsql ai --profile dev ### 快捷键操作 - `Enter`: 提交自然语言需求给 AI -- `←` / `→`: 横向平滑滚动查看宽表隐藏的列 -- `PgUp` / `PgDn`: 结果行数据向上/向下翻页查看(每次翻页 12 行) +- `Tab`: 在历史多个查询结果表格之间无缝切换焦点 (`[FOCUSED]`) +- `←` / `→`: 横向平滑滚动查看当前焦点表格的隐藏列 +- `PgUp` / `PgDn`: 向上/向下翻页查看当前焦点表格的第 13-N 行数据 - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Ctrl+E`: 确认并安全执行当前生成预览的 SQL - `Ctrl+R`: 切换到 SQL 文本手工微调模式 From f7358bc19f7e048c9e8339855d19f55e598ff4f3 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:57:49 +0800 Subject: [PATCH 23/75] feat: refactor TUI keybindings with scoped SQL approval controls and Ctrl+E expansion toggle --- internal/tui/components.go | 11 ++-- internal/tui/model.go | 129 +++++++++++++++++++++---------------- internal/tui/model_test.go | 6 +- 3 files changed, 83 insertions(+), 63 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 806b1d7..1a93903 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -206,7 +206,7 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter footerNotes = append(footerNotes, fmt.Sprintf("cols %d-%d of %d (Use ←/→ keys for Cols)", startCol+1, endCol, totalCols)) } if hasTruncatedCell { - footerNotes = append(footerNotes, "press Ctrl+V for Full View") + footerNotes = append(footerNotes, "press Ctrl+E for Full View (Expand/Collapse)") } if len(footerNotes) > 0 { @@ -239,8 +239,9 @@ func FormatVerticalResult(result *db.QueryResult) string { } maxRows := len(result.Rows) - if maxRows > 50 { - maxRows = 50 + // Allow up to 500 rows in full vertical expansion view + if maxRows > 500 { + maxRows = 500 } var sb strings.Builder @@ -268,8 +269,8 @@ func FormatVerticalResult(result *db.QueryResult) string { sb.WriteString("\n") } - if len(result.Rows) > 50 { - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(fmt.Sprintf("... and %d more rows truncated\n", len(result.Rows)-50))) + if len(result.Rows) > 500 { + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(fmt.Sprintf("... and %d more rows (truncated for performance)\n", len(result.Rows)-500))) } return sb.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index e00e46e..6640302 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -262,12 +262,68 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case tea.KeyMsg: + // 1. Ctrl+C always quits + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + + // 2. When editing SQL in text area + if m.editingSQL { + switch msg.Type { + case tea.KeyEnter: + m.currentSQL = strings.TrimSpace(m.textarea.Value()) + m.editingSQL = false + m.textarea.Reset() + return m, nil + case tea.KeyEsc: + m.editingSQL = false + m.textarea.Reset() + return m, nil + } + var taCmd tea.Cmd + m.textarea, taCmd = m.textarea.Update(msg) + return m, taCmd + } + + // 3. When in SQLReady state (SQL preview pending approval) + if m.state == StateSQLReady { + switch { + case msg.Type == tea.KeyEnter: // Enter to Execute SQL + m.state = StateExecuting + execLine := ExecutingTagStyle.Render("⚡ Executing") + " " + SQLCodeStyle.Render(m.currentSQL) + m.messages = append(m.messages, execLine) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.executeSQLCmd(m.currentSQL) + + case msg.String() == "e" || msg.String() == "E": // 'e' key to Edit SQL + m.editingSQL = true + m.textarea.SetValue(m.currentSQL) + return m, nil + + case msg.Type == tea.KeyEsc: // Esc to Cancel SQL preview + m.state = StateIdle + return m, nil + } + } + + // 4. General TUI keyhandlers switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: + case tea.KeyEsc: return m, tea.Quit + case tea.KeyCtrlE: // Ctrl+E to Expand/Collapse full vertical view + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + ts := &m.tableStates[m.activeTable] + ts.VerticalView = !ts.VerticalView + m.renderTableState(m.activeTable, true) + } + + case tea.KeyShiftTab: // Toggle Auto-Execute vs Manual-Approve mode + m.autoExecute = !m.autoExecute + case tea.KeyTab: // Toggle focus between tables in history - if len(m.tableStates) > 1 && !m.editingSQL { + if len(m.tableStates) > 1 { oldIdx := m.activeTable m.activeTable = (m.activeTable + 1) % len(m.tableStates) m.renderTableState(oldIdx, false) @@ -318,55 +374,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyUp: - if !m.editingSQL { - m.viewport.LineUp(1) - return m, nil - } + m.viewport.LineUp(1) + return m, nil case tea.KeyDown: - if !m.editingSQL { - m.viewport.LineDown(1) - return m, nil - } - - case tea.KeyShiftTab: // Toggle Auto-Execute vs Manual-Approve mode - m.autoExecute = !m.autoExecute - - case tea.KeyCtrlV: // Toggle Vertical (psql \x) full untruncated view - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - ts := &m.tableStates[m.activeTable] - ts.VerticalView = !ts.VerticalView - m.renderTableState(m.activeTable, true) - } - - case tea.KeyCtrlE: // Execute current SQL - if m.state == StateSQLReady && m.currentSQL != "" { - m.state = StateExecuting - execLine := ExecutingTagStyle.Render("⚡ Executing") + " " + SQLCodeStyle.Render(m.currentSQL) - m.messages = append(m.messages, execLine) - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.executeSQLCmd(m.currentSQL) - } - - case tea.KeyCtrlR: // Toggle Edit SQL mode - if m.state == StateSQLReady { - m.editingSQL = !m.editingSQL - if m.editingSQL { - m.textarea.SetValue(m.currentSQL) - } - } - - case tea.KeyEnter: // Send prompt or confirm edited SQL - if m.editingSQL { - m.currentSQL = m.textarea.Value() - m.editingSQL = false - m.textarea.Reset() - return m, nil - } + m.viewport.LineDown(1) + return m, nil + case tea.KeyEnter: prompt := strings.TrimSpace(m.textarea.Value()) - if prompt != "" && (m.state == StateIdle || m.state == StateSQLReady) { + if prompt != "" && m.state == StateIdle { userLine := UserTagStyle.Render("👤 YOU") + " " + prompt m.messages = append(m.messages, userLine) m.textarea.Reset() @@ -378,11 +395,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if !m.editingSQL { - var taCmd tea.Cmd - m.textarea, taCmd = m.textarea.Update(msg) - cmds = append(cmds, taCmd) - } + var taCmd tea.Cmd + m.textarea, taCmd = m.textarea.Update(msg) + cmds = append(cmds, taCmd) var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) @@ -422,13 +437,13 @@ func (m Model) View() string { if m.currentSQL == "" { sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") } - preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Press Ctrl+E to Execute, Ctrl+R to Edit):"), sqlContent) + preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Enter: Execute | e: Edit SQL | Esc: Cancel):"), sqlContent) sb.WriteString(SQLBoxStyle.Width(m.width - 4).Render(preview) + "\n") } // 4. Input Area & Footer Hints if m.editingSQL { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Press Enter to Apply Changes):") + "\n") + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Enter: Apply | Esc: Cancel):") + "\n") } sb.WriteString(m.textarea.View() + "\n") @@ -436,7 +451,11 @@ func (m Model) View() string { if m.autoExecute { execModeHint = "AUTO" } - help := fmt.Sprintf("Enter: Send | Tab: Focus Table | ←/→: Cols | PgUp/PgDn: Page Rows | Ctrl+E: Exec | Ctrl+R: Edit | Ctrl+V: Vertical | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + + help := fmt.Sprintf("Enter: Send Prompt | Tab: Focus Table | ←/→: Cols | PgUp/PgDn: Rows | Ctrl+E: Expand/Collapse | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + if m.state == StateSQLReady { + help = fmt.Sprintf("Enter: Execute SQL | e: Edit SQL | Esc: Cancel | Ctrl+E: Expand/Collapse | Shift+Tab: Mode (%s)", execModeHint) + } sb.WriteString(HelpStyle.Render(help)) return sb.String() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 849e053..9046e8a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -62,11 +62,11 @@ func TestTUI_Model_StateTransitions(t *testing.T) { t.Errorf("expected view to contain SQL Preview, got:\n%s", viewStr) } - // 4. Test KeyMsg Ctrl+E -> transition to StateExecuting - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) + // 4. Test KeyMsg KeyEnter in StateSQLReady -> transition to StateExecuting + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(Model) if m.state != StateExecuting { - t.Fatalf("expected state StateExecuting after Ctrl+E, got %v", m.state) + t.Fatalf("expected state StateExecuting after KeyEnter, got %v", m.state) } if cmd == nil { t.Fatal("expected non-nil Cmd for executeSQLCmd") From 8ef7d67f68c2dd0007b8ad098d1220784f67da24 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:58:07 +0800 Subject: [PATCH 24/75] docs: update refactored scoped shortcuts in docs/ai.md --- docs/ai.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 92e447b..de81015 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -116,13 +116,18 @@ xsql ai --profile dev ``` ### 快捷键操作 + +#### SQL 待确认状态 (SQL Preview Mode) +- `Enter`: 确认并安全执行当前生成预览的 SQL +- `e`: 切换到 SQL 文本手工编辑/微调模式 +- `Esc`: 取消当前 SQL 生成建议,返回 Prompt 输入模式 + +#### 通用与表格操作 (General & Table Operations) - `Enter`: 提交自然语言需求给 AI +- `Ctrl+E`: 展开/收起折叠全量内容 (Toggle Expanded Full View,无 50 行截断) - `Tab`: 在历史多个查询结果表格之间无缝切换焦点 (`[FOCUSED]`) - `←` / `→`: 横向平滑滚动查看当前焦点表格的隐藏列 - `PgUp` / `PgDn`: 向上/向下翻页查看当前焦点表格的第 13-N 行数据 - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 -- `Ctrl+E`: 确认并安全执行当前生成预览的 SQL -- `Ctrl+R`: 切换到 SQL 文本手工微调模式 -- `Ctrl+V`: 一键切换全量垂直展开查看模式 (`psql \x` 全字段无截断展示) - `Esc` / `Ctrl+C`: 退出 AI 模式 From a6ad5d04e1e5daed16191642e783f0cd45806ab4 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:00:48 +0800 Subject: [PATCH 25/75] refactor: remove xsql ai subcommand and keep xsql-ai as a standalone binary --- cmd/xsql/ai.go | 83 ------------------------------------------------ docs/ai.md | 3 +- docs/cli-spec.md | 8 ++--- 3 files changed, 4 insertions(+), 90 deletions(-) delete mode 100644 cmd/xsql/ai.go diff --git a/cmd/xsql/ai.go b/cmd/xsql/ai.go deleted file mode 100644 index ff221c5..0000000 --- a/cmd/xsql/ai.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - - "github.com/zx06/xsql/internal/ai" - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/secret" - "github.com/zx06/xsql/internal/tui" -) - -type CmdAIFlags struct { - Model string - BaseURL string - APIKey string - UnsafeAllowWrite bool - Prompt string -} - -func NewAICommand() *cobra.Command { - flags := &CmdAIFlags{} - - cmd := &cobra.Command{ - Use: "ai [PROMPT]", - Short: "Interactive AI terminal mode (TUI) to write and execute SQL", - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - flags.Prompt = args[0] - } - return runCmdAI(cmd, flags) - }, - } - - cmd.Flags().StringVar(&flags.Model, "model", "", "AI model name (default: gpt-4o)") - cmd.Flags().StringVar(&flags.BaseURL, "base-url", "", "AI service base URL") - cmd.Flags().StringVar(&flags.APIKey, "api-key", "", "AI service API key") - cmd.Flags().BoolVar(&flags.UnsafeAllowWrite, "unsafe-allow-write", false, "Allow write operations (bypasses read-only protection)") - cmd.Flags().StringVar(&flags.Prompt, "prompt", "", "Initial prompt for AI query") - - return cmd -} - -func runCmdAI(cmd *cobra.Command, flags *CmdAIFlags) error { - opts := config.Options{ - ConfigPath: GlobalConfig.ConfigStr, - CLIProfile: GlobalConfig.ProfileStr, - CLIProfileSet: cmd.Flags().Changed("profile") || GlobalConfig.ProfileStr != "", - CLIAIModel: flags.Model, - CLIAIModelSet: cmd.Flags().Changed("model"), - CLIAIBaseURL: flags.BaseURL, - CLIAIBaseURLSet: cmd.Flags().Changed("base-url"), - CLIAIAPIKey: flags.APIKey, - CLIAIAPIKeySet: cmd.Flags().Changed("api-key"), - } - - resolved, xe := config.Resolve(opts) - if xe != nil { - return xe - } - - apiKey := resolved.AI.APIKey - if secret.IsKeyringRef(apiKey) { - if resolvedKey, xe := secret.Resolve(apiKey, secret.Options{AllowPlaintext: true}); xe == nil { - apiKey = resolvedKey - } - } - resolved.AI.APIKey = apiKey - - aiClient := ai.NewClient(resolved.AI, nil) - aiService := ai.NewService(resolved.AI, aiClient) - - model := tui.NewModel(opts, resolved, aiService, flags.Prompt, flags.UnsafeAllowWrite) - - p := tea.NewProgram(model, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - return fmt.Errorf("error running TUI: %w", err) - } - - return nil -} diff --git a/docs/ai.md b/docs/ai.md index de81015..af1f60e 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -107,12 +107,11 @@ xsql web Web UI 复用 xsql 的 profile、SSH、只读策略和结构化错误契约,但其 HTTP API 面向浏览器,不等同于 MCP 协议。 ## AI TUI 交互模式 (xsql-ai) -xsql 提供了交互式 AI 终端模式 `xsql-ai`(也可通过 `xsql ai` 运行)。用户只需在终端以自然语言发问,AI 结合当前数据库 Schema 结构自动构建对应的 SQL 查询,并在 TUI 中提供交互预览与安全执行: +xsql-ai 为独立的 CLI 可执行程序,提供交互式 AI 终端模式。用户只需在终端以自然语言发问,AI 结合当前数据库 Schema 结构自动构建对应的 SQL 查询,并在 TUI 中提供交互预览与安全执行: ```bash # 启动交互式 TUI xsql-ai --profile dev -xsql ai --profile dev ``` ### 快捷键操作 diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 3eb5b1f..1bde715 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -546,20 +546,18 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au - 写操作需要显式设置 `unsafe_allow_write: true` - Streamable HTTP 传输要求鉴权,请在请求中提供 `Authorization: Bearer ` 头 -### `xsql ai` / `xsql-ai` +### `xsql-ai` 独立程序 -启动交互式 AI 终端模式(TUI)或单次 AI SQL 查询能力。通过自然语言与 AI 对话,由 AI 基于当前数据库的 Schema 结构自动构建 SQL 查询,并在终端进行可视化预览与安全执行。 +`xsql-ai` 为独立的 CLI 可执行程序,提供类似 Chatbot 的交互终端(TUI)。通过自然语言与 AI 对话,由 AI 基于当前数据库的 Schema 结构自动构建 SQL 查询,并在终端进行可视化预览与安全执行。 ```bash # 启动交互式 TUI 模式 -xsql ai --profile dev -# 或者使用独立二进制程序 xsql-ai --profile dev # 指定 AI 模型和服务地址 xsql-ai --profile dev --model deepseek-coder --base-url https://api.deepseek.com/v1 -# 单次自然语言提问模式 +# 启动并直接传入初始 Prompt 自动分析执行 xsql-ai --profile dev "查一下近7天注册的用户数量" ``` From 5ab28a18f25ffdd4fd216a81e4303b30759a6e54 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:02:43 +0800 Subject: [PATCH 26/75] fix: resolve SQL editing focus and Enter key execution flow in TUI --- internal/tui/model.go | 13 ++++++++++- internal/tui/model_test.go | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 6640302..82716b5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -271,13 +271,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.editingSQL { switch msg.Type { case tea.KeyEnter: - m.currentSQL = strings.TrimSpace(m.textarea.Value()) + editedVal := strings.TrimSpace(m.textarea.Value()) + if editedVal != "" { + m.currentSQL = editedVal + } m.editingSQL = false m.textarea.Reset() + m.textarea.Blur() // Blur textarea to avoid capturing next Enter key + m.state = StateSQLReady return m, nil + case tea.KeyEsc: m.editingSQL = false m.textarea.Reset() + m.textarea.Focus() return m, nil } var taCmd tea.Cmd @@ -290,6 +297,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case msg.Type == tea.KeyEnter: // Enter to Execute SQL m.state = StateExecuting + m.textarea.Focus() // Restore focus for next prompt input execLine := ExecutingTagStyle.Render("⚡ Executing") + " " + SQLCodeStyle.Render(m.currentSQL) m.messages = append(m.messages, execLine) m.viewport.SetContent(strings.Join(m.messages, "\n\n")) @@ -298,11 +306,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case msg.String() == "e" || msg.String() == "E": // 'e' key to Edit SQL m.editingSQL = true + m.textarea.Focus() m.textarea.SetValue(m.currentSQL) + m.textarea.CursorEnd() return m, nil case msg.Type == tea.KeyEsc: // Esc to Cancel SQL preview m.state = StateIdle + m.textarea.Focus() return m, nil } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 9046e8a..f734291 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -176,3 +176,48 @@ func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { t.Fatal("expected non-nil executeSQLCmd for auto-execution") } } + +func TestTUI_Model_EditSQLExecutionFlow(t *testing.T) { + resolved := config.Resolved{ + ProfileName: "dev", + Profile: config.Profile{DB: "mysql"}, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + m.state = StateSQLReady + m.currentSQL = "SELECT * FROM users LIMIT 10;" + + // 1. Press 'e' -> enters editingSQL mode + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + m = updated.(Model) + if !m.editingSQL { + t.Fatal("expected editingSQL to be true after pressing 'e'") + } + if m.textarea.Value() != "SELECT * FROM users LIMIT 10;" { + t.Fatalf("expected textarea value to be populated, got %q", m.textarea.Value()) + } + + // 2. Modify textarea and press Enter -> applies change and exits editingSQL + m.textarea.SetValue("SELECT id, name FROM users LIMIT 5;") + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.editingSQL { + t.Fatal("expected editingSQL to be false after pressing Enter") + } + if m.currentSQL != "SELECT id, name FROM users LIMIT 5;" { + t.Fatalf("expected currentSQL to be updated, got %q", m.currentSQL) + } + if m.state != StateSQLReady { + t.Fatalf("expected state StateSQLReady after editing, got %v", m.state) + } + + // 3. Press Enter in StateSQLReady -> transitions to StateExecuting with modified SQL + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.state != StateExecuting { + t.Fatalf("expected state StateExecuting after pressing Enter, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected non-nil executeSQLCmd for executing modified SQL") + } +} From 89cf6a1cb26e1fe6be8c190216e981acf39279ca Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:06:32 +0800 Subject: [PATCH 27/75] fix: handle CJK wide character display width with runewidth to prevent table border breaking --- go.mod | 2 +- go.sum | 4 +++ internal/tui/components.go | 30 ++++++++++++--------- internal/tui/components_test.go | 48 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 internal/tui/components_test.go diff --git a/go.mod b/go.mod index e07aa74..34a6c7b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-sql-driver/mysql v1.10.0 github.com/google/jsonschema-go v0.4.3 github.com/jackc/pgx/v5 v5.9.2 + github.com/mattn/go-runewidth v0.0.16 github.com/modelcontextprotocol/go-sdk v1.6.0 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 @@ -35,7 +36,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect diff --git a/go.sum b/go.sum index d037970..f5e2012 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= @@ -14,6 +16,8 @@ github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/tui/components.go b/internal/tui/components.go index 1a93903..91de8e1 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" + "github.com/mattn/go-runewidth" "github.com/zx06/xsql/internal/db" ) @@ -74,7 +75,7 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter rowOffset = 0 } - // Calculate maximum width needed for each column + // Calculate maximum display width needed for each column using runewidth for CJK support totalCols := len(result.Columns) if colOffset >= totalCols { colOffset = totalCols - 1 @@ -85,7 +86,7 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter colWidths := make([]int, totalCols) for i, col := range result.Columns { - w := len(col) + w := runewidth.StringWidth(col) if w > MaxColumnWidth { w = MaxColumnWidth } @@ -99,9 +100,9 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter if val != nil { cellStr := fmt.Sprintf("%v", val) cellStr = strings.ReplaceAll(cellStr, "\n", " ") - runesLen := len([]rune(cellStr)) - if runesLen > w { - w = runesLen + dispLen := runewidth.StringWidth(cellStr) + if dispLen > w { + w = dispLen } } } @@ -111,6 +112,7 @@ func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, ter if w < 6 { w = 6 } + // Add padding (2 chars) + border (1 char) colWidths[i] = w + 3 } @@ -233,13 +235,13 @@ func FormatVerticalResult(result *db.QueryResult) string { maxKeyLen := 0 for _, col := range result.Columns { - if len(col) > maxKeyLen { - maxKeyLen = len(col) + w := runewidth.StringWidth(col) + if w > maxKeyLen { + maxKeyLen = w } } maxRows := len(result.Rows) - // Allow up to 500 rows in full vertical expansion view if maxRows > 500 { maxRows = 500 } @@ -251,7 +253,9 @@ func FormatVerticalResult(result *db.QueryResult) string { for _, col := range result.Columns { val := result.Rows[i][col] - keyStr := FieldKeyStyle.Render(fmt.Sprintf("%-*s", maxKeyLen, col)) + keyWidth := runewidth.StringWidth(col) + padding := strings.Repeat(" ", max(0, maxKeyLen-keyWidth)) + keyStr := FieldKeyStyle.Render(col + padding) if val == nil { sb.WriteString(fmt.Sprintf(" %s : %s\n", keyStr, TableNilStyle.Render("NULL"))) @@ -292,12 +296,12 @@ func sanitizeCellWithStatus(val any, maxLen int) (string, bool) { s = strings.ReplaceAll(s, "\r", " ") s = strings.TrimSpace(s) - runes := []rune(s) - if len(runes) > maxLen { + dispLen := runewidth.StringWidth(s) + if dispLen > maxLen { if maxLen <= 3 { - return string(runes[:maxLen]), true + return runewidth.Truncate(s, maxLen, ""), true } - return string(runes[:maxLen-3]) + "...", true + return runewidth.Truncate(s, maxLen, "..."), true } return s, false } diff --git a/internal/tui/components_test.go b/internal/tui/components_test.go new file mode 100644 index 0000000..637f933 --- /dev/null +++ b/internal/tui/components_test.go @@ -0,0 +1,48 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/mattn/go-runewidth" + "github.com/zx06/xsql/internal/db" +) + +func TestSanitizeCellWithCJK(t *testing.T) { + // Test Chinese wide character display width calculation & truncation + cjkStr := "机器下架" + if runewidth.StringWidth(cjkStr) != 8 { + t.Fatalf("expected runewidth 8 for '机器下架', got %d", runewidth.StringWidth(cjkStr)) + } + + sanitized, wasTruncated := sanitizeCellWithStatus(cjkStr, 6) + if !wasTruncated { + t.Fatal("expected wasTruncated to be true") + } + if strings.Contains(sanitized, "\n") { + t.Fatal("sanitized string must never contain newlines") + } +} + +func TestFormatTableResult_CJKBorderProtection(t *testing.T) { + res := &db.QueryResult{ + Columns: []string{"id", "status"}, + Rows: []map[string]any{ + {"id": "1", "status": "故障"}, + {"id": "2", "status": "机器下架"}, + {"id": "3", "status": "正常运行"}, + }, + } + + formatted := FormatTableResult(res, 0, 0, 80, true) + lines := strings.Split(formatted, "\n") + + // Verify that rows are strictly single line per data row + for i, line := range lines { + if strings.Contains(line, "故障") { + if !strings.Contains(line, "1") { + t.Errorf("line %d split '故障' into new line: %q", i, line) + } + } + } +} From ae3a2637be97d888b8d4754ea3530b11546cc089 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:19:01 +0800 Subject: [PATCH 28/75] refactor: migrate OpenAI client to official SDK and Tool Call execute_sql --- docs/ai.md | 6 + docs/rfcs/0009-openai-sdk-tool-call.md | 35 ++++++ go.mod | 5 + go.sum | 12 ++ internal/ai/client.go | 153 ++++++++++++++----------- internal/ai/prompt.go | 15 +-- internal/ai/service.go | 42 +------ internal/ai/service_test.go | 130 +++++++++++++++++---- tests/e2e/ai_test.go | 57 +++++---- 9 files changed, 291 insertions(+), 164 deletions(-) create mode 100644 docs/rfcs/0009-openai-sdk-tool-call.md diff --git a/docs/ai.md b/docs/ai.md index af1f60e..ba0e47f 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -114,6 +114,11 @@ xsql-ai 为独立的 CLI 可执行程序,提供交互式 AI 终端模式。用 xsql-ai --profile dev ``` +### LLM 集成与 Tool Call 机制 +`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互。SQL 生成过程通过 Tool Calling 约定完成: +- 导出 Tool:`execute_sql(sql: string, explanation: string)` +- 模型通过调用 `execute_sql` 返回生成的 SQL 及对查询动作的解释说明。 + ### 快捷键操作 #### SQL 待确认状态 (SQL Preview Mode) @@ -130,3 +135,4 @@ xsql-ai --profile dev - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Esc` / `Ctrl+C`: 退出 AI 模式 + diff --git a/docs/rfcs/0009-openai-sdk-tool-call.md b/docs/rfcs/0009-openai-sdk-tool-call.md new file mode 100644 index 0000000..c699d91 --- /dev/null +++ b/docs/rfcs/0009-openai-sdk-tool-call.md @@ -0,0 +1,35 @@ +# RFC 0009: Migrate OpenAI Integration to Official SDK and Tool Call + +Status: Proposed + +## 摘要 +本 RFC 提出将 `xsql` 项目中的 AI 客户端重构为使用 OpenAI 官方 Go SDK (`github.com/openai/openai-go`),并将 SQL 生成与解析机制从手动字符串/JSON 解析改为标准 OpenAI Tool Call (`execute_sql`) 模式。 + +## 背景 / 动机 +- **当前问题**: + 1. `internal/ai/client.go` 自行实现了 HTTP 请求封装,维护成本高且不易支持高级特性。 + 2. `internal/ai/service.go` 通过 System Prompt 强求模型输出 JSON 字符串,并通过正则表达式/`json.Unmarshal` 提取 `sql` 和 `explanation`,解析脆弱且容易由于 markdown 格式干扰出错。 +- **目标**: + 1. 引入 `github.com/openai/openai-go` 官方 SDK。 + 2. 定义 `execute_sql(sql string, explanation string)` 函数工具,由模型通过 Tool Call 返回结构化参数。 + 3. 保留对无 Tool Call 场景(文本回复)的兼容容错。 + +## 方案(Proposed) + +### 技术设计 +1. **官方 SDK 集成**: + - 依赖:`github.com/openai/openai-go` + - 初始化:使用 `openai.NewClient(option.WithAPIKey(...), option.WithBaseURL(...), option.WithHTTPClient(...))`。 +2. **Tool Calling 定义**: + - 工具名称:`execute_sql` + - 工具描述:Execute or present generated SQL query based on database schema and user intent. + - 参数 Schema: + - `sql`: (string) 针对特定 DB 语法的 SQL 查询语句。 + - `explanation`: (string) 对查询意图或无法生成 SQL 的说明解释。 +3. **响应处理逻辑**: + - 若模型响应包含 `execute_sql` 的 Tool Call,则解析 JSON 参数提取 `sql` 与 `explanation`。 + - 若模型仅返回文本(无 Tool Call),则设置 `sql=""`,并将文本存入 `explanation`。 + +### 测试计划 +- 单元测试:`internal/ai/service_test.go` 升级 Mock API 响应为 Tool Call 消息体格式。 +- E2E 测试:`tests/e2e/ai_test.go` 升级 Mock API 响应。 diff --git a/go.mod b/go.mod index 34a6c7b..b7f0b63 100644 --- a/go.mod +++ b/go.mod @@ -39,11 +39,16 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/openai/openai-go v1.12.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index f5e2012..6ac528d 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= +github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -93,6 +95,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= diff --git a/internal/ai/client.go b/internal/ai/client.go index 10c8967..6a43787 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -1,14 +1,15 @@ package ai import ( - "bytes" "context" "encoding/json" - "fmt" - "io" "net/http" "strings" + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/openai/openai-go/shared" + "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/errors" ) @@ -18,97 +19,109 @@ type ChatMessage struct { Content string `json:"content"` } -type ChatCompletionRequest struct { - Model string `json:"model"` - Messages []ChatMessage `json:"messages"` - MaxTokens int `json:"max_tokens,omitempty"` -} - -type ChatCompletionChoice struct { - Message ChatMessage `json:"message"` -} - -type ChatCompletionResponse struct { - Choices []ChatCompletionChoice `json:"choices"` - Error *struct { - Message string `json:"message"` - Code string `json:"code"` - } `json:"error,omitempty"` -} - type Client struct { - cfg config.AIConfig - httpClient *http.Client + cfg config.AIConfig + openaiClient openai.Client } func NewClient(cfg config.AIConfig, httpClient *http.Client) *Client { - if httpClient == nil { - httpClient = http.DefaultClient + opts := []option.RequestOption{} + + if cfg.APIKey != "" { + opts = append(opts, option.WithAPIKey(cfg.APIKey)) + } + if cfg.BaseURL != "" { + opts = append(opts, option.WithBaseURL(strings.TrimRight(cfg.BaseURL, "/"))) } + if httpClient != nil { + opts = append(opts, option.WithHTTPClient(httpClient)) + } + + cli := openai.NewClient(opts...) return &Client{ - cfg: cfg, - httpClient: httpClient, + cfg: cfg, + openaiClient: cli, } } -func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (string, *errors.XError) { - baseURL := strings.TrimRight(c.cfg.BaseURL, "/") - url := fmt.Sprintf("%s/chat/completions", baseURL) - - reqBody := ChatCompletionRequest{ - Model: c.cfg.Model, - Messages: messages, - MaxTokens: c.cfg.MaxTokens, +func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*SQLResponse, *errors.XError) { + sdkMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)) + for _, m := range messages { + switch m.Role { + case "system": + sdkMessages = append(sdkMessages, openai.SystemMessage(m.Content)) + case "user": + sdkMessages = append(sdkMessages, openai.UserMessage(m.Content)) + case "assistant": + sdkMessages = append(sdkMessages, openai.AssistantMessage(m.Content)) + default: + sdkMessages = append(sdkMessages, openai.UserMessage(m.Content)) + } } - data, err := json.Marshal(reqBody) - if err != nil { - return "", errors.New(errors.CodeInternal, "failed to marshal AI request", map[string]any{"err": err.Error()}) + toolDef := openai.ChatCompletionToolParam{ + Function: shared.FunctionDefinitionParam{ + Name: "execute_sql", + Description: openai.String("Execute or present generated SQL query based on database schema and user intent"), + Parameters: shared.FunctionParameters{ + "type": "object", + "properties": map[string]interface{}{ + "sql": map[string]interface{}{ + "type": "string", + "description": "The generated SQL query statement.", + }, + "explanation": map[string]interface{}{ + "type": "string", + "description": "Explanation of what the query does or why SQL generation failed.", + }, + }, + "required": []string{"sql", "explanation"}, + }, + }, } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) - if err != nil { - return "", errors.New(errors.CodeInternal, "failed to create AI HTTP request", map[string]any{"err": err.Error()}) + model := c.cfg.Model + if model == "" { + model = "gpt-4o" } - req.Header.Set("Content-Type", "application/json") - if c.cfg.APIKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.cfg.APIKey)) + params := openai.ChatCompletionNewParams{ + Model: shared.ChatModel(model), + Messages: sdkMessages, + Tools: []openai.ChatCompletionToolParam{toolDef}, } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", errors.New(errors.CodeDBConnectFailed, "failed to connect to AI service", map[string]any{"err": err.Error(), "url": url}) + if c.cfg.MaxTokens > 0 { + params.MaxTokens = openai.Int(int64(c.cfg.MaxTokens)) } - defer resp.Body.Close() - respBytes, err := io.ReadAll(resp.Body) + resp, err := c.openaiClient.Chat.Completions.New(ctx, params) if err != nil { - return "", errors.New(errors.CodeInternal, "failed to read AI response", map[string]any{"err": err.Error()}) - } - - if resp.StatusCode != http.StatusOK { - return "", errors.New(errors.CodeDBExecFailed, "AI provider returned non-200 error", map[string]any{ - "status": resp.StatusCode, - "body": string(respBytes), + return nil, errors.New(errors.CodeDBExecFailed, "AI provider returned error", map[string]any{ + "err": err.Error(), }) } - var chatResp ChatCompletionResponse - if err := json.Unmarshal(respBytes, &chatResp); err != nil { - return "", errors.New(errors.CodeInternal, "failed to parse AI response JSON", map[string]any{"err": err.Error()}) - } - - if chatResp.Error != nil { - return "", errors.New(errors.CodeDBExecFailed, "AI provider returned error", map[string]any{ - "message": chatResp.Error.Message, - "code": chatResp.Error.Code, - }) + if len(resp.Choices) == 0 { + return nil, errors.New(errors.CodeInternal, "AI provider returned empty choices", nil) } - if len(chatResp.Choices) == 0 { - return "", errors.New(errors.CodeInternal, "AI provider returned empty choices", nil) + choice := resp.Choices[0] + msg := choice.Message + + for _, toolCall := range msg.ToolCalls { + if toolCall.Function.Name == "execute_sql" { + var sqlResp SQLResponse + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &sqlResp); err == nil { + sqlResp.SQL = strings.TrimSpace(sqlResp.SQL) + sqlResp.Explanation = strings.TrimSpace(sqlResp.Explanation) + return &sqlResp, nil + } + } } - return chatResp.Choices[0].Message.Content, nil + content := strings.TrimSpace(msg.Content) + return &SQLResponse{ + SQL: "", + Explanation: content, + }, nil } diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 0e10eff..ed3ee42 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -16,15 +16,12 @@ DATABASE SCHEMA: IMPORTANT RULES: 1. Generate valid %s SQL ONLY. 2. Default to READ-ONLY SELECT queries unless explicitly instructed otherwise. -3. Your response MUST be valid JSON containing two keys: "sql" and "explanation". - Format: - { - "sql": "SELECT * FROM users WHERE active = true;", - "explanation": "Retrieves all active users from the users table." - } -4. Do NOT wrap JSON in code block ticks if possible, or wrap in standard JSON. -5. If the request asks for general database metadata or listing tables/columns (e.g. 'show tables', 'what tables exist'), generate standard SQL (e.g. 'SHOW TABLES;' for MySQL, or 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\';' for PostgreSQL) even if the provided schema is empty. -6. If the request genuinely cannot be answered by the schema, set "sql": "" and explain in "explanation".` +3. When you have generated a SQL query or need to respond with a query decision, call the 'execute_sql' tool with arguments: + - "sql": the generated SQL query (e.g. "SELECT * FROM users WHERE active = true;") + - "explanation": a concise explanation of what the query does or why it cannot be generated. +4. If the request asks for general database metadata or listing tables/columns (e.g. 'show tables', 'what tables exist'), generate standard SQL (e.g. 'SHOW TABLES;' for MySQL, or 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\';' for PostgreSQL) even if the provided schema is empty. +5. Avoid full table scans without limits or filters whenever possible. Prefer specifying necessary columns, WHERE conditions, or adding LIMIT clauses where appropriate to protect performance. +6. If the request genuinely cannot be answered by the schema or database, call 'execute_sql' with "sql": "" and state the reason in "explanation".` func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo) string { schemaJSON := "{}" diff --git a/internal/ai/service.go b/internal/ai/service.go index dfa754c..7d62c70 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -2,9 +2,6 @@ package ai import ( "context" - "encoding/json" - "regexp" - "strings" "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/db" @@ -37,42 +34,5 @@ func (s *Service) GenerateSQL(ctx context.Context, userPrompt string, schemaInfo {Role: "user", Content: userPrompt}, } - content, xe := s.client.ChatCompletion(ctx, messages) - if xe != nil { - return nil, xe - } - - return parseSQLResponse(content) -} - -var codeBlockRegex = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```") - -func parseSQLResponse(content string) (*SQLResponse, *errors.XError) { - cleaned := strings.TrimSpace(content) - if matches := codeBlockRegex.FindStringSubmatch(cleaned); len(matches) > 1 { - cleaned = strings.TrimSpace(matches[1]) - } - - var resp SQLResponse - if err := json.Unmarshal([]byte(cleaned), &resp); err == nil { - resp.SQL = strings.TrimSpace(resp.SQL) - resp.Explanation = strings.TrimSpace(resp.Explanation) - return &resp, nil - } - - // Fallback if AI returned raw SQL or raw text - if strings.HasPrefix(strings.ToUpper(cleaned), "SELECT") || - strings.HasPrefix(strings.ToUpper(cleaned), "WITH") || - strings.HasPrefix(strings.ToUpper(cleaned), "SHOW") || - strings.HasPrefix(strings.ToUpper(cleaned), "EXPLAIN") { - return &SQLResponse{ - SQL: cleaned, - Explanation: "Generated SQL based on request.", - }, nil - } - - return &SQLResponse{ - SQL: "", - Explanation: cleaned, - }, nil + return s.client.ChatCompletion(ctx, messages) } diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index f8da948..4d9d457 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -2,7 +2,6 @@ package ai import ( "context" - "encoding/json" "net/http" "net/http/httptest" "testing" @@ -29,9 +28,25 @@ func TestBuildSystemPrompt(t *testing.T) { if prompt == "" { t.Fatal("expected non-empty prompt") } + + defaultPrompt := BuildSystemPrompt("", nil) + if defaultPrompt == "" { + t.Fatal("expected non-empty default prompt") + } } -func TestGenerateSQL_MockHTTP(t *testing.T) { +func TestNewService_NilClient(t *testing.T) { + cfg := config.AIConfig{ + Provider: "openai", + APIKey: "key", + } + svc := NewService(cfg, nil) + if svc == nil || svc.client == nil { + t.Fatal("expected non-nil Service and Client") + } +} + +func TestGenerateSQL_MockHTTP_ToolCall(t *testing.T) { mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { t.Errorf("unexpected path: %s", r.URL.Path) @@ -40,26 +55,43 @@ func TestGenerateSQL_MockHTTP(t *testing.T) { t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) } - resp := ChatCompletionResponse{ - Choices: []ChatCompletionChoice{ + respBody := `{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [ { - Message: ChatMessage{ - Role: "assistant", - Content: `{"sql": "SELECT id, name FROM users;", "explanation": "Queries all users."}`, + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "execute_sql", + "arguments": "{\"sql\":\"SELECT id, name FROM users;\",\"explanation\":\"Queries all users.\"}" + } + } + ] }, - }, - }, - } + "finish_reason": "tool_calls" + } + ] + }` w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write([]byte(respBody)) })) defer mockServer.Close() cfg := config.AIConfig{ - Provider: "openai", - BaseURL: mockServer.URL, - APIKey: "test-key", - Model: "gpt-4o", + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + Model: "gpt-4o", + MaxTokens: 100, } client := NewClient(cfg, mockServer.Client()) @@ -78,12 +110,70 @@ func TestGenerateSQL_MockHTTP(t *testing.T) { } } -func TestParseSQLResponse_Fallback(t *testing.T) { - resp, xe := parseSQLResponse("SELECT * FROM users") +func TestGenerateSQL_MockHTTP_TextMessageFallback(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respBody := `{ + "id": "chatcmpl-124", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I cannot answer this question based on the schema." + }, + "finish_reason": "stop" + } + ] + }` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respBody)) + })) + defer mockServer.Close() + + cfg := config.AIConfig{ + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + Model: "gpt-4o", + } + + client := NewClient(cfg, mockServer.Client()) + service := NewService(cfg, client) + + res, xe := service.GenerateSQL(context.Background(), "unknown table", nil, "mysql") if xe != nil { - t.Fatal(xe) + t.Fatalf("unexpected error: %v", xe) } - if resp.SQL != "SELECT * FROM users" { - t.Errorf("expected raw SQL fallback, got %q", resp.SQL) + + if res.SQL != "" { + t.Errorf("expected empty SQL, got %q", res.SQL) + } + if res.Explanation != "I cannot answer this question based on the schema." { + t.Errorf("expected explanation, got %q", res.Explanation) + } +} + +func TestGenerateSQL_MockHTTP_APIError(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error": {"message": "internal server error"}}`)) + })) + defer mockServer.Close() + + cfg := config.AIConfig{ + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + } + + client := NewClient(cfg, mockServer.Client()) + service := NewService(cfg, client) + + _, xe := service.GenerateSQL(context.Background(), "test", nil, "mysql") + if xe == nil { + t.Fatal("expected error for HTTP 500") } } diff --git a/tests/e2e/ai_test.go b/tests/e2e/ai_test.go index f886359..d704551 100644 --- a/tests/e2e/ai_test.go +++ b/tests/e2e/ai_test.go @@ -5,7 +5,7 @@ package e2e import ( "bytes" "context" - "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -23,7 +23,7 @@ import ( ) func TestE2E_AI_Service_With_MockOpenAI(t *testing.T) { - // 1. Setup Mock OpenAI Server + // 1. Setup Mock OpenAI Server with Tool Call response server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { http.Error(w, "not found", http.StatusNotFound) @@ -34,33 +34,42 @@ func TestE2E_AI_Service_With_MockOpenAI(t *testing.T) { return } - var req ai.ChatCompletionRequest - _ = json.NewDecoder(r.Body).Decode(&req) + bodyBytes, _ := io.ReadAll(r.Body) + bodyStr := string(bodyBytes) - // Assert System prompt contains schema context - hasSystem := false - for _, msg := range req.Messages { - if msg.Role == "system" && strings.Contains(msg.Content, "DATABASE SCHEMA") { - hasSystem = true - break - } - } - if !hasSystem { - t.Errorf("system prompt missing schema context: %+v", req.Messages) + // Assert request contains schema context + if !strings.Contains(bodyStr, "DATABASE SCHEMA") { + t.Errorf("request body missing schema context: %s", bodyStr) } - resp := ai.ChatCompletionResponse{ - Choices: []ai.ChatCompletionChoice{ + respBody := `{ + "id": "chatcmpl-e2e-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [ { - Message: ai.ChatMessage{ - Role: "assistant", - Content: `{"sql": "SELECT COUNT(*) FROM users;", "explanation": "Returns total user count."}`, + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_e2e_123", + "type": "function", + "function": { + "name": "execute_sql", + "arguments": "{\"sql\":\"SELECT COUNT(*) FROM users;\",\"explanation\":\"Returns total user count.\"}" + } + } + ] }, - }, - }, - } + "finish_reason": "tool_calls" + } + ] + }` w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + _, _ = w.Write([]byte(respBody)) })) defer server.Close() @@ -137,7 +146,7 @@ ai: outBuf := &bytes.Buffer{} p := tea.NewProgram(model, tea.WithInput(inBuf), tea.WithOutput(outBuf)) - + go func() { time.Sleep(100 * time.Millisecond) p.Quit() From fa5b25e30affb061a57332009c73b6f0ae06b2c3 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:25:19 +0800 Subject: [PATCH 29/75] fix: resolve linter version error and E2E mock server URL hang --- .golangci.yml | 1 - tests/e2e/ai_test.go | 45 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 6fe9a3c..0e57260 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,3 @@ -version: "2" run: timeout: 5m diff --git a/tests/e2e/ai_test.go b/tests/e2e/ai_test.go index d704551..13f9a42 100644 --- a/tests/e2e/ai_test.go +++ b/tests/e2e/ai_test.go @@ -5,6 +5,7 @@ package e2e import ( "bytes" "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -115,10 +116,43 @@ func TestE2E_AI_Service_With_MockOpenAI(t *testing.T) { } func TestE2E_AI_TUI_Terminal_Pipe(t *testing.T) { + // Setup Mock Server for AI BaseURL + aiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respBody := `{ + "id": "chatcmpl-tui", + "object": "chat.completion", + "created": 1677652288, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_tui", + "type": "function", + "function": { + "name": "execute_sql", + "arguments": "{\"sql\":\"SELECT COUNT(*) FROM users;\",\"explanation\":\"Returns total user count.\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + }` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respBody)) + })) + defer aiServer.Close() + // Setup temporary xsql config file tmpDir := t.TempDir() cfgPath := filepath.Join(tmpDir, "xsql.yaml") - cfgContent := `profiles: + cfgContent := fmt.Sprintf(`profiles: dev: db: mysql host: 127.0.0.1 @@ -126,10 +160,10 @@ func TestE2E_AI_TUI_Terminal_Pipe(t *testing.T) { user: root database: test ai: - base_url: "https://mock.api.com" + base_url: "%s" model: "test-model" api_key: "test-key" -` +`, aiServer.URL) if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { t.Fatal(err) } @@ -139,7 +173,8 @@ ai: t.Fatalf("failed to resolve config: %v", xe) } - aiService := ai.NewService(resolved.AI, nil) + aiClient := ai.NewClient(resolved.AI, aiServer.Client()) + aiService := ai.NewService(resolved.AI, aiClient) model := tui.NewModel(config.Options{}, resolved, aiService, "Show total users", false) inBuf := bytes.NewBufferString("\n") // Press Enter @@ -148,7 +183,7 @@ ai: p := tea.NewProgram(model, tea.WithInput(inBuf), tea.WithOutput(outBuf)) go func() { - time.Sleep(100 * time.Millisecond) + time.Sleep(300 * time.Millisecond) p.Quit() }() From 33efdb6bc4b2716359de1260273afb25554050ec Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:27:12 +0800 Subject: [PATCH 30/75] fix: adapt TUI color styles for light theme and fix linter issues --- .golangci.yml | 2 ++ internal/tui/components.go | 22 +++++++++++----------- internal/tui/styles.go | 28 ++++++++++++++-------------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0e57260..f02f0a3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,6 @@ +version: "2" + run: timeout: 5m tests: false diff --git a/internal/tui/components.go b/internal/tui/components.go index 91de8e1..97960d3 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -14,30 +14,30 @@ import ( var ( TableHeaderStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#7D56F4")). + Foreground(lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#A78BFA"}). Padding(0, 1) TableCellStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C0CAF5")). + Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}). Padding(0, 1) TableNilStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#565F89")). + Foreground(lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"}). Italic(true). Padding(0, 1) TableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3B4261")) + Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#3B4261"}) ActiveTableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7AA2F7")) + Foreground(lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#7AA2F7"}) FieldKeyStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#7AA2F7")) + Foreground(lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#7AA2F7"}) FieldValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C0CAF5")) + Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}) RecordDividerStyle = lipgloss.NewStyle(). Bold(true). @@ -45,7 +45,7 @@ var ( ScrollBadgeStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#FF75B5")) + Foreground(lipgloss.AdaptiveColor{Light: "#9D174D", Dark: "#FF75B5"}) ) const ( @@ -258,15 +258,15 @@ func FormatVerticalResult(result *db.QueryResult) string { keyStr := FieldKeyStyle.Render(col + padding) if val == nil { - sb.WriteString(fmt.Sprintf(" %s : %s\n", keyStr, TableNilStyle.Render("NULL"))) + fmt.Fprintf(&sb, " %s : %s\n", keyStr, TableNilStyle.Render("NULL")) } else { valStr := fmt.Sprintf("%v", val) // Full display with indentation for multiline text if strings.Contains(valStr, "\n") { indented := strings.ReplaceAll(valStr, "\n", "\n ") - sb.WriteString(fmt.Sprintf(" %s :\n %s\n", keyStr, FieldValueStyle.Render(indented))) + fmt.Fprintf(&sb, " %s :\n %s\n", keyStr, FieldValueStyle.Render(indented)) } else { - sb.WriteString(fmt.Sprintf(" %s : %s\n", keyStr, FieldValueStyle.Render(valStr))) + fmt.Fprintf(&sb, " %s : %s\n", keyStr, FieldValueStyle.Render(valStr)) } } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 4bb7384..00f6afb 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -6,12 +6,12 @@ var ( // Palette Colors PrimaryColor = lipgloss.Color("#7D56F4") SecondaryColor = lipgloss.Color("#04B575") - AccentColor = lipgloss.Color("#FF75B5") - WarningColor = lipgloss.Color("#FF9E3B") - ErrorColor = lipgloss.Color("#FF5370") - MutedColor = lipgloss.Color("#565F89") - CyanColor = lipgloss.Color("#7AA2F7") - BgDark = lipgloss.Color("#1A1B26") + AccentColor = lipgloss.Color("#E03177") + WarningColor = lipgloss.Color("#D97706") + ErrorColor = lipgloss.Color("#E11D48") + MutedColor = lipgloss.AdaptiveColor{Light: "#475569", Dark: "#94A3B8"} + CyanColor = lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#7AA2F7"} + BgBox = lipgloss.AdaptiveColor{Light: "#F1F5F9", Dark: "#1F2335"} // Header Styles HeaderStyle = lipgloss.NewStyle(). @@ -40,26 +40,26 @@ var ( BadgeManualApprove = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#C0CAF5")). - Background(lipgloss.Color("#3B4261")). + Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#C0CAF5"}). + Background(lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#3B4261"}). Padding(0, 1) // SQL Preview Box SQLBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(PrimaryColor). - Background(lipgloss.Color("#1F2335")). + Background(BgBox). Padding(0, 1). MarginTop(1). MarginBottom(1) SQLTitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(AccentColor) + Foreground(lipgloss.AdaptiveColor{Light: "#9D174D", Dark: "#FF75B5"}) SQLCodeStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#7AA2F7")) + Foreground(lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#7AA2F7"}) // Help / Footer HelpStyle = lipgloss.NewStyle(). @@ -69,7 +69,7 @@ var ( // Chat Messages UserTagStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#1A1B26")). + Foreground(lipgloss.Color("#FFFFFF")). Background(SecondaryColor). Padding(0, 1) @@ -81,7 +81,7 @@ var ( ExecutingTagStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#1A1B26")). + Foreground(lipgloss.Color("#FFFFFF")). Background(WarningColor). Padding(0, 1) @@ -90,7 +90,7 @@ var ( Foreground(SecondaryColor) AIResponseStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C0CAF5")). + Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}). PaddingLeft(1) ErrorMsgStyle = lipgloss.NewStyle(). From 16f179cdfad9895ee036a8f0504c111a882b87a4 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:28:21 +0800 Subject: [PATCH 31/75] fix: format Go files according to goimports spec --- go.mod | 2 +- internal/config/types.go | 12 ++++++------ internal/tui/components.go | 2 +- internal/tui/model.go | 24 ++++++++++++------------ 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index b7f0b63..08443a5 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/mattn/go-runewidth v0.0.16 github.com/modelcontextprotocol/go-sdk v1.6.0 + github.com/openai/openai-go v1.12.0 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.52.0 @@ -39,7 +40,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/openai/openai-go v1.12.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect diff --git a/internal/config/types.go b/internal/config/types.go index 15dbe7b..3651d67 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -15,10 +15,10 @@ type File struct { // AIConfig defines the AI LLM service configuration. type AIConfig struct { - Provider string `yaml:"provider" json:"provider"` // default "openai" - BaseURL string `yaml:"base_url" json:"base_url"` // default "https://api.openai.com/v1" - APIKey string `yaml:"api_key" json:"api_key"` // supports keyring:xxx reference - Model string `yaml:"model" json:"model"` // default "gpt-4o" + Provider string `yaml:"provider" json:"provider"` // default "openai" + BaseURL string `yaml:"base_url" json:"base_url"` // default "https://api.openai.com/v1" + APIKey string `yaml:"api_key" json:"api_key"` // supports keyring:xxx reference + Model string `yaml:"model" json:"model"` // default "gpt-4o" MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // default 2048 } @@ -116,8 +116,8 @@ type Options struct { CLIAIAPIKeySet bool // ENV (injected by caller for testability) - EnvProfile string - EnvFormat string + EnvProfile string + EnvFormat string EnvAIModel string EnvAIBaseURL string EnvAIAPIKey string diff --git a/internal/tui/components.go b/internal/tui/components.go index 97960d3..978909e 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -27,7 +27,7 @@ var ( Padding(0, 1) TableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#3B4261"}) + Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#3B4261"}) ActiveTableBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#7AA2F7"}) diff --git a/internal/tui/model.go b/internal/tui/model.go index 82716b5..bcf0e61 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -61,13 +61,13 @@ type Model struct { initialPrompt string autoExecute bool - state State - schemaInfo *db.SchemaInfo - currentSQL string - explanation string - messages []string - tableStates []TableState - activeTable int + state State + schemaInfo *db.SchemaInfo + currentSQL string + explanation string + messages []string + tableStates []TableState + activeTable int textarea textarea.Model viewport viewport.Model @@ -206,10 +206,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.currentSQL = msg.response.SQL m.explanation = msg.response.Explanation - + aiMsg := AITagStyle.Render("🤖 AI") + " " + AIResponseStyle.Render(msg.response.Explanation) m.messages = append(m.messages, aiMsg) - + if msg.response.SQL != "" { if m.autoExecute { m.state = StateExecuting @@ -233,7 +233,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if msg.result != nil { statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) m.messages = append(m.messages, statusLine) - + // Remove focus from previous active table if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { m.renderTableState(m.activeTable, false) @@ -449,7 +449,7 @@ func (m Model) View() string { sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") } preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Enter: Execute | e: Edit SQL | Esc: Cancel):"), sqlContent) - sb.WriteString(SQLBoxStyle.Width(m.width - 4).Render(preview) + "\n") + sb.WriteString(SQLBoxStyle.Width(m.width-4).Render(preview) + "\n") } // 4. Input Area & Footer Hints @@ -462,7 +462,7 @@ func (m Model) View() string { if m.autoExecute { execModeHint = "AUTO" } - + help := fmt.Sprintf("Enter: Send Prompt | Tab: Focus Table | ←/→: Cols | PgUp/PgDn: Rows | Ctrl+E: Expand/Collapse | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) if m.state == StateSQLReady { help = fmt.Sprintf("Enter: Execute SQL | e: Edit SQL | Esc: Cancel | Ctrl+E: Expand/Collapse | Shift+Tab: Mode (%s)", execModeHint) From e17483043a22789557510c7765b7009f35f2cb15 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:38:29 +0800 Subject: [PATCH 32/75] fix: remove SQL box background to prevent color block mismatch --- internal/tui/styles.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 00f6afb..dc9dc0e 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -48,7 +48,6 @@ var ( SQLBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(PrimaryColor). - Background(BgBox). Padding(0, 1). MarginTop(1). MarginBottom(1) From 8094f6a323ea1afb4f33479303381f2c87f378fe Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:01:00 +0800 Subject: [PATCH 33/75] refactor: overhaul TUI visual design with Catppuccin palette, Pill badges, metrics, and keybindings --- docs/rfcs/0010-tui-visual-redesign.md | 30 ++++++++ internal/tui/model.go | 101 ++++++++++++++++++-------- internal/tui/styles.go | 72 +++++++++++------- 3 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 docs/rfcs/0010-tui-visual-redesign.md diff --git a/docs/rfcs/0010-tui-visual-redesign.md b/docs/rfcs/0010-tui-visual-redesign.md new file mode 100644 index 0000000..220a82f --- /dev/null +++ b/docs/rfcs/0010-tui-visual-redesign.md @@ -0,0 +1,30 @@ +# RFC 0010: TUI Visual and Interactive Experience Redesign + +Status: Proposed + +## 摘要 +本 RFC 提出对 `xsql-ai` 终端 TUI 进行视觉与交互体验的重构。参考 Catppuccin / Tokyo Night 现代调色盘以及 Charm (Bubbletea / Lipgloss) 社区开源工具(如 `mods`、`glow`、`gh-dash`)的设计精髓,全面优化 Pill 胶囊标签、✦ Prompt 输入框、全主题自适应调色盘与按键指引。 + +## 背景 / 动机 +- **当前问题**: + - Header 与提示文本在白色/浅色终端主题下对比度不理想。 + - 缺少 SQL 执行的耗时与元信息状态展示。 + - 底部快捷键文本较为平淡,缺乏现代终端工具的按键 Badge 指示器。 + +## 方案(Proposed) + +### 视觉与交互规范 +1. **Pill 胶囊 Header 导航**: + - 使用 rounded 内边距与不同主题色背景塑造 `xsql AI`、Profile、DB、只读/读写模式与自动/手动执行模式。 +2. **运行指标 (Metrics Bar)**: + - 执行 SQL 后输出包含执行耗时(如 `⏱️ 14ms`)、行数(如 `📊 10 rows`)与 LLM 模型(如 `🤖 gpt-4o`)。 +3. **✦ Prompt 输入框与 Focus 指示器**: + - 输入框增加 `✦ Ask AI:` 品牌提示前缀,并根据获得焦点状态显示鲜明边框。 +4. **按键 Badge 底部栏**: + - 底部提示升级为形如 `[Enter] 发送` `[Tab] 聚焦表格` `[e] 编辑SQL` `[Shift+Tab] 模式切换` `[Esc] 退出` 的精致键盘 Badge。 +5. **Catppuccin / Tokyo Night 调色盘**: + - 全量采用 `lipgloss.AdaptiveColor` 确保在任何深色/浅色背景终端中均具有符合 WCAG 的高对比度。 + +### 测试计划 +- 单元测试:`internal/tui/components_test.go` 与 `model_test.go`。 +- E2E 测试:`tests/e2e/ai_test.go`。 diff --git a/internal/tui/model.go b/internal/tui/model.go index bcf0e61..8475838 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -40,8 +41,9 @@ type sqlGeneratedMsg struct { } type queryExecutedMsg struct { - result *db.QueryResult - err *errors.XError + result *db.QueryResult + err *errors.XError + duration time.Duration } type TableState struct { @@ -81,6 +83,7 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() ta.Placeholder = "Ask AI to write a SQL query (e.g. 'Show top 10 users')...." + ta.Prompt = PromptPrefixStyle.Render("✦ ") ta.Focus() ta.CharLimit = 1000 ta.SetWidth(80) @@ -141,6 +144,7 @@ func (m Model) generateSQLCmd(prompt string) tea.Cmd { func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { return func() tea.Msg { + start := time.Now() ctx := context.Background() res, xe := app.Query(ctx, app.QueryRequest{ Profile: m.profile, @@ -149,7 +153,8 @@ func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { SkipHostKeyCheck: m.profile.SSHConfig != nil && m.profile.SSHConfig.SkipHostKey, UnsafeAllowWrite: m.unsafeAllowWrite, }) - return queryExecutedMsg{result: res, err: xe} + elapsed := time.Since(start) + return queryExecutedMsg{result: res, err: xe, duration: elapsed} } } @@ -231,7 +236,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) } else if msg.result != nil { - statusLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Execution Success (%d rows returned)", len(msg.result.Rows))) + modelName := m.opts.CLIAIModel + if modelName == "" { + modelName = "gpt-4o" + } + durStr := msg.duration.Round(time.Millisecond).String() + if msg.duration < time.Millisecond { + durStr = fmt.Sprintf("%.2fms", float64(msg.duration.Microseconds())/1000.0) + } + metricsStr := fmt.Sprintf("⏱️ %s | 📊 %d rows | 🤖 %s", durStr, len(msg.result.Rows), modelName) + statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) m.messages = append(m.messages, statusLine) // Remove focus from previous active table @@ -262,12 +276,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case tea.KeyMsg: - // 1. Ctrl+C always quits if msg.Type == tea.KeyCtrlC { return m, tea.Quit } - // 2. When editing SQL in text area if m.editingSQL { switch msg.Type { case tea.KeyEnter: @@ -277,7 +289,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.editingSQL = false m.textarea.Reset() - m.textarea.Blur() // Blur textarea to avoid capturing next Enter key + m.textarea.Blur() m.state = StateSQLReady return m, nil @@ -292,48 +304,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, taCmd } - // 3. When in SQLReady state (SQL preview pending approval) if m.state == StateSQLReady { switch { - case msg.Type == tea.KeyEnter: // Enter to Execute SQL + case msg.Type == tea.KeyEnter: m.state = StateExecuting - m.textarea.Focus() // Restore focus for next prompt input + m.textarea.Focus() execLine := ExecutingTagStyle.Render("⚡ Executing") + " " + SQLCodeStyle.Render(m.currentSQL) m.messages = append(m.messages, execLine) m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) - case msg.String() == "e" || msg.String() == "E": // 'e' key to Edit SQL + case msg.String() == "e" || msg.String() == "E": m.editingSQL = true m.textarea.Focus() m.textarea.SetValue(m.currentSQL) m.textarea.CursorEnd() return m, nil - case msg.Type == tea.KeyEsc: // Esc to Cancel SQL preview + case msg.Type == tea.KeyEsc: m.state = StateIdle m.textarea.Focus() return m, nil } } - // 4. General TUI keyhandlers switch msg.Type { case tea.KeyEsc: return m, tea.Quit - case tea.KeyCtrlE: // Ctrl+E to Expand/Collapse full vertical view + case tea.KeyCtrlE: if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { ts := &m.tableStates[m.activeTable] ts.VerticalView = !ts.VerticalView m.renderTableState(m.activeTable, true) } - case tea.KeyShiftTab: // Toggle Auto-Execute vs Manual-Approve mode + case tea.KeyShiftTab: m.autoExecute = !m.autoExecute - case tea.KeyTab: // Toggle focus between tables in history + case tea.KeyTab: if len(m.tableStates) > 1 { oldIdx := m.activeTable m.activeTable = (m.activeTable + 1) % len(m.tableStates) @@ -417,25 +427,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func renderKeybindingBadges(items [][2]string) string { + var parts []string + for _, item := range items { + keyBadge := KeyBadgeStyle.Render(item[0]) + label := KeyLabelStyle.Render(item[1]) + parts = append(parts, fmt.Sprintf("%s %s", keyBadge, label)) + } + return strings.Join(parts, " ") +} + func (m Model) View() string { var sb strings.Builder - // 1. Header Bar - modeBadge := BadgeReadOnly.Render("READ-ONLY") + // 1. Header Pill Badges + titlePill := HeaderTitleBadge.Render("xsql AI") + profilePill := HeaderProfileBadge.Render(fmt.Sprintf("%s (%s)", m.profileName, m.profile.DB)) + + modePill := BadgeReadOnly.Render("READ-ONLY") if m.unsafeAllowWrite { - modeBadge = BadgeReadWrite.Render("READ-WRITE") + modePill = BadgeReadWrite.Render("READ-WRITE") } - execModeBadge := BadgeManualApprove.Render("MANUAL-APPROVE") + + execPill := BadgeManualApprove.Render("MANUAL") if m.autoExecute { - execModeBadge = BadgeAutoExec.Render("AUTO-EXECUTE") + execPill = BadgeAutoExec.Render("AUTO-EXEC") } - header := fmt.Sprintf(" xsql AI | Profile: %s (%s) | %s | Mode: %s ", m.profileName, m.profile.DB, modeBadge, execModeBadge) - sb.WriteString(HeaderStyle.Width(m.width).Render(header) + "\n\n") - // 2. Main Viewport (Messages & Results) + header := fmt.Sprintf(" %s %s %s %s", titlePill, profilePill, modePill, execPill) + sb.WriteString(header + "\n\n") + + // 2. Main Viewport sb.WriteString(m.viewport.View() + "\n\n") - // 3. State Status & SQL Preview Card + // 3. State Status & SQL Preview Box switch m.state { case StateLoadingSchema: sb.WriteString(m.spinner.View() + " Loading database schema...\n") @@ -448,11 +473,11 @@ func (m Model) View() string { if m.currentSQL == "" { sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") } - preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Enter: Execute | e: Edit SQL | Esc: Cancel):"), sqlContent) + preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Enter: Execute | e: Edit | Esc: Cancel):"), sqlContent) sb.WriteString(SQLBoxStyle.Width(m.width-4).Render(preview) + "\n") } - // 4. Input Area & Footer Hints + // 4. Input Area & Footer Keybindings if m.editingSQL { sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Enter: Apply | Esc: Cancel):") + "\n") } @@ -463,11 +488,27 @@ func (m Model) View() string { execModeHint = "AUTO" } - help := fmt.Sprintf("Enter: Send Prompt | Tab: Focus Table | ←/→: Cols | PgUp/PgDn: Rows | Ctrl+E: Expand/Collapse | Shift+Tab: Mode (%s) | Esc: Quit", execModeHint) + var keybindings string if m.state == StateSQLReady { - help = fmt.Sprintf("Enter: Execute SQL | e: Edit SQL | Esc: Cancel | Ctrl+E: Expand/Collapse | Shift+Tab: Mode (%s)", execModeHint) + keybindings = renderKeybindingBadges([][2]string{ + {"Enter", "Execute"}, + {"e", "Edit SQL"}, + {"Esc", "Cancel"}, + {"Shift+Tab", "Mode (" + execModeHint + ")"}, + }) + } else { + keybindings = renderKeybindingBadges([][2]string{ + {"Enter", "Send"}, + {"Tab", "Focus Table"}, + {"←/→", "Cols"}, + {"PgUp/PgDn", "Rows"}, + {"Ctrl+E", "Expand"}, + {"Shift+Tab", "Mode (" + execModeHint + ")"}, + {"Esc", "Quit"}, + }) } - sb.WriteString(HelpStyle.Render(help)) + + sb.WriteString(keybindings + "\n") return sb.String() } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index dc9dc0e..f6c3833 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -3,23 +3,40 @@ package tui import "github.com/charmbracelet/lipgloss" var ( - // Palette Colors - PrimaryColor = lipgloss.Color("#7D56F4") - SecondaryColor = lipgloss.Color("#04B575") - AccentColor = lipgloss.Color("#E03177") - WarningColor = lipgloss.Color("#D97706") - ErrorColor = lipgloss.Color("#E11D48") - MutedColor = lipgloss.AdaptiveColor{Light: "#475569", Dark: "#94A3B8"} - CyanColor = lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#7AA2F7"} - BgBox = lipgloss.AdaptiveColor{Light: "#F1F5F9", Dark: "#1F2335"} - - // Header Styles - HeaderStyle = lipgloss.NewStyle(). + // Palette Colors (Adaptive Catppuccin / Tokyo Night Theme) + PrimaryColor = lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"} + SecondaryColor = lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"} + AccentColor = lipgloss.AdaptiveColor{Light: "#DB2777", Dark: "#F472B6"} + WarningColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"} + ErrorColor = lipgloss.AdaptiveColor{Light: "#E11D48", Dark: "#F87171"} + InfoColor = lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#38BDF8"} + MutedColor = lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"} + TextNormal = lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"} + BgSubtle = lipgloss.AdaptiveColor{Light: "#F1F5F9", Dark: "#1E293B"} + + // Keybinding Badge Styles + KeyBadgeStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(PrimaryColor). + Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#F8FAFC"}). + Background(lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#334155"}). Padding(0, 1) + KeyLabelStyle = lipgloss.NewStyle(). + Foreground(MutedColor) + + // Header Pill Badges + HeaderTitleBadge = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(PrimaryColor). + Padding(0, 1) + + HeaderProfileBadge = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(InfoColor). + Padding(0, 1) + BadgeReadOnly = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). @@ -35,13 +52,13 @@ var ( BadgeAutoExec = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). - Background(PrimaryColor). + Background(AccentColor). Padding(0, 1) BadgeManualApprove = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#C0CAF5"}). - Background(lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#3B4261"}). + Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#E2E8F0"}). + Background(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#475569"}). Padding(0, 1) // SQL Preview Box @@ -54,18 +71,13 @@ var ( SQLTitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#9D174D", Dark: "#FF75B5"}) + Foreground(AccentColor) SQLCodeStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#7AA2F7"}) - - // Help / Footer - HelpStyle = lipgloss.NewStyle(). - Foreground(MutedColor). - MarginTop(1) + Foreground(InfoColor) - // Chat Messages + // User & AI Chat Tags UserTagStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). @@ -88,12 +100,20 @@ var ( Bold(true). Foreground(SecondaryColor) + MetricsStyle = lipgloss.NewStyle(). + Foreground(MutedColor). + Italic(true) + AIResponseStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}). + Foreground(TextNormal). PaddingLeft(1) ErrorMsgStyle = lipgloss.NewStyle(). Bold(true). Foreground(ErrorColor). PaddingLeft(1) + + PromptPrefixStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) ) From 2af7c8197c4bb06effad722e0494fd50b2a3ff17 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:03:26 +0800 Subject: [PATCH 34/75] refactor: polish TUI layout, table color harmony, header bar and disable input line numbers --- internal/tui/components.go | 16 ++++++++-------- internal/tui/model.go | 10 ++++++---- internal/tui/styles.go | 32 ++++++++++++++++++-------------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/internal/tui/components.go b/internal/tui/components.go index 978909e..d12f1f0 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -14,30 +14,30 @@ import ( var ( TableHeaderStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#A78BFA"}). + Foreground(lipgloss.AdaptiveColor{Light: "#4F46E5", Dark: "#818CF8"}). Padding(0, 1) TableCellStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}). + Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#F1F5F9"}). Padding(0, 1) TableNilStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"}). + Foreground(lipgloss.AdaptiveColor{Light: "#94A3B8", Dark: "#64748B"}). Italic(true). Padding(0, 1) TableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#3B4261"}) + Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}) ActiveTableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#7AA2F7"}) + Foreground(lipgloss.AdaptiveColor{Light: "#6366F1", Dark: "#818CF8"}) FieldKeyStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#7AA2F7"}) + Foreground(lipgloss.AdaptiveColor{Light: "#4F46E5", Dark: "#818CF8"}) FieldValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}) + Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#F1F5F9"}) RecordDividerStyle = lipgloss.NewStyle(). Bold(true). @@ -45,7 +45,7 @@ var ( ScrollBadgeStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#9D174D", Dark: "#FF75B5"}) + Foreground(lipgloss.AdaptiveColor{Light: "#BE185D", Dark: "#F472B6"}) ) const ( diff --git a/internal/tui/model.go b/internal/tui/model.go index 8475838..3686f59 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -82,7 +82,8 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() - ta.Placeholder = "Ask AI to write a SQL query (e.g. 'Show top 10 users')...." + ta.Placeholder = "Ask AI to generate a SQL query (e.g. 'Show top 10 servers')...." + ta.ShowLineNumbers = false ta.Prompt = PromptPrefixStyle.Render("✦ ") ta.Focus() ta.CharLimit = 1000 @@ -440,7 +441,7 @@ func renderKeybindingBadges(items [][2]string) string { func (m Model) View() string { var sb strings.Builder - // 1. Header Pill Badges + // 1. Full-Width Header Bar titlePill := HeaderTitleBadge.Render("xsql AI") profilePill := HeaderProfileBadge.Render(fmt.Sprintf("%s (%s)", m.profileName, m.profile.DB)) @@ -454,7 +455,8 @@ func (m Model) View() string { execPill = BadgeAutoExec.Render("AUTO-EXEC") } - header := fmt.Sprintf(" %s %s %s %s", titlePill, profilePill, modePill, execPill) + headerContent := fmt.Sprintf("%s %s %s %s", titlePill, profilePill, modePill, execPill) + header := HeaderBarStyle.Width(m.width).Render(headerContent) sb.WriteString(header + "\n\n") // 2. Main Viewport @@ -481,7 +483,7 @@ func (m Model) View() string { if m.editingSQL { sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Enter: Apply | Esc: Cancel):") + "\n") } - sb.WriteString(m.textarea.View() + "\n") + sb.WriteString(m.textarea.View() + "\n\n") execModeHint := "MANUAL" if m.autoExecute { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f6c3833..9b71cdc 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -4,27 +4,21 @@ import "github.com/charmbracelet/lipgloss" var ( // Palette Colors (Adaptive Catppuccin / Tokyo Night Theme) - PrimaryColor = lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"} + PrimaryColor = lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#A78BFA"} SecondaryColor = lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"} - AccentColor = lipgloss.AdaptiveColor{Light: "#DB2777", Dark: "#F472B6"} + AccentColor = lipgloss.AdaptiveColor{Light: "#BE185D", Dark: "#F472B6"} WarningColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"} ErrorColor = lipgloss.AdaptiveColor{Light: "#E11D48", Dark: "#F87171"} InfoColor = lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#38BDF8"} MutedColor = lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"} TextNormal = lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"} - BgSubtle = lipgloss.AdaptiveColor{Light: "#F1F5F9", Dark: "#1E293B"} + HeaderBg = lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#1E293B"} - // Keybinding Badge Styles - KeyBadgeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#F8FAFC"}). - Background(lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#334155"}). + // Header Container & Badges + HeaderBarStyle = lipgloss.NewStyle(). + Background(HeaderBg). Padding(0, 1) - KeyLabelStyle = lipgloss.NewStyle(). - Foreground(MutedColor) - - // Header Pill Badges HeaderTitleBadge = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). @@ -57,10 +51,20 @@ var ( BadgeManualApprove = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#E2E8F0"}). + Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}). Background(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#475569"}). Padding(0, 1) + // Keybinding Badges + KeyBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"}). + Background(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}). + Padding(0, 1) + + KeyLabelStyle = lipgloss.NewStyle(). + Foreground(MutedColor) + // SQL Preview Box SQLBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). @@ -77,7 +81,7 @@ var ( Bold(true). Foreground(InfoColor) - // User & AI Chat Tags + // User & AI Message Tags UserTagStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). From 38bc36e3acc461818cb00eab069af9fc36ee976e Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:05:45 +0800 Subject: [PATCH 35/75] fix: remove repeating prompt symbol from textarea lines --- internal/tui/model.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 3686f59..91a8dac 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -84,7 +84,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi ta := textarea.New() ta.Placeholder = "Ask AI to generate a SQL query (e.g. 'Show top 10 servers')...." ta.ShowLineNumbers = false - ta.Prompt = PromptPrefixStyle.Render("✦ ") + ta.Prompt = "" ta.Focus() ta.CharLimit = 1000 ta.SetWidth(80) @@ -480,9 +480,11 @@ func (m Model) View() string { } // 4. Input Area & Footer Keybindings + promptTitle := lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor).Render("✦ Ask AI:") if m.editingSQL { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Enter: Apply | Esc: Cancel):") + "\n") + promptTitle = lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Enter: Apply | Esc: Cancel):") } + sb.WriteString(promptTitle + "\n") sb.WriteString(m.textarea.View() + "\n\n") execModeHint := "MANUAL" From bb6aee2a0db20f0835fa9983c939544e9ab60acc Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:14:57 +0800 Subject: [PATCH 36/75] feat: integrate goja JS engine and Session DataStore for AI data analysis, recall and export --- docs/ai.md | 11 ++- docs/rfcs/0011-goja-js-data-analysis.md | 27 ++++++ go.mod | 4 + go.sum | 8 ++ internal/ai/client.go | 57 ++++++++++-- internal/ai/prompt.go | 33 ++++--- internal/ai/service.go | 26 +++++- internal/ai/service_test.go | 59 +++++++++++- internal/export/exporter.go | 117 ++++++++++++++++++++++++ internal/export/exporter_test.go | 59 ++++++++++++ internal/js/engine.go | 104 +++++++++++++++++++++ internal/js/engine_test.go | 70 ++++++++++++++ internal/session/store.go | 108 ++++++++++++++++++++++ internal/session/store_test.go | 49 ++++++++++ internal/tui/model.go | 46 ++++++++-- internal/tui/model_test.go | 6 +- 16 files changed, 741 insertions(+), 43 deletions(-) create mode 100644 docs/rfcs/0011-goja-js-data-analysis.md create mode 100644 internal/export/exporter.go create mode 100644 internal/export/exporter_test.go create mode 100644 internal/js/engine.go create mode 100644 internal/js/engine_test.go create mode 100644 internal/session/store.go create mode 100644 internal/session/store_test.go diff --git a/docs/ai.md b/docs/ai.md index ba0e47f..1b537bd 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -115,9 +115,14 @@ xsql-ai --profile dev ``` ### LLM 集成与 Tool Call 机制 -`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互。SQL 生成过程通过 Tool Calling 约定完成: -- 导出 Tool:`execute_sql(sql: string, explanation: string)` -- 模型通过调用 `execute_sql` 返回生成的 SQL 及对查询动作的解释说明。 +`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,支持双 Tool Calling 与多轮数据集召回: +- 数据库查询 Tool:`execute_sql(sql: string, explanation: string)` +- JS 数据分析 Tool:`execute_javascript(js_code: string, explanation: string)` + +#### 零数据泄露与 Session 数据集召回 (Session DataStore) +- 每次查询成功的结果在本地分配标号(`res1`, `res2`, ...)。 +- 大模型上下文中仅包含数据集的轻量 Catalog 目录结构(字段名与行数),不传输海量真实数据。 +- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 Go 宿主层安全导出为 CSV/JSON/Markdown。 ### 快捷键操作 diff --git a/docs/rfcs/0011-goja-js-data-analysis.md b/docs/rfcs/0011-goja-js-data-analysis.md new file mode 100644 index 0000000..7f4f3fa --- /dev/null +++ b/docs/rfcs/0011-goja-js-data-analysis.md @@ -0,0 +1,27 @@ +# RFC 0011: Integration of goja JS Engine and Session DataStore for AI Data Analytics + +Status: Proposed + +## 摘要 +本 RFC 提出在 `xsql` / `xsql-ai` 中集成纯 Go 实现的 `goja` JavaScript 虚拟机(100% Zero CGO),并构建 **Session DataStore(会话数据集存储与召回)** 机制。 + +## 背景 / 动机 +- 当前 `xsql-ai` 仅支持 SQL 交互与表格展示,缺少数据二次聚合计算、跨查询结果 Join/比对以及结构化导出文件(CSV/JSON/Markdown)的能力。 +- 将海量原始数据全量透传给大模型(LLM)会导致上下文爆炸(Context Overflow)与高昂 Token 成本,且存在数据隐私泄露红线。 + +## 架构与核心设计 + +### 1. 零 CGO JS 引擎 (`internal/js`) +- 使用 `github.com/dop251/goja` 在纯 Go 内存沙箱中执行 AI 动态生成的 JS 数据分析代码。 +- 支持 Context 超时打断(默认 1 分钟,可配置 `js_timeout`)。 + +### 2. Session 数据集存储与召回 (`internal/session`) +- 本地维护 `SessionDataStore`,为每次 SQL 执行成功的 QueryResult 分配唯一 ID(`res1`, `res2`, ...)。 +- 向大模型上下文仅提供轻量 **Dataset Catalog** 元数据目录,LLM 可以在后续多轮对话中指定 `res1`, `res2` 召回历史数据并在 JS 中做跨数据集 Join 或计算。 + +### 3. 外层文件导出 (`internal/export`) +- JS 仅负责数据计算与转换;由外层 Go 宿主层统一执行安全的磁盘文件写入(CSV / JSON / Markdown)。 + +### 4. AI Tool Calling (`internal/ai`) +- 新增 Tool:`execute_javascript(js_code: string, explanation: string)`。 +- AI 可先通过 `execute_sql` 查出数据,再调用 `execute_javascript` 完成分析与导出。 diff --git a/go.mod b/go.mod index 08443a5..c4e092f 100644 --- a/go.mod +++ b/go.mod @@ -27,8 +27,12 @@ require ( github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dlclark/regexp2/v2 v2.5.2 // indirect + github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 6ac528d..ed5297e 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,14 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= +github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es= +github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -39,6 +45,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/ai/client.go b/internal/ai/client.go index 6a43787..56d2051 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -44,7 +44,7 @@ func NewClient(cfg config.AIConfig, httpClient *http.Client) *Client { } } -func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*SQLResponse, *errors.XError) { +func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*AIResponse, *errors.XError) { sdkMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)) for _, m := range messages { switch m.Role { @@ -59,7 +59,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*S } } - toolDef := openai.ChatCompletionToolParam{ + sqlToolDef := openai.ChatCompletionToolParam{ Function: shared.FunctionDefinitionParam{ Name: "execute_sql", Description: openai.String("Execute or present generated SQL query based on database schema and user intent"), @@ -80,6 +80,27 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*S }, } + jsToolDef := openai.ChatCompletionToolParam{ + Function: shared.FunctionDefinitionParam{ + Name: "execute_javascript", + Description: openai.String("Execute JavaScript code in local goja VM sandbox to aggregate, transform, join, or format active session datasets (res1, res2, rows)."), + Parameters: shared.FunctionParameters{ + "type": "object", + "properties": map[string]interface{}{ + "js_code": map[string]interface{}{ + "type": "string", + "description": "The JavaScript code snippet to execute on active session datasets.", + }, + "explanation": map[string]interface{}{ + "type": "string", + "description": "Explanation of what the JavaScript processing code does.", + }, + }, + "required": []string{"js_code", "explanation"}, + }, + }, + } + model := c.cfg.Model if model == "" { model = "gpt-4o" @@ -88,7 +109,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*S params := openai.ChatCompletionNewParams{ Model: shared.ChatModel(model), Messages: sdkMessages, - Tools: []openai.ChatCompletionToolParam{toolDef}, + Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef}, } if c.cfg.MaxTokens > 0 { params.MaxTokens = openai.Int(int64(c.cfg.MaxTokens)) @@ -110,17 +131,35 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*S for _, toolCall := range msg.ToolCalls { if toolCall.Function.Name == "execute_sql" { - var sqlResp SQLResponse - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &sqlResp); err == nil { - sqlResp.SQL = strings.TrimSpace(sqlResp.SQL) - sqlResp.Explanation = strings.TrimSpace(sqlResp.Explanation) - return &sqlResp, nil + var raw struct { + SQL string `json:"sql"` + Explanation string `json:"explanation"` + } + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err == nil { + return &AIResponse{ + Type: TypeSQL, + SQL: strings.TrimSpace(raw.SQL), + Explanation: strings.TrimSpace(raw.Explanation), + }, nil + } + } else if toolCall.Function.Name == "execute_javascript" { + var raw struct { + JSCode string `json:"js_code"` + Explanation string `json:"explanation"` + } + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err == nil { + return &AIResponse{ + Type: TypeJS, + JSCode: strings.TrimSpace(raw.JSCode), + Explanation: strings.TrimSpace(raw.Explanation), + }, nil } } } content := strings.TrimSpace(msg.Content) - return &SQLResponse{ + return &AIResponse{ + Type: TypeText, SQL: "", Explanation: content, }, nil diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index ed3ee42..1ab77dd 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -7,23 +7,28 @@ import ( "github.com/zx06/xsql/internal/db" ) -const SystemPromptTemplate = `You are an expert AI SQL generator for the %s database. -Your job is to convert natural language requests into correct, efficient SQL queries based on the provided database schema. +const SystemPromptTemplate = `You are an expert AI SQL generator and Data Analyst for the %s database. +Your job is to convert natural language requests into correct, efficient SQL queries or JavaScript data analysis scripts. DATABASE SCHEMA: %s -IMPORTANT RULES: -1. Generate valid %s SQL ONLY. -2. Default to READ-ONLY SELECT queries unless explicitly instructed otherwise. -3. When you have generated a SQL query or need to respond with a query decision, call the 'execute_sql' tool with arguments: +%s + +AVAILABLE TOOLS: +1. 'execute_sql': Call this to query the database. - "sql": the generated SQL query (e.g. "SELECT * FROM users WHERE active = true;") - - "explanation": a concise explanation of what the query does or why it cannot be generated. -4. If the request asks for general database metadata or listing tables/columns (e.g. 'show tables', 'what tables exist'), generate standard SQL (e.g. 'SHOW TABLES;' for MySQL, or 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\';' for PostgreSQL) even if the provided schema is empty. -5. Avoid full table scans without limits or filters whenever possible. Prefer specifying necessary columns, WHERE conditions, or adding LIMIT clauses where appropriate to protect performance. -6. If the request genuinely cannot be answered by the schema or database, call 'execute_sql' with "sql": "" and state the reason in "explanation".` + - "explanation": a concise explanation of what the query does. +2. 'execute_javascript': Call this when the user asks for post-query data analysis, percentage calculations, cross-dataset joins/comparisons, or structured formatting. + - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). + - "explanation": explanation of what the JavaScript script processes. -func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo) string { +IMPORTANT RULES: +1. Default to READ-ONLY SELECT queries for database execution. +2. Avoid full table scans without limits or filters whenever possible. +3. When post-processing or joining previously queried datasets (e.g. 'res1', 'res2'), prefer calling 'execute_javascript' to compute results locally.` + +func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { schemaJSON := "{}" if schemaInfo != nil { if bytes, err := json.MarshalIndent(schemaInfo, "", " "); err == nil { @@ -33,5 +38,9 @@ func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo) string { if dbType == "" { dbType = "MySQL/PostgreSQL" } - return fmt.Sprintf(SystemPromptTemplate, dbType, schemaJSON, dbType) + catalogBlock := "" + if catalog != "" { + catalogBlock = fmt.Sprintf("SESSION DATASETS CATALOG:\n%s\n", catalog) + } + return fmt.Sprintf(SystemPromptTemplate, dbType, schemaJSON, catalogBlock) } diff --git a/internal/ai/service.go b/internal/ai/service.go index 7d62c70..5c65e33 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -8,11 +8,23 @@ import ( "github.com/zx06/xsql/internal/errors" ) -type SQLResponse struct { - SQL string `json:"sql"` - Explanation string `json:"explanation"` +type ResponseType string + +const ( + TypeSQL ResponseType = "sql" + TypeJS ResponseType = "js" + TypeText ResponseType = "text" +) + +type AIResponse struct { + Type ResponseType `json:"type"` + SQL string `json:"sql,omitempty"` + JSCode string `json:"js_code,omitempty"` + Explanation string `json:"explanation"` } +type SQLResponse = AIResponse + type Service struct { client *Client } @@ -26,8 +38,12 @@ func NewService(cfg config.AIConfig, client *Client) *Service { } } -func (s *Service) GenerateSQL(ctx context.Context, userPrompt string, schemaInfo *db.SchemaInfo, dbType string) (*SQLResponse, *errors.XError) { - systemPrompt := BuildSystemPrompt(dbType, schemaInfo) +func (s *Service) GenerateSQL(ctx context.Context, userPrompt string, schemaInfo *db.SchemaInfo, dbType string) (*AIResponse, *errors.XError) { + return s.GenerateResponse(ctx, userPrompt, schemaInfo, dbType, "") +} + +func (s *Service) GenerateResponse(ctx context.Context, userPrompt string, schemaInfo *db.SchemaInfo, dbType string, catalog string) (*AIResponse, *errors.XError) { + systemPrompt := BuildSystemPrompt(dbType, schemaInfo, catalog) messages := []ChatMessage{ {Role: "system", Content: systemPrompt}, diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index 4d9d457..e53e65a 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -24,12 +24,12 @@ func TestBuildSystemPrompt(t *testing.T) { }, } - prompt := BuildSystemPrompt("mysql", schema) + prompt := BuildSystemPrompt("mysql", schema, "res1: users") if prompt == "" { t.Fatal("expected non-empty prompt") } - defaultPrompt := BuildSystemPrompt("", nil) + defaultPrompt := BuildSystemPrompt("", nil, "") if defaultPrompt == "" { t.Fatal("expected non-empty default prompt") } @@ -110,6 +110,61 @@ func TestGenerateSQL_MockHTTP_ToolCall(t *testing.T) { } } +func TestGenerateResponse_MockHTTP_JSToolCall(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respBody := `{ + "id": "chatcmpl-125", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_js123", + "type": "function", + "function": { + "name": "execute_javascript", + "arguments": "{\"js_code\":\"rows.filter(r => r.status === 'ONLINE');\",\"explanation\":\"Filters online servers.\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + }` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respBody)) + })) + defer mockServer.Close() + + cfg := config.AIConfig{ + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + } + + client := NewClient(cfg, mockServer.Client()) + service := NewService(cfg, client) + + res, xe := service.GenerateResponse(context.Background(), "filter online servers", nil, "mysql", "res1 catalog") + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + + if res.Type != TypeJS { + t.Errorf("expected type JS, got %q", res.Type) + } + if res.JSCode != "rows.filter(r => r.status === 'ONLINE');" { + t.Errorf("unexpected JS code: %q", res.JSCode) + } +} + func TestGenerateSQL_MockHTTP_TextMessageFallback(t *testing.T) { mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respBody := `{ diff --git a/internal/export/exporter.go b/internal/export/exporter.go new file mode 100644 index 0000000..f2b6981 --- /dev/null +++ b/internal/export/exporter.go @@ -0,0 +1,117 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/errors" +) + +type ExportFormat string + +const ( + FormatCSV ExportFormat = "csv" + FormatJSON ExportFormat = "json" + FormatMarkdown ExportFormat = "markdown" +) + +func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath string) (string, *errors.XError) { + if result == nil { + return "", errors.New(errors.CodeCfgInvalid, "cannot export nil QueryResult", nil) + } + + if filePath == "" { + filePath = fmt.Sprintf("export_%s.%s", format, format) + } + + // Ensure directory exists + dir := filepath.Dir(filePath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", errors.New(errors.CodeInternal, "failed to create export directory", map[string]any{ + "dir": dir, + "err": err.Error(), + }) + } + } + + f, err := os.Create(filePath) + if err != nil { + return "", errors.New(errors.CodeInternal, "failed to create export file", map[string]any{ + "path": filePath, + "err": err.Error(), + }) + } + defer f.Close() + + switch format { + case FormatJSON: + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(result.Rows); err != nil { + return "", errors.New(errors.CodeInternal, "failed to write JSON export", map[string]any{"err": err.Error()}) + } + + case FormatMarkdown: + var sb strings.Builder + sb.WriteString("| " + strings.Join(result.Columns, " | ") + " |\n") + var sep []string + for range result.Columns { + sep = append(sep, "---") + } + sb.WriteString("| " + strings.Join(sep, " | ") + " |\n") + + for _, row := range result.Rows { + var vals []string + for _, col := range result.Columns { + val := row[col] + if val == nil { + vals = append(vals, "NULL") + } else { + cellStr := fmt.Sprintf("%v", val) + cellStr = strings.ReplaceAll(cellStr, "\n", " ") + cellStr = strings.ReplaceAll(cellStr, "|", "\\|") + vals = append(vals, cellStr) + } + } + sb.WriteString("| " + strings.Join(vals, " | ") + " |\n") + } + if _, err := f.WriteString(sb.String()); err != nil { + return "", errors.New(errors.CodeInternal, "failed to write Markdown export", map[string]any{"err": err.Error()}) + } + + case FormatCSV: + fallthrough + default: + w := csv.NewWriter(f) + if err := w.Write(result.Columns); err != nil { + return "", errors.New(errors.CodeInternal, "failed to write CSV header", map[string]any{"err": err.Error()}) + } + for _, row := range result.Rows { + var vals []string + for _, col := range result.Columns { + val := row[col] + if val == nil { + vals = append(vals, "") + } else { + vals = append(vals, fmt.Sprintf("%v", val)) + } + } + if err := w.Write(vals); err != nil { + return "", errors.New(errors.CodeInternal, "failed to write CSV row", map[string]any{"err": err.Error()}) + } + } + w.Flush() + if err := w.Error(); err != nil { + return "", errors.New(errors.CodeInternal, "failed to flush CSV writer", map[string]any{"err": err.Error()}) + } + } + + absPath, _ := filepath.Abs(filePath) + return absPath, nil +} diff --git a/internal/export/exporter_test.go b/internal/export/exporter_test.go new file mode 100644 index 0000000..7f17fbe --- /dev/null +++ b/internal/export/exporter_test.go @@ -0,0 +1,59 @@ +package export + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zx06/xsql/internal/db" +) + +func TestExportQueryResult_CSV_JSON_MD(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xsql_export_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + res := &db.QueryResult{ + Columns: []string{"id", "username", "status"}, + Rows: []map[string]any{ + {"id": 1, "username": "alice", "status": "active"}, + {"id": 2, "username": "bob", "status": nil}, + }, + } + + // 1. CSV + csvPath := filepath.Join(tempDir, "test.csv") + absPath, xe := ExportQueryResult(res, FormatCSV, csvPath) + if xe != nil { + t.Fatalf("CSV export failed: %v", xe) + } + content, _ := os.ReadFile(absPath) + if !strings.Contains(string(content), "username") || !strings.Contains(string(content), "alice") { + t.Fatalf("unexpected CSV content: %s", string(content)) + } + + // 2. JSON + jsonPath := filepath.Join(tempDir, "test.json") + absPath, xe = ExportQueryResult(res, FormatJSON, jsonPath) + if xe != nil { + t.Fatalf("JSON export failed: %v", xe) + } + content, _ = os.ReadFile(absPath) + if !strings.Contains(string(content), `"alice"`) { + t.Fatalf("unexpected JSON content: %s", string(content)) + } + + // 3. Markdown + mdPath := filepath.Join(tempDir, "test.md") + absPath, xe = ExportQueryResult(res, FormatMarkdown, mdPath) + if xe != nil { + t.Fatalf("Markdown export failed: %v", xe) + } + content, _ = os.ReadFile(absPath) + if !strings.Contains(string(content), "| username |") { + t.Fatalf("unexpected Markdown content: %s", string(content)) + } +} diff --git a/internal/js/engine.go b/internal/js/engine.go new file mode 100644 index 0000000..48407b0 --- /dev/null +++ b/internal/js/engine.go @@ -0,0 +1,104 @@ +package js + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/dop251/goja" + + "github.com/zx06/xsql/internal/errors" + "github.com/zx06/xsql/internal/session" +) + +type ExecutionResult struct { + Value any `json:"value"` + JSONString string `json:"json_string"` + SummaryText string `json:"summary_text"` +} + +type JSEngine struct { + DefaultTimeout time.Duration +} + +func NewJSEngine(timeout time.Duration) *JSEngine { + if timeout <= 0 { + timeout = 1 * time.Minute + } + return &JSEngine{ + DefaultTimeout: timeout, + } +} + +func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.SessionDataStore) (*ExecutionResult, *errors.XError) { + if ctx == nil { + ctx = context.Background() + } + + execCtx, cancel := context.WithTimeout(ctx, e.DefaultTimeout) + defer cancel() + + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + + // Inject all active datasets from store + if store != nil { + allData := store.GetAll() + for id, queryRes := range allData { + if queryRes != nil { + _ = vm.Set(id, queryRes.Rows) + } + } + // Inject latest query result as `rows` and `columns` + if latest, ok := store.Latest(); ok && latest != nil { + _ = vm.Set("rows", latest.Rows) + _ = vm.Set("columns", latest.Columns) + } + } + + // Timeout interrupt setup + doneChan := make(chan struct{}) + defer close(doneChan) + + go func() { + select { + case <-execCtx.Done(): + if execCtx.Err() == context.DeadlineExceeded { + vm.Interrupt("execution timeout (limit reached)") + } + case <-doneChan: + } + }() + + // Execute JS script + val, err := vm.RunString(jsCode) + if err != nil { + return nil, errors.New(errors.CodeDBExecFailed, "JavaScript execution error", map[string]any{ + "err": err.Error(), + }) + } + + if val == nil || goja.IsUndefined(val) || goja.IsNull(val) { + return &ExecutionResult{ + Value: nil, + JSONString: "null", + SummaryText: "(null)", + }, nil + } + + exported := val.Export() + jsonBytes, jsonErr := json.MarshalIndent(exported, "", " ") + jsonStr := "" + if jsonErr == nil { + jsonStr = string(jsonBytes) + } else { + jsonStr = fmt.Sprintf("%v", exported) + } + + return &ExecutionResult{ + Value: exported, + JSONString: jsonStr, + SummaryText: jsonStr, + }, nil +} diff --git a/internal/js/engine_test.go b/internal/js/engine_test.go new file mode 100644 index 0000000..7b4e95a --- /dev/null +++ b/internal/js/engine_test.go @@ -0,0 +1,70 @@ +package js + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/session" +) + +func TestJSEngine_MultiDatasetRecall(t *testing.T) { + store := session.NewSessionDataStore() + + res1 := &db.QueryResult{ + Columns: []string{"id", "name", "status"}, + Rows: []map[string]any{ + {"id": 1, "name": "srv1", "status": "ONLINE"}, + {"id": 2, "name": "srv2", "status": "OFFLINE"}, + }, + } + store.Save("servers query", res1) + + res2 := &db.QueryResult{ + Columns: []string{"id", "server_id", "severity"}, + Rows: []map[string]any{ + {"id": 101, "server_id": 1, "severity": "HIGH"}, + }, + } + store.Save("alerts query", res2) + + engine := NewJSEngine(5 * time.Second) + + // JS script joining res1 and res2 + jsCode := ` + (function() { + var alertServerIds = new Set(res2.map(function(a) { return a.server_id; })); + var onlineWithAlerts = res1.filter(function(s) { + return s.status === 'ONLINE' && alertServerIds.has(s.id); + }); + return { + count: onlineWithAlerts.length, + servers: onlineWithAlerts + }; + })(); + ` + + result, xe := engine.Execute(context.Background(), jsCode, store) + if xe != nil { + t.Fatalf("unexpected execution error: %v", xe) + } + + if !strings.Contains(result.JSONString, `"count": 1`) || !strings.Contains(result.JSONString, `"srv1"`) { + t.Fatalf("expected joined result with srv1, got:\n%s", result.JSONString) + } +} + +func TestJSEngine_Timeout(t *testing.T) { + engine := NewJSEngine(100 * time.Millisecond) + + jsCode := ` + while(true) {} + ` + + _, xe := engine.Execute(context.Background(), jsCode, nil) + if xe == nil { + t.Fatal("expected timeout error for infinite loop, got nil") + } +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..fbafeaa --- /dev/null +++ b/internal/session/store.go @@ -0,0 +1,108 @@ +package session + +import ( + "fmt" + "strings" + "sync" + + "github.com/zx06/xsql/internal/db" +) + +type DatasetEntry struct { + ID string `json:"id"` + Description string `json:"description"` + Result *db.QueryResult `json:"result"` +} + +type SessionDataStore struct { + mu sync.RWMutex + datasets map[string]*DatasetEntry + order []string + counter int +} + +func NewSessionDataStore() *SessionDataStore { + return &SessionDataStore{ + datasets: make(map[string]*DatasetEntry), + order: make([]string, 0), + } +} + +func (s *SessionDataStore) Save(description string, result *db.QueryResult) string { + if result == nil { + return "" + } + s.mu.Lock() + defer s.mu.Unlock() + + s.counter++ + id := fmt.Sprintf("res%d", s.counter) + + entry := &DatasetEntry{ + ID: id, + Description: strings.TrimSpace(description), + Result: result, + } + + s.datasets[id] = entry + s.order = append(s.order, id) + return id +} + +func (s *SessionDataStore) Get(id string) (*db.QueryResult, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.datasets[id] + if !ok || entry == nil { + return nil, false + } + return entry.Result, true +} + +func (s *SessionDataStore) Latest() (*db.QueryResult, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + if len(s.order) == 0 { + return nil, false + } + lastID := s.order[len(s.order)-1] + return s.datasets[lastID].Result, true +} + +func (s *SessionDataStore) GetAll() map[string]*db.QueryResult { + s.mu.RLock() + defer s.mu.RUnlock() + + res := make(map[string]*db.QueryResult, len(s.datasets)) + for k, v := range s.datasets { + res[k] = v.Result + } + return res +} + +func (s *SessionDataStore) GetCatalog() string { + s.mu.RLock() + defer s.mu.RUnlock() + + if len(s.datasets) == 0 { + return "(No active datasets in current session)" + } + + var sb strings.Builder + sb.WriteString("Active Datasets in Session:\n") + for _, id := range s.order { + entry := s.datasets[id] + if entry == nil || entry.Result == nil { + continue + } + cols := strings.Join(entry.Result.Columns, ", ") + desc := entry.Description + if desc == "" { + desc = "Query Result" + } + fmt.Fprintf(&sb, "- `%s`: %s (%d rows, columns: [%s])\n", id, desc, len(entry.Result.Rows), cols) + } + return sb.String() +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go new file mode 100644 index 0000000..ec7ad79 --- /dev/null +++ b/internal/session/store_test.go @@ -0,0 +1,49 @@ +package session + +import ( + "strings" + "testing" + + "github.com/zx06/xsql/internal/db" +) + +func TestSessionDataStore(t *testing.T) { + store := NewSessionDataStore() + + if catalog := store.GetCatalog(); !strings.Contains(catalog, "No active datasets") { + t.Fatalf("expected empty catalog warning, got: %s", catalog) + } + + res1 := &db.QueryResult{ + Columns: []string{"id", "name"}, + Rows: []map[string]any{{"id": 1, "name": "srv1"}}, + } + id1 := store.Save("servers query", res1) + if id1 != "res1" { + t.Fatalf("expected ID 'res1', got %s", id1) + } + + res2 := &db.QueryResult{ + Columns: []string{"id", "severity"}, + Rows: []map[string]any{{"id": 1, "severity": "HIGH"}}, + } + id2 := store.Save("alerts query", res2) + if id2 != "res2" { + t.Fatalf("expected ID 'res2', got %s", id2) + } + + latest, ok := store.Latest() + if !ok || len(latest.Rows) != 1 || latest.Rows[0]["severity"] != "HIGH" { + t.Fatal("failed to retrieve latest dataset") + } + + catalog := store.GetCatalog() + if !strings.Contains(catalog, "`res1`") || !strings.Contains(catalog, "`res2`") { + t.Fatalf("expected catalog to list res1 and res2, got:\n%s", catalog) + } + + got1, ok := store.Get("res1") + if !ok || got1.Rows[0]["name"] != "srv1" { + t.Fatal("failed to get res1 from store") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 91a8dac..1063ee9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -17,6 +17,8 @@ import ( "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/db" "github.com/zx06/xsql/internal/errors" + "github.com/zx06/xsql/internal/js" + "github.com/zx06/xsql/internal/session" ) type State int @@ -35,11 +37,13 @@ type schemaLoadedMsg struct { err *errors.XError } -type sqlGeneratedMsg struct { - response *ai.SQLResponse +type aiResponseMsg struct { + response *ai.AIResponse err *errors.XError } +type sqlGeneratedMsg = aiResponseMsg + type queryExecutedMsg struct { result *db.QueryResult err *errors.XError @@ -63,6 +67,9 @@ type Model struct { initialPrompt string autoExecute bool + sessionStore *session.SessionDataStore + jsEngine *js.JSEngine + state State schemaInfo *db.SchemaInfo currentSQL string @@ -82,7 +89,7 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() - ta.Placeholder = "Ask AI to generate a SQL query (e.g. 'Show top 10 servers')...." + ta.Placeholder = "Ask AI to generate SQL or analyze datasets (e.g. 'Show top 10 servers')...." ta.ShowLineNumbers = false ta.Prompt = "" ta.Focus() @@ -104,6 +111,8 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, + sessionStore: session.NewSessionDataStore(), + jsEngine: js.NewJSEngine(1 * time.Minute), tableStates: []TableState{}, activeTable: -1, state: StateLoadingSchema, @@ -138,8 +147,9 @@ func (m Model) loadSchemaCmd() tea.Cmd { func (m Model) generateSQLCmd(prompt string) tea.Cmd { return func() tea.Msg { ctx := context.Background() - resp, xe := m.aiService.GenerateSQL(ctx, prompt, m.schemaInfo, m.profile.DB) - return sqlGeneratedMsg{response: resp, err: xe} + catalog := m.sessionStore.GetCatalog() + resp, xe := m.aiService.GenerateResponse(ctx, prompt, m.schemaInfo, m.profile.DB, catalog) + return aiResponseMsg{response: resp, err: xe} } } @@ -205,18 +215,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = StateIdle m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - case sqlGeneratedMsg: + case aiResponseMsg: if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("AI Error: %v", msg.err))) m.state = StateIdle } else { - m.currentSQL = msg.response.SQL m.explanation = msg.response.Explanation - aiMsg := AITagStyle.Render("🤖 AI") + " " + AIResponseStyle.Render(msg.response.Explanation) m.messages = append(m.messages, aiMsg) - if msg.response.SQL != "" { + if msg.response.Type == ai.TypeJS && msg.response.JSCode != "" { + // Execute JS script using goja on active session datasets + jsExecLine := ExecutingTagStyle.Render("⚡ JS Analysis") + " " + SQLCodeStyle.Render(msg.response.JSCode) + m.messages = append(m.messages, jsExecLine) + + ctx := context.Background() + jsRes, jsErr := m.jsEngine.Execute(ctx, msg.response.JSCode, m.sessionStore) + if jsErr != nil { + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("JS Execution Error: %v", jsErr))) + } else { + resultMsg := SuccessBadgeStyle.Render("📊 JS Result:") + "\n" + AIResponseStyle.Render(jsRes.SummaryText) + m.messages = append(m.messages, resultMsg) + } + m.state = StateIdle + } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { + m.currentSQL = msg.response.SQL if m.autoExecute { m.state = StateExecuting execLine := ExecutingTagStyle.Render("⚡ Auto-Executing") + " " + SQLCodeStyle.Render(m.currentSQL) @@ -237,6 +260,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) } else if msg.result != nil { + // Save QueryResult into SessionDataStore and get assigned ID + datasetID := m.sessionStore.Save(m.currentSQL, msg.result) + modelName := m.opts.CLIAIModel if modelName == "" { modelName = "gpt-4o" @@ -245,7 +271,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.duration < time.Millisecond { durStr = fmt.Sprintf("%.2fms", float64(msg.duration.Microseconds())/1000.0) } - metricsStr := fmt.Sprintf("⏱️ %s | 📊 %d rows | 🤖 %s", durStr, len(msg.result.Rows), modelName) + metricsStr := fmt.Sprintf("⏱️ %s | 📊 %d rows | 🤖 %s | 💾 %s", durStr, len(msg.result.Rows), modelName, datasetID) statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) m.messages = append(m.messages, statusLine) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f734291..6c6ea7b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -37,7 +37,8 @@ func TestTUI_Model_StateTransitions(t *testing.T) { // 2. Send sqlGeneratedMsg -> transition to StateSQLReady updated, _ = m.Update(sqlGeneratedMsg{ - response: &ai.SQLResponse{ + response: &ai.AIResponse{ + Type: ai.TypeSQL, SQL: "SELECT * FROM users;", Explanation: "Returns all users.", }, @@ -163,7 +164,8 @@ func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { // Send sqlGeneratedMsg -> should automatically transition to StateExecuting updated, cmd := m.Update(sqlGeneratedMsg{ - response: &ai.SQLResponse{ + response: &ai.AIResponse{ + Type: ai.TypeSQL, SQL: "SELECT * FROM users;", Explanation: "Returns users.", }, From 183327c763d7b2e0292d931382321540839c260c Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:30:06 +0800 Subject: [PATCH 37/75] feat: add AI auto-fix retry loop for JS analysis and inject ES6 polyfills --- internal/js/engine.go | 80 +++++++++++++++++++++++++++++++++++++++---- internal/tui/model.go | 19 +++++++++- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/internal/js/engine.go b/internal/js/engine.go index 48407b0..e1a4a13 100644 --- a/internal/js/engine.go +++ b/internal/js/engine.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/dop251/goja" @@ -13,9 +14,10 @@ import ( ) type ExecutionResult struct { - Value any `json:"value"` - JSONString string `json:"json_string"` - SummaryText string `json:"summary_text"` + Value any `json:"value"` + JSONString string `json:"json_string"` + SummaryText string `json:"summary_text"` + Logs []string `json:"logs"` } type JSEngine struct { @@ -42,6 +44,61 @@ func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.Se vm := goja.New() vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + // Inject console.log / console.error capture + var logs []string + console := vm.NewObject() + _ = console.Set("log", func(call goja.FunctionCall) goja.Value { + var args []string + for _, arg := range call.Arguments { + args = append(args, fmt.Sprintf("%v", arg.Export())) + } + logs = append(logs, strings.Join(args, " ")) + return goja.Undefined() + }) + _ = console.Set("error", func(call goja.FunctionCall) goja.Value { + var args []string + for _, arg := range call.Arguments { + args = append(args, fmt.Sprintf("%v", arg.Export())) + } + logs = append(logs, "[ERROR] "+strings.Join(args, " ")) + return goja.Undefined() + }) + _ = vm.Set("console", console) + + // Inject Common ES6 Polyfills (String.prototype.repeat, Object.entries, Object.values, Object.assign) + polyfills := ` + if (!String.prototype.repeat) { + String.prototype.repeat = function(count) { + var str = '' + this; + count = +count; + if (count != count) count = 0; + if (count < 0) return ''; + var r = ''; + while (count > 0) { + if (count & 1) r += str; + count >>>= 1; + str += str; + } + return r; + }; + } + if (!Object.entries) { + Object.entries = function(obj) { + var ownProps = Object.keys(obj), i = ownProps.length, resArray = new Array(i); + while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; + return resArray; + }; + } + if (!Object.values) { + Object.values = function(obj) { + var ownProps = Object.keys(obj), i = ownProps.length, resArray = new Array(i); + while (i--) resArray[i] = obj[ownProps[i]]; + return resArray; + }; + } + ` + _, _ = vm.RunString(polyfills) + // Inject all active datasets from store if store != nil { allData := store.GetAll() @@ -74,16 +131,21 @@ func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.Se // Execute JS script val, err := vm.RunString(jsCode) if err != nil { - return nil, errors.New(errors.CodeDBExecFailed, "JavaScript execution error", map[string]any{ + return nil, errors.New(errors.CodeDBExecFailed, fmt.Sprintf("JavaScript execution error: %v", err), map[string]any{ "err": err.Error(), }) } if val == nil || goja.IsUndefined(val) || goja.IsNull(val) { + summary := "(null)" + if len(logs) > 0 { + summary = strings.Join(logs, "\n") + } return &ExecutionResult{ Value: nil, JSONString: "null", - SummaryText: "(null)", + SummaryText: summary, + Logs: logs, }, nil } @@ -96,9 +158,15 @@ func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.Se jsonStr = fmt.Sprintf("%v", exported) } + summaryText := jsonStr + if len(logs) > 0 { + summaryText = strings.Join(logs, "\n") + "\n\nReturn Value:\n" + jsonStr + } + return &ExecutionResult{ Value: exported, JSONString: jsonStr, - SummaryText: jsonStr, + SummaryText: summaryText, + Logs: logs, }, nil } diff --git a/internal/tui/model.go b/internal/tui/model.go index 1063ee9..103d6e7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -69,6 +69,8 @@ type Model struct { sessionStore *session.SessionDataStore jsEngine *js.JSEngine + jsRetryCount int + maxJSRetries int state State schemaInfo *db.SchemaInfo @@ -113,6 +115,8 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi autoExecute: false, sessionStore: session.NewSessionDataStore(), jsEngine: js.NewJSEngine(1 * time.Minute), + jsRetryCount: 0, + maxJSRetries: 3, tableStates: []TableState{}, activeTable: -1, state: StateLoadingSchema, @@ -232,8 +236,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ctx := context.Background() jsRes, jsErr := m.jsEngine.Execute(ctx, msg.response.JSCode, m.sessionStore) if jsErr != nil { - m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("JS Execution Error: %v", jsErr))) + m.jsRetryCount++ + if m.jsRetryCount <= m.maxJSRetries { + retryWarn := ErrorMsgStyle.Render(fmt.Sprintf("⚠️ JS Execution Failed (Attempt %d/%d): %v", m.jsRetryCount, m.maxJSRetries, jsErr.Message)) + m.messages = append(m.messages, retryWarn) + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + + retryPrompt := fmt.Sprintf("The previous JavaScript code execution failed with error:\n%s\n\nPlease analyze the error, fix your JavaScript code, and call 'execute_javascript' again.", jsErr.Message) + return m, m.generateSQLCmd(retryPrompt) + } + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("❌ JS Execution Error (after %d retries): %v", m.maxJSRetries, jsErr.Message))) + m.jsRetryCount = 0 } else { + m.jsRetryCount = 0 resultMsg := SuccessBadgeStyle.Render("📊 JS Result:") + "\n" + AIResponseStyle.Render(jsRes.SummaryText) m.messages = append(m.messages, resultMsg) } From bad3f0782178e9475f3c628339d2013157d3bc13 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:37:56 +0800 Subject: [PATCH 38/75] refactor: specify ES5 JS syntax in system prompt and remove manual polyfills --- internal/ai/prompt.go | 5 +++-- internal/js/engine.go | 34 ---------------------------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 1ab77dd..6042586 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -20,13 +20,14 @@ AVAILABLE TOOLS: - "sql": the generated SQL query (e.g. "SELECT * FROM users WHERE active = true;") - "explanation": a concise explanation of what the query does. 2. 'execute_javascript': Call this when the user asks for post-query data analysis, percentage calculations, cross-dataset joins/comparisons, or structured formatting. - - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). + - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). Must be ES5 standard syntax. - "explanation": explanation of what the JavaScript script processes. IMPORTANT RULES: 1. Default to READ-ONLY SELECT queries for database execution. 2. Avoid full table scans without limits or filters whenever possible. -3. When post-processing or joining previously queried datasets (e.g. 'res1', 'res2'), prefer calling 'execute_javascript' to compute results locally.` +3. When post-processing or joining previously queried datasets (e.g. 'res1', 'res2'), prefer calling 'execute_javascript' to compute results locally. +4. JAVASCRIPT ENVIRONMENT SPECIFICATION: The execution environment is strict ES5 (ECMAScript 5.1). Do NOT use ES6+ features such as String.prototype.repeat, Object.entries, Object.values, Arrow functions, let/const, or async/await. Always use standard ES5 syntax (e.g., var, function(), standard for loops, Object.keys()).` func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { schemaJSON := "{}" diff --git a/internal/js/engine.go b/internal/js/engine.go index e1a4a13..8646965 100644 --- a/internal/js/engine.go +++ b/internal/js/engine.go @@ -65,40 +65,6 @@ func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.Se }) _ = vm.Set("console", console) - // Inject Common ES6 Polyfills (String.prototype.repeat, Object.entries, Object.values, Object.assign) - polyfills := ` - if (!String.prototype.repeat) { - String.prototype.repeat = function(count) { - var str = '' + this; - count = +count; - if (count != count) count = 0; - if (count < 0) return ''; - var r = ''; - while (count > 0) { - if (count & 1) r += str; - count >>>= 1; - str += str; - } - return r; - }; - } - if (!Object.entries) { - Object.entries = function(obj) { - var ownProps = Object.keys(obj), i = ownProps.length, resArray = new Array(i); - while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; - return resArray; - }; - } - if (!Object.values) { - Object.values = function(obj) { - var ownProps = Object.keys(obj), i = ownProps.length, resArray = new Array(i); - while (i--) resArray[i] = obj[ownProps[i]]; - return resArray; - }; - } - ` - _, _ = vm.RunString(polyfills) - // Inject all active datasets from store if store != nil { allData := store.GetAll() From 047301faf361396f1d1947ef12c4f055b5b81fc5 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:39:00 +0800 Subject: [PATCH 39/75] feat: implement proactive AI intent evaluation and automatic post-query JS analysis chain --- internal/tui/model.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 103d6e7..376f661 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -65,6 +65,7 @@ type Model struct { profileName string unsafeAllowWrite bool initialPrompt string + lastUserPrompt string autoExecute bool sessionStore *session.SessionDataStore @@ -112,6 +113,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi profileName: resolved.ProfileName, unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), + lastUserPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, sessionStore: session.NewSessionDataStore(), jsEngine: js.NewJSEngine(1 * time.Minute), @@ -189,6 +191,17 @@ func (m *Model) renderTableState(idx int, isActive bool) { m.viewport.SetContent(strings.Join(m.messages, "\n\n")) } +func shouldTriggerJSAnalysis(prompt string) bool { + p := strings.ToLower(prompt) + keywords := []string{"分析", "计算", "占比", "导出", "统计", "处理", "汇总", "比例", "生成报告", "报表", "analyze", "calculate", "percentage", "ratio", "export", "summary", "report", "group"} + for _, kw := range keywords { + if strings.Contains(p, kw) { + return true + } + } + return false +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -209,6 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.initialPrompt != "" { prompt := m.initialPrompt m.initialPrompt = "" + m.lastUserPrompt = prompt userLine := UserTagStyle.Render("👤 YOU") + " " + prompt m.messages = append(m.messages, userLine) m.state = StateThinking @@ -309,6 +323,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { formatted := FormatTableResult(msg.result, 0, 0, m.width, true) m.messages = append(m.messages, formatted) + + // Auto-chain: Check if user prompt requests post-query data analysis or JS computation + if shouldTriggerJSAnalysis(m.lastUserPrompt) { + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.generateSQLCmd(m.lastUserPrompt) + } } m.state = StateIdle m.viewport.SetContent(strings.Join(m.messages, "\n\n")) @@ -449,6 +471,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: prompt := strings.TrimSpace(m.textarea.Value()) if prompt != "" && m.state == StateIdle { + m.lastUserPrompt = prompt userLine := UserTagStyle.Render("👤 YOU") + " " + prompt m.messages = append(m.messages, userLine) m.textarea.Reset() From a82230f7d2b7330f9d762944f9dc7f1caafb51d9 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:43:52 +0800 Subject: [PATCH 40/75] fix: unescape stringified JSON in JS engine and collapse long JS code display in TUI --- internal/ai/prompt.go | 2 +- internal/js/engine.go | 36 ++++++++++++++++++++++++++++-------- internal/tui/model.go | 5 +++-- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 6042586..547c98b 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -20,7 +20,7 @@ AVAILABLE TOOLS: - "sql": the generated SQL query (e.g. "SELECT * FROM users WHERE active = true;") - "explanation": a concise explanation of what the query does. 2. 'execute_javascript': Call this when the user asks for post-query data analysis, percentage calculations, cross-dataset joins/comparisons, or structured formatting. - - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). Must be ES5 standard syntax. + - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). Must be ES5 standard syntax. Return a clean JS object or formatted string. Do NOT wrap return values in JSON.stringify() with string escaping. - "explanation": explanation of what the JavaScript script processes. IMPORTANT RULES: diff --git a/internal/js/engine.go b/internal/js/engine.go index 8646965..a39b34f 100644 --- a/internal/js/engine.go +++ b/internal/js/engine.go @@ -116,17 +116,37 @@ func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.Se } exported := val.Export() - jsonBytes, jsonErr := json.MarshalIndent(exported, "", " ") - jsonStr := "" - if jsonErr == nil { - jsonStr = string(jsonBytes) - } else { - jsonStr = fmt.Sprintf("%v", exported) + var jsonStr string + var summaryText string + + if str, ok := exported.(string); ok { + var parsed any + if json.Unmarshal([]byte(str), &parsed) == nil { + if b, err := json.MarshalIndent(parsed, "", " "); err == nil { + jsonStr = string(b) + } else { + jsonStr = str + } + } else { + jsonStr = str + } + summaryText = jsonStr + } else if exported != nil { + if b, err := json.MarshalIndent(exported, "", " "); err == nil { + jsonStr = string(b) + } else { + jsonStr = fmt.Sprintf("%v", exported) + } + summaryText = jsonStr } - summaryText := jsonStr if len(logs) > 0 { - summaryText = strings.Join(logs, "\n") + "\n\nReturn Value:\n" + jsonStr + consoleLogs := strings.Join(logs, "\n") + if summaryText == "" || summaryText == "(null)" { + summaryText = consoleLogs + } else { + summaryText = consoleLogs + "\n\n" + summaryText + } } return &ExecutionResult{ diff --git a/internal/tui/model.go b/internal/tui/model.go index 376f661..c4a9371 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -244,7 +244,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.response.Type == ai.TypeJS && msg.response.JSCode != "" { // Execute JS script using goja on active session datasets - jsExecLine := ExecutingTagStyle.Render("⚡ JS Analysis") + " " + SQLCodeStyle.Render(msg.response.JSCode) + lineCount := len(strings.Split(msg.response.JSCode, "\n")) + jsExecLine := ExecutingTagStyle.Render("⚡ JS Analysis") + " " + MetricsStyle.Render(fmt.Sprintf("Executing %d lines of JavaScript data processing script...", lineCount)) m.messages = append(m.messages, jsExecLine) ctx := context.Background() @@ -265,7 +266,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.jsRetryCount = 0 } else { m.jsRetryCount = 0 - resultMsg := SuccessBadgeStyle.Render("📊 JS Result:") + "\n" + AIResponseStyle.Render(jsRes.SummaryText) + resultMsg := SuccessBadgeStyle.Render("📊 Analysis Report:") + "\n" + AIResponseStyle.Render(jsRes.SummaryText) m.messages = append(m.messages, resultMsg) } m.state = StateIdle From e48f691501c36746e55cc3448215c3e3e19e956d Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:45:24 +0800 Subject: [PATCH 41/75] feat: synthesize JS computation results into human-readable Markdown AI analysis report --- internal/tui/model.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index c4a9371..a96d518 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -264,12 +264,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("❌ JS Execution Error (after %d retries): %v", m.maxJSRetries, jsErr.Message))) m.jsRetryCount = 0 + m.state = StateIdle } else { m.jsRetryCount = 0 - resultMsg := SuccessBadgeStyle.Render("📊 Analysis Report:") + "\n" + AIResponseStyle.Render(jsRes.SummaryText) - m.messages = append(m.messages, resultMsg) + // Automatically ask AI to format the computed JS summary into a clean, human-readable Markdown analysis report + if jsRes != nil && jsRes.SummaryText != "" { + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + + reportPrompt := fmt.Sprintf("The JavaScript data calculation produced the following computed result:\n%s\n\nPlease synthesize this computed result into a clear, professional, beautifully formatted Markdown analysis report for the user.", jsRes.SummaryText) + return m, m.generateSQLCmd(reportPrompt) + } + m.state = StateIdle } - m.state = StateIdle } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { m.currentSQL = msg.response.SQL if m.autoExecute { From 1a24823be46d804a8e6b9777b9c23a9b1356543b Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:46:42 +0800 Subject: [PATCH 42/75] refactor: enforce Agent Loop Invariant ensuring final turn is always LLM text response --- internal/ai/prompt.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 547c98b..7ebbf09 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -27,7 +27,8 @@ IMPORTANT RULES: 1. Default to READ-ONLY SELECT queries for database execution. 2. Avoid full table scans without limits or filters whenever possible. 3. When post-processing or joining previously queried datasets (e.g. 'res1', 'res2'), prefer calling 'execute_javascript' to compute results locally. -4. JAVASCRIPT ENVIRONMENT SPECIFICATION: The execution environment is strict ES5 (ECMAScript 5.1). Do NOT use ES6+ features such as String.prototype.repeat, Object.entries, Object.values, Arrow functions, let/const, or async/await. Always use standard ES5 syntax (e.g., var, function(), standard for loops, Object.keys()).` +4. JAVASCRIPT ENVIRONMENT SPECIFICATION: The execution environment is strict ES5 (ECMAScript 5.1). Do NOT use ES6+ features such as String.prototype.repeat, Object.entries, Object.values, Arrow functions, let/const, or async/await. Always use standard ES5 syntax (e.g., var, function(), standard for loops, Object.keys()). +5. AGENT LOOP INVARIANT: Like standard AI Agent loops, the final response of an interaction turn MUST ALWAYS be a natural language / Markdown text report explaining the findings and insights clearly to the user (never end on a tool call or raw JSON string).` func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { schemaJSON := "{}" From 85e7e4b2215cd73cbacd6e6a91f221656f9ac8f4 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:48:28 +0800 Subject: [PATCH 43/75] refactor: implement generic ReAct Agent Loop driven by chat history and tool feedback --- internal/ai/service.go | 4 ++ internal/tui/model.go | 129 +++++++++++++++++++++++++------------ internal/tui/model_test.go | 21 +++++- 3 files changed, 109 insertions(+), 45 deletions(-) diff --git a/internal/ai/service.go b/internal/ai/service.go index 5c65e33..a5f7048 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -52,3 +52,7 @@ func (s *Service) GenerateResponse(ctx context.Context, userPrompt string, schem return s.client.ChatCompletion(ctx, messages) } + +func (s *Service) ChatCompletion(ctx context.Context, messages []ChatMessage) (*AIResponse, *errors.XError) { + return s.client.ChatCompletion(ctx, messages) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index a96d518..2f0491a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -65,11 +65,11 @@ type Model struct { profileName string unsafeAllowWrite bool initialPrompt string - lastUserPrompt string autoExecute bool sessionStore *session.SessionDataStore jsEngine *js.JSEngine + chatHistory []ai.ChatMessage jsRetryCount int maxJSRetries int @@ -113,10 +113,10 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi profileName: resolved.ProfileName, unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), - lastUserPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, sessionStore: session.NewSessionDataStore(), jsEngine: js.NewJSEngine(1 * time.Minute), + chatHistory: []ai.ChatMessage{}, jsRetryCount: 0, maxJSRetries: 3, tableStates: []TableState{}, @@ -150,15 +150,31 @@ func (m Model) loadSchemaCmd() tea.Cmd { } } -func (m Model) generateSQLCmd(prompt string) tea.Cmd { +func (m Model) runAgentStepCmd() tea.Cmd { return func() tea.Msg { ctx := context.Background() + // Inject updated catalog into system prompt if needed catalog := m.sessionStore.GetCatalog() - resp, xe := m.aiService.GenerateResponse(ctx, prompt, m.schemaInfo, m.profile.DB, catalog) + sysPrompt := ai.BuildSystemPrompt(m.profile.DB, m.schemaInfo, catalog) + + // Ensure system prompt is up to date in history + msgs := make([]ai.ChatMessage, 0, len(m.chatHistory)+1) + msgs = append(msgs, ai.ChatMessage{Role: "system", Content: sysPrompt}) + for _, item := range m.chatHistory { + if item.Role != "system" { + msgs = append(msgs, item) + } + } + + resp, xe := m.aiService.ChatCompletion(ctx, msgs) return aiResponseMsg{response: resp, err: xe} } } +func (m Model) generateSQLCmd(prompt string) tea.Cmd { + return m.runAgentStepCmd() +} + func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { return func() tea.Msg { start := time.Now() @@ -191,17 +207,6 @@ func (m *Model) renderTableState(idx int, isActive bool) { m.viewport.SetContent(strings.Join(m.messages, "\n\n")) } -func shouldTriggerJSAnalysis(prompt string) bool { - p := strings.ToLower(prompt) - keywords := []string{"分析", "计算", "占比", "导出", "统计", "处理", "汇总", "比例", "生成报告", "报表", "analyze", "calculate", "percentage", "ratio", "export", "summary", "report", "group"} - for _, kw := range keywords { - if strings.Contains(p, kw) { - return true - } - } - return false -} - func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -222,13 +227,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.initialPrompt != "" { prompt := m.initialPrompt m.initialPrompt = "" - m.lastUserPrompt = prompt userLine := UserTagStyle.Render("👤 YOU") + " " + prompt m.messages = append(m.messages, userLine) + m.chatHistory = append(m.chatHistory, ai.ChatMessage{Role: "user", Content: prompt}) m.state = StateThinking m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() - return m, m.generateSQLCmd(prompt) + return m, m.runAgentStepCmd() } m.state = StateIdle m.viewport.SetContent(strings.Join(m.messages, "\n\n")) @@ -239,11 +244,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = StateIdle } else { m.explanation = msg.response.Explanation - aiMsg := AITagStyle.Render("🤖 AI") + " " + AIResponseStyle.Render(msg.response.Explanation) - m.messages = append(m.messages, aiMsg) if msg.response.Type == ai.TypeJS && msg.response.JSCode != "" { - // Execute JS script using goja on active session datasets + // Record Assistant Tool Call into chat history + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "assistant", + Content: fmt.Sprintf("Call tool 'execute_javascript':\n%s", msg.response.JSCode), + }) + lineCount := len(strings.Split(msg.response.JSCode, "\n")) jsExecLine := ExecutingTagStyle.Render("⚡ JS Analysis") + " " + MetricsStyle.Render(fmt.Sprintf("Executing %d lines of JavaScript data processing script...", lineCount)) m.messages = append(m.messages, jsExecLine) @@ -255,30 +263,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.jsRetryCount <= m.maxJSRetries { retryWarn := ErrorMsgStyle.Render(fmt.Sprintf("⚠️ JS Execution Failed (Attempt %d/%d): %v", m.jsRetryCount, m.maxJSRetries, jsErr.Message)) m.messages = append(m.messages, retryWarn) + + // Append tool error to chat history and loop back to Agent + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'execute_javascript' failed with error: %s. Please fix the code and call 'execute_javascript' again.", jsErr.Message), + }) + m.state = StateThinking m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() - - retryPrompt := fmt.Sprintf("The previous JavaScript code execution failed with error:\n%s\n\nPlease analyze the error, fix your JavaScript code, and call 'execute_javascript' again.", jsErr.Message) - return m, m.generateSQLCmd(retryPrompt) + return m, m.runAgentStepCmd() } m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("❌ JS Execution Error (after %d retries): %v", m.maxJSRetries, jsErr.Message))) m.jsRetryCount = 0 m.state = StateIdle } else { m.jsRetryCount = 0 - // Automatically ask AI to format the computed JS summary into a clean, human-readable Markdown analysis report - if jsRes != nil && jsRes.SummaryText != "" { - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - reportPrompt := fmt.Sprintf("The JavaScript data calculation produced the following computed result:\n%s\n\nPlease synthesize this computed result into a clear, professional, beautifully formatted Markdown analysis report for the user.", jsRes.SummaryText) - return m, m.generateSQLCmd(reportPrompt) - } - m.state = StateIdle + // Append tool success result to chat history and loop back to Agent + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'execute_javascript' executed successfully. Output:\n%s", jsRes.SummaryText), + }) + + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() } } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { + // Record Assistant Tool Call into chat history + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "assistant", + Content: fmt.Sprintf("Call tool 'execute_sql': %s", msg.response.SQL), + }) + m.currentSQL = msg.response.SQL if m.autoExecute { m.state = StateExecuting @@ -290,6 +310,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.state = StateSQLReady } else { + // FINAL LLM AGENT OUTPUT (No Tool Call) + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "assistant", + Content: msg.response.Explanation, + }) + + if msg.response.Explanation != "" { + aiMsg := AITagStyle.Render("🤖 AI") + " " + AIResponseStyle.Render(msg.response.Explanation) + m.messages = append(m.messages, aiMsg) + } m.state = StateIdle } } @@ -298,7 +328,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case queryExecutedMsg: if msg.err != nil { - m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message))) + errText := fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message) + m.messages = append(m.messages, ErrorMsgStyle.Render(errText)) + + // Append tool error to chat history and loop back to Agent + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'execute_sql' failed with error: %s", errText), + }) + + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() } else if msg.result != nil { // Save QueryResult into SessionDataStore and get assigned ID datasetID := m.sessionStore.Save(m.currentSQL, msg.result) @@ -333,13 +375,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { formatted := FormatTableResult(msg.result, 0, 0, m.width, true) m.messages = append(m.messages, formatted) - // Auto-chain: Check if user prompt requests post-query data analysis or JS computation - if shouldTriggerJSAnalysis(m.lastUserPrompt) { - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.generateSQLCmd(m.lastUserPrompt) - } + // Append tool success result to chat history and LOOP BACK to Agent + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'execute_sql' executed successfully. Returned %d rows (columns: %v). Dataset saved as '%s'.", len(msg.result.Rows), msg.result.Columns, datasetID), + }) + + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() } m.state = StateIdle m.viewport.SetContent(strings.Join(m.messages, "\n\n")) @@ -480,14 +525,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: prompt := strings.TrimSpace(m.textarea.Value()) if prompt != "" && m.state == StateIdle { - m.lastUserPrompt = prompt userLine := UserTagStyle.Render("👤 YOU") + " " + prompt m.messages = append(m.messages, userLine) + m.chatHistory = append(m.chatHistory, ai.ChatMessage{Role: "user", Content: prompt}) m.textarea.Reset() m.state = StateThinking m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() - return m, m.generateSQLCmd(prompt) + return m, m.runAgentStepCmd() } } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 6c6ea7b..f67b463 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -73,16 +73,31 @@ func TestTUI_Model_StateTransitions(t *testing.T) { t.Fatal("expected non-nil Cmd for executeSQLCmd") } - // 5. Send queryExecutedMsg -> transition to StateIdle - updated, _ = m.Update(queryExecutedMsg{ + // 5. Send queryExecutedMsg -> Agent loops back with runAgentStepCmd (StateThinking) + updated, cmd = m.Update(queryExecutedMsg{ result: &db.QueryResult{ Columns: []string{"id", "name"}, Rows: []map[string]any{{"id": 1, "name": "Alice"}}, }, }) m = updated.(Model) + if m.state != StateThinking { + t.Fatalf("expected state StateThinking while Agent processes tool result, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected non-nil Cmd for runAgentStepCmd after query execution") + } + + // 6. Send final aiResponseMsg (TypeText) -> transition to StateIdle + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeText, + Explanation: "Found 1 user named Alice.", + }, + }) + m = updated.(Model) if m.state != StateIdle { - t.Fatalf("expected state StateIdle after query executed, got %v", m.state) + t.Fatalf("expected state StateIdle after final text response, got %v", m.state) } // View output should contain query result From a2b27d62aa97f89856600b3a947ccea811c8719f Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:50:36 +0800 Subject: [PATCH 44/75] feat: implement collapsible tool call items with toggle interaction in TUI --- internal/tui/model.go | 108 ++++++++++++++++++++++++++++++++++++----- internal/tui/styles.go | 19 ++++++++ 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 2f0491a..513fed0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -58,6 +58,16 @@ type TableState struct { VerticalView bool } +type ToolCallItem struct { + ID string + Name string + Summary string + Detail string + Result string + MsgIndex int + IsExpanded bool +} + type Model struct { opts config.Options aiService *ai.Service @@ -79,6 +89,7 @@ type Model struct { explanation string messages []string tableStates []TableState + toolCalls []ToolCallItem activeTable int textarea textarea.Model @@ -120,6 +131,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi jsRetryCount: 0, maxJSRetries: 3, tableStates: []TableState{}, + toolCalls: []ToolCallItem{}, activeTable: -1, state: StateLoadingSchema, textarea: ta, @@ -153,11 +165,9 @@ func (m Model) loadSchemaCmd() tea.Cmd { func (m Model) runAgentStepCmd() tea.Cmd { return func() tea.Msg { ctx := context.Background() - // Inject updated catalog into system prompt if needed catalog := m.sessionStore.GetCatalog() sysPrompt := ai.BuildSystemPrompt(m.profile.DB, m.schemaInfo, catalog) - // Ensure system prompt is up to date in history msgs := make([]ai.ChatMessage, 0, len(m.chatHistory)+1) msgs = append(msgs, ai.ChatMessage{Role: "system", Content: sysPrompt}) for _, item := range m.chatHistory { @@ -207,6 +217,32 @@ func (m *Model) renderTableState(idx int, isActive bool) { m.viewport.SetContent(strings.Join(m.messages, "\n\n")) } +func (m *Model) renderToolCall(idx int) { + if idx < 0 || idx >= len(m.toolCalls) { + return + } + tc := &m.toolCalls[idx] + if tc.MsgIndex < 0 || tc.MsgIndex >= len(m.messages) { + return + } + + var sb strings.Builder + if !tc.IsExpanded { + badge := ToolCollapsedBadge.Render("▶ 🛠️ Tool: " + tc.Name) + summary := MetricsStyle.Render(fmt.Sprintf("%s (Folded - Press Ctrl+O to unfold)", tc.Summary)) + sb.WriteString(fmt.Sprintf("%s %s", badge, summary)) + } else { + badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name) + summary := SQLCodeStyle.Render(tc.Summary) + detail := ToolDetailStyle.Render(tc.Detail) + resText := MetricsStyle.Render(tc.Result) + sb.WriteString(fmt.Sprintf("%s %s\n%s\n%s", badge, summary, detail, resText)) + } + + m.messages[tc.MsgIndex] = sb.String() + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -253,18 +289,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) lineCount := len(strings.Split(msg.response.JSCode, "\n")) - jsExecLine := ExecutingTagStyle.Render("⚡ JS Analysis") + " " + MetricsStyle.Render(fmt.Sprintf("Executing %d lines of JavaScript data processing script...", lineCount)) - m.messages = append(m.messages, jsExecLine) + tc := ToolCallItem{ + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "execute_javascript", + Summary: fmt.Sprintf("Executing %d lines of JS data analysis", lineCount), + Detail: msg.response.JSCode, + MsgIndex: len(m.messages), + IsExpanded: false, // Folded by default! + } + + m.messages = append(m.messages, "") + m.toolCalls = append(m.toolCalls, tc) + toolIdx := len(m.toolCalls) - 1 ctx := context.Background() jsRes, jsErr := m.jsEngine.Execute(ctx, msg.response.JSCode, m.sessionStore) if jsErr != nil { m.jsRetryCount++ + m.toolCalls[toolIdx].Result = fmt.Sprintf("❌ Failed: %v", jsErr.Message) + m.renderToolCall(toolIdx) + if m.jsRetryCount <= m.maxJSRetries { retryWarn := ErrorMsgStyle.Render(fmt.Sprintf("⚠️ JS Execution Failed (Attempt %d/%d): %v", m.jsRetryCount, m.maxJSRetries, jsErr.Message)) m.messages = append(m.messages, retryWarn) - // Append tool error to chat history and loop back to Agent m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", Content: fmt.Sprintf("Tool 'execute_javascript' failed with error: %s. Please fix the code and call 'execute_javascript' again.", jsErr.Message), @@ -280,8 +328,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = StateIdle } else { m.jsRetryCount = 0 + m.toolCalls[toolIdx].Result = "✓ JavaScript executed successfully" + m.renderToolCall(toolIdx) - // Append tool success result to chat history and loop back to Agent m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", Content: fmt.Sprintf("Tool 'execute_javascript' executed successfully. Output:\n%s", jsRes.SummaryText), @@ -293,13 +342,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runAgentStepCmd() } } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { - // Record Assistant Tool Call into chat history m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", Content: fmt.Sprintf("Call tool 'execute_sql': %s", msg.response.SQL), }) m.currentSQL = msg.response.SQL + + tc := ToolCallItem{ + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "execute_sql", + Summary: m.currentSQL, + Detail: m.currentSQL, + MsgIndex: len(m.messages), + IsExpanded: false, // Folded by default! + } + m.messages = append(m.messages, "") + m.toolCalls = append(m.toolCalls, tc) + toolIdx := len(m.toolCalls) - 1 + m.renderToolCall(toolIdx) + if m.autoExecute { m.state = StateExecuting execLine := ExecutingTagStyle.Render("⚡ Auto-Executing") + " " + SQLCodeStyle.Render(m.currentSQL) @@ -331,7 +393,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { errText := fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message) m.messages = append(m.messages, ErrorMsgStyle.Render(errText)) - // Append tool error to chat history and loop back to Agent m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", Content: fmt.Sprintf("Tool 'execute_sql' failed with error: %s", errText), @@ -342,7 +403,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, m.runAgentStepCmd() } else if msg.result != nil { - // Save QueryResult into SessionDataStore and get assigned ID datasetID := m.sessionStore.Save(m.currentSQL, msg.result) modelName := m.opts.CLIAIModel @@ -357,6 +417,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) m.messages = append(m.messages, statusLine) + // Update latest SQL ToolCallItem result + if len(m.toolCalls) > 0 { + lastIdx := len(m.toolCalls) - 1 + if m.toolCalls[lastIdx].Name == "execute_sql" { + m.toolCalls[lastIdx].Result = statusLine + m.renderToolCall(lastIdx) + } + } + // Remove focus from previous active table if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { m.renderTableState(m.activeTable, false) @@ -375,7 +444,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { formatted := FormatTableResult(msg.result, 0, 0, m.width, true) m.messages = append(m.messages, formatted) - // Append tool success result to chat history and LOOP BACK to Agent m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", Content: fmt.Sprintf("Tool 'execute_sql' executed successfully. Returned %d rows (columns: %v). Dataset saved as '%s'.", len(msg.result.Rows), msg.result.Columns, datasetID), @@ -453,6 +521,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEsc: return m, tea.Quit + case tea.KeyCtrlO: + // Toggle folding/unfolding of all tool calls or the latest tool call + if len(m.toolCalls) > 0 { + lastIdx := len(m.toolCalls) - 1 + m.toolCalls[lastIdx].IsExpanded = !m.toolCalls[lastIdx].IsExpanded + m.renderToolCall(lastIdx) + return m, nil + } + case tea.KeyCtrlE: if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { ts := &m.tableStates[m.activeTable] @@ -587,7 +664,7 @@ func (m Model) View() string { case StateLoadingSchema: sb.WriteString(m.spinner.View() + " Loading database schema...\n") case StateThinking: - sb.WriteString(m.spinner.View() + " AI is analyzing schema and generating SQL...\n") + sb.WriteString(m.spinner.View() + " AI is analyzing schema and executing tools...\n") case StateExecuting: sb.WriteString(m.spinner.View() + " Executing SQL query...\n") case StateSQLReady: @@ -612,6 +689,11 @@ func (m Model) View() string { execModeHint = "AUTO" } + toolFoldState := "Folded" + if len(m.toolCalls) > 0 && m.toolCalls[len(m.toolCalls)-1].IsExpanded { + toolFoldState = "Unfolded" + } + var keybindings string if m.state == StateSQLReady { keybindings = renderKeybindingBadges([][2]string{ @@ -619,6 +701,7 @@ func (m Model) View() string { {"e", "Edit SQL"}, {"Esc", "Cancel"}, {"Shift+Tab", "Mode (" + execModeHint + ")"}, + {"Ctrl+O", "Tool Details (" + toolFoldState + ")"}, }) } else { keybindings = renderKeybindingBadges([][2]string{ @@ -626,7 +709,8 @@ func (m Model) View() string { {"Tab", "Focus Table"}, {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, - {"Ctrl+E", "Expand"}, + {"Ctrl+E", "Expand Table"}, + {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"Shift+Tab", "Mode (" + execModeHint + ")"}, {"Esc", "Quit"}, }) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 9b71cdc..4e55a83 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -120,4 +120,23 @@ var ( PromptPrefixStyle = lipgloss.NewStyle(). Bold(true). Foreground(PrimaryColor) + + // Collapsible Tool Call Badges + ToolCollapsedBadge = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "#475569", Dark: "#94A3B8"}). + Background(lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#1E293B"}). + Padding(0, 1) + + ToolExpandedBadge = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(WarningColor). + Padding(0, 1) + + ToolDetailStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(MutedColor). + PaddingLeft(1). + Foreground(MutedColor) ) From 63747d3e0dc6caae9f3a7bee104a33ee600cc5cb Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:52:32 +0800 Subject: [PATCH 45/75] feat: implement render_table Tool Call empowering AI to control UI table rendering --- internal/ai/client.go | 41 +++++++++++++++++++++++++- internal/ai/prompt.go | 6 +++- internal/ai/service.go | 9 ++++-- internal/tui/model.go | 65 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/internal/ai/client.go b/internal/ai/client.go index 56d2051..4a937e4 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -101,6 +101,31 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A }, } + tableToolDef := openai.ChatCompletionToolParam{ + Function: shared.FunctionDefinitionParam{ + Name: "render_table", + Description: openai.String("Render a cached session dataset (e.g. res1, res2) as an interactive TUI table widget for the user."), + Parameters: shared.FunctionParameters{ + "type": "object", + "properties": map[string]interface{}{ + "dataset_id": map[string]interface{}{ + "type": "string", + "description": "The session dataset ID to render as interactive TUI table (e.g., 'res1').", + }, + "title": map[string]interface{}{ + "type": "string", + "description": "Optional title for the table component.", + }, + "explanation": map[string]interface{}{ + "type": "string", + "description": "Explanation of the table being rendered.", + }, + }, + "required": []string{"dataset_id", "explanation"}, + }, + }, + } + model := c.cfg.Model if model == "" { model = "gpt-4o" @@ -109,7 +134,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A params := openai.ChatCompletionNewParams{ Model: shared.ChatModel(model), Messages: sdkMessages, - Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef}, + Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, tableToolDef}, } if c.cfg.MaxTokens > 0 { params.MaxTokens = openai.Int(int64(c.cfg.MaxTokens)) @@ -154,6 +179,20 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A Explanation: strings.TrimSpace(raw.Explanation), }, nil } + } else if toolCall.Function.Name == "render_table" { + var raw struct { + DatasetID string `json:"dataset_id"` + Title string `json:"title"` + Explanation string `json:"explanation"` + } + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err == nil { + return &AIResponse{ + Type: TypeTable, + DatasetID: strings.TrimSpace(raw.DatasetID), + Title: strings.TrimSpace(raw.Title), + Explanation: strings.TrimSpace(raw.Explanation), + }, nil + } } } diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 7ebbf09..0bff7c5 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -8,7 +8,7 @@ import ( ) const SystemPromptTemplate = `You are an expert AI SQL generator and Data Analyst for the %s database. -Your job is to convert natural language requests into correct, efficient SQL queries or JavaScript data analysis scripts. +Your job is to convert natural language requests into correct, efficient SQL queries, JavaScript data analysis scripts, or TUI table rendering tools. DATABASE SCHEMA: %s @@ -22,6 +22,10 @@ AVAILABLE TOOLS: 2. 'execute_javascript': Call this when the user asks for post-query data analysis, percentage calculations, cross-dataset joins/comparisons, or structured formatting. - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). Must be ES5 standard syntax. Return a clean JS object or formatted string. Do NOT wrap return values in JSON.stringify() with string escaping. - "explanation": explanation of what the JavaScript script processes. +3. 'render_table': Call this tool to render a cached session dataset (e.g. 'res1', 'res2') as an interactive TUI table widget for the user. + - "dataset_id": dataset ID from catalog to render (e.g. 'res1'). + - "title": optional title for table widget. + - "explanation": explanation of the table view. IMPORTANT RULES: 1. Default to READ-ONLY SELECT queries for database execution. diff --git a/internal/ai/service.go b/internal/ai/service.go index a5f7048..5452a41 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -11,15 +11,18 @@ import ( type ResponseType string const ( - TypeSQL ResponseType = "sql" - TypeJS ResponseType = "js" - TypeText ResponseType = "text" + TypeSQL ResponseType = "sql" + TypeJS ResponseType = "js" + TypeTable ResponseType = "table" + TypeText ResponseType = "text" ) type AIResponse struct { Type ResponseType `json:"type"` SQL string `json:"sql,omitempty"` JSCode string `json:"js_code,omitempty"` + DatasetID string `json:"dataset_id,omitempty"` + Title string `json:"title,omitempty"` Explanation string `json:"explanation"` } diff --git a/internal/tui/model.go b/internal/tui/model.go index 513fed0..006949b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -341,6 +341,68 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, m.runAgentStepCmd() } + } else if msg.response.Type == ai.TypeTable && msg.response.DatasetID != "" { + // Record Assistant Tool Call into chat history + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "assistant", + Content: fmt.Sprintf("Call tool 'render_table': dataset_id=%s, title=%s", msg.response.DatasetID, msg.response.Title), + }) + + datasetRes, exists := m.sessionStore.Get(msg.response.DatasetID) + if !exists || datasetRes == nil { + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("❌ Tool render_table failed: dataset '%s' not found", msg.response.DatasetID))) + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'render_table' failed: dataset '%s' not found in session catalog.", msg.response.DatasetID), + }) + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() + } + + // Render table component in TUI + tc := ToolCallItem{ + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "render_table", + Summary: fmt.Sprintf("Rendered interactive table view for %s (%d rows)", msg.response.DatasetID, len(datasetRes.Rows)), + Detail: fmt.Sprintf("Dataset: %s | Title: %s", msg.response.DatasetID, msg.response.Title), + Result: fmt.Sprintf("✓ Table rendered (%d rows)", len(datasetRes.Rows)), + MsgIndex: len(m.messages), + IsExpanded: false, + } + m.messages = append(m.messages, "") + m.toolCalls = append(m.toolCalls, tc) + toolIdx := len(m.toolCalls) - 1 + m.renderToolCall(toolIdx) + + // Remove focus from previous active table + if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { + m.renderTableState(m.activeTable, false) + } + + ts := TableState{ + Result: datasetRes, + MsgIndex: len(m.messages), + ColOffset: 0, + RowOffset: 0, + VerticalView: false, + } + m.tableStates = append(m.tableStates, ts) + m.activeTable = len(m.tableStates) - 1 + + formatted := FormatTableResult(datasetRes, 0, 0, m.width, true) + m.messages = append(m.messages, formatted) + + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'render_table' executed successfully. Interactive table view for dataset '%s' rendered for user.", msg.response.DatasetID), + }) + + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", @@ -426,7 +488,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Remove focus from previous active table + // Auto-render interactive table widget for the dataset if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { m.renderTableState(m.activeTable, false) } @@ -522,7 +584,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyCtrlO: - // Toggle folding/unfolding of all tool calls or the latest tool call if len(m.toolCalls) > 0 { lastIdx := len(m.toolCalls) - 1 m.toolCalls[lastIdx].IsExpanded = !m.toolCalls[lastIdx].IsExpanded From 77b56447c689a837536b33157abeae8f0e6de1b3 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:53:51 +0800 Subject: [PATCH 46/75] feat: add export_data Tool Call with human-in-the-loop interactive confirmation --- internal/ai/client.go | 47 ++++++++++++++- internal/ai/prompt.go | 7 ++- internal/ai/service.go | 11 ++-- internal/tui/model.go | 131 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 175 insertions(+), 21 deletions(-) diff --git a/internal/ai/client.go b/internal/ai/client.go index 4a937e4..5f9794a 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -126,6 +126,35 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A }, } + exportToolDef := openai.ChatCompletionToolParam{ + Function: shared.FunctionDefinitionParam{ + Name: "export_data", + Description: openai.String("Export a cached session dataset (e.g. res1, res2) to a local file in CSV, JSON, or Markdown format after human confirmation."), + Parameters: shared.FunctionParameters{ + "type": "object", + "properties": map[string]interface{}{ + "dataset_id": map[string]interface{}{ + "type": "string", + "description": "The dataset ID from session catalog to export (e.g. 'res1').", + }, + "format": map[string]interface{}{ + "type": "string", + "description": "Export file format: 'csv', 'json', or 'markdown'.", + }, + "filepath": map[string]interface{}{ + "type": "string", + "description": "Target file path (e.g. 'result.csv', 'report.json').", + }, + "explanation": map[string]interface{}{ + "type": "string", + "description": "Explanation of the data being exported.", + }, + }, + "required": []string{"dataset_id", "format", "filepath", "explanation"}, + }, + }, + } + model := c.cfg.Model if model == "" { model = "gpt-4o" @@ -134,7 +163,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A params := openai.ChatCompletionNewParams{ Model: shared.ChatModel(model), Messages: sdkMessages, - Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, tableToolDef}, + Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, tableToolDef, exportToolDef}, } if c.cfg.MaxTokens > 0 { params.MaxTokens = openai.Int(int64(c.cfg.MaxTokens)) @@ -193,6 +222,22 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A Explanation: strings.TrimSpace(raw.Explanation), }, nil } + } else if toolCall.Function.Name == "export_data" { + var raw struct { + DatasetID string `json:"dataset_id"` + Format string `json:"format"` + FilePath string `json:"filepath"` + Explanation string `json:"explanation"` + } + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err == nil { + return &AIResponse{ + Type: TypeExport, + DatasetID: strings.TrimSpace(raw.DatasetID), + Format: strings.TrimSpace(raw.Format), + FilePath: strings.TrimSpace(raw.FilePath), + Explanation: strings.TrimSpace(raw.Explanation), + }, nil + } } } diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 0bff7c5..452839b 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -8,7 +8,7 @@ import ( ) const SystemPromptTemplate = `You are an expert AI SQL generator and Data Analyst for the %s database. -Your job is to convert natural language requests into correct, efficient SQL queries, JavaScript data analysis scripts, or TUI table rendering tools. +Your job is to convert natural language requests into correct, efficient SQL queries, JavaScript data analysis scripts, TUI table widgets, or file export requests. DATABASE SCHEMA: %s @@ -26,6 +26,11 @@ AVAILABLE TOOLS: - "dataset_id": dataset ID from catalog to render (e.g. 'res1'). - "title": optional title for table widget. - "explanation": explanation of the table view. +4. 'export_data': Call this tool when the user requests exporting a dataset to a local file. This tool requires human-in-the-loop interactive confirmation. + - "dataset_id": dataset ID from catalog to export (e.g. 'res1'). + - "format": file format ('csv', 'json', or 'markdown'). + - "filepath": target output filename (e.g. 'servers.csv'). + - "explanation": explanation of what is being exported. IMPORTANT RULES: 1. Default to READ-ONLY SELECT queries for database execution. diff --git a/internal/ai/service.go b/internal/ai/service.go index 5452a41..b319677 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -11,10 +11,11 @@ import ( type ResponseType string const ( - TypeSQL ResponseType = "sql" - TypeJS ResponseType = "js" - TypeTable ResponseType = "table" - TypeText ResponseType = "text" + TypeSQL ResponseType = "sql" + TypeJS ResponseType = "js" + TypeTable ResponseType = "table" + TypeExport ResponseType = "export" + TypeText ResponseType = "text" ) type AIResponse struct { @@ -22,6 +23,8 @@ type AIResponse struct { SQL string `json:"sql,omitempty"` JSCode string `json:"js_code,omitempty"` DatasetID string `json:"dataset_id,omitempty"` + Format string `json:"format,omitempty"` + FilePath string `json:"filepath,omitempty"` Title string `json:"title,omitempty"` Explanation string `json:"explanation"` } diff --git a/internal/tui/model.go b/internal/tui/model.go index 006949b..c54f6a6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -17,6 +17,7 @@ import ( "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/db" "github.com/zx06/xsql/internal/errors" + "github.com/zx06/xsql/internal/export" "github.com/zx06/xsql/internal/js" "github.com/zx06/xsql/internal/session" ) @@ -29,6 +30,7 @@ const ( StateThinking StateSQLReady StateExecuting + StateExportReady ) // Msg types @@ -68,6 +70,13 @@ type ToolCallItem struct { IsExpanded bool } +type PendingExport struct { + DatasetID string + Format string + FilePath string + ToolIdx int +} + type Model struct { opts config.Options aiService *ai.Service @@ -77,11 +86,12 @@ type Model struct { initialPrompt string autoExecute bool - sessionStore *session.SessionDataStore - jsEngine *js.JSEngine - chatHistory []ai.ChatMessage - jsRetryCount int - maxJSRetries int + sessionStore *session.SessionDataStore + jsEngine *js.JSEngine + chatHistory []ai.ChatMessage + pendingExport *PendingExport + jsRetryCount int + maxJSRetries int state State schemaInfo *db.SchemaInfo @@ -282,7 +292,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.explanation = msg.response.Explanation if msg.response.Type == ai.TypeJS && msg.response.JSCode != "" { - // Record Assistant Tool Call into chat history m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", Content: fmt.Sprintf("Call tool 'execute_javascript':\n%s", msg.response.JSCode), @@ -295,7 +304,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Summary: fmt.Sprintf("Executing %d lines of JS data analysis", lineCount), Detail: msg.response.JSCode, MsgIndex: len(m.messages), - IsExpanded: false, // Folded by default! + IsExpanded: false, } m.messages = append(m.messages, "") @@ -342,7 +351,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runAgentStepCmd() } } else if msg.response.Type == ai.TypeTable && msg.response.DatasetID != "" { - // Record Assistant Tool Call into chat history m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", Content: fmt.Sprintf("Call tool 'render_table': dataset_id=%s, title=%s", msg.response.DatasetID, msg.response.Title), @@ -361,7 +369,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runAgentStepCmd() } - // Render table component in TUI tc := ToolCallItem{ ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), Name: "render_table", @@ -376,7 +383,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { toolIdx := len(m.toolCalls) - 1 m.renderToolCall(toolIdx) - // Remove focus from previous active table if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { m.renderTableState(m.activeTable, false) } @@ -403,6 +409,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.runAgentStepCmd() + } else if msg.response.Type == ai.TypeExport && msg.response.DatasetID != "" { + // Record Assistant Tool Call into chat history + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "assistant", + Content: fmt.Sprintf("Call tool 'export_data': dataset_id=%s, format=%s, filepath=%s", msg.response.DatasetID, msg.response.Format, msg.response.FilePath), + }) + + tc := ToolCallItem{ + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "export_data", + Summary: fmt.Sprintf("Export %s to %s (%s) [Pending User Confirmation]", msg.response.DatasetID, msg.response.FilePath, strings.ToUpper(msg.response.Format)), + Detail: fmt.Sprintf("Dataset: %s | FilePath: %s | Format: %s", msg.response.DatasetID, msg.response.FilePath, msg.response.Format), + Result: "⏳ Pending Human Confirmation", + MsgIndex: len(m.messages), + IsExpanded: false, + } + m.messages = append(m.messages, "") + m.toolCalls = append(m.toolCalls, tc) + toolIdx := len(m.toolCalls) - 1 + m.renderToolCall(toolIdx) + + m.pendingExport = &PendingExport{ + DatasetID: msg.response.DatasetID, + Format: msg.response.Format, + FilePath: msg.response.FilePath, + ToolIdx: toolIdx, + } + m.state = StateExportReady } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", @@ -417,7 +451,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Summary: m.currentSQL, Detail: m.currentSQL, MsgIndex: len(m.messages), - IsExpanded: false, // Folded by default! + IsExpanded: false, } m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) @@ -479,7 +513,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) m.messages = append(m.messages, statusLine) - // Update latest SQL ToolCallItem result if len(m.toolCalls) > 0 { lastIdx := len(m.toolCalls) - 1 if m.toolCalls[lastIdx].Name == "execute_sql" { @@ -488,7 +521,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Auto-render interactive table widget for the dataset if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { m.renderTableState(m.activeTable, false) } @@ -554,6 +586,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, taCmd } + if m.state == StateExportReady && m.pendingExport != nil { + switch msg.Type { + case tea.KeyEnter: + // Execute Export after human confirmation + datasetRes, exists := m.sessionStore.Get(m.pendingExport.DatasetID) + if !exists || datasetRes == nil { + m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: Dataset '%s' not found", m.pendingExport.DatasetID) + m.renderToolCall(m.pendingExport.ToolIdx) + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'export_data' failed: dataset '%s' not found in session catalog.", m.pendingExport.DatasetID), + }) + } else { + outPath, xe := export.ExportQueryResult(datasetRes, export.ExportFormat(m.pendingExport.Format), m.pendingExport.FilePath) + if xe != nil { + m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: %v", xe.Message) + m.renderToolCall(m.pendingExport.ToolIdx) + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'export_data' failed to write file: %v", xe.Message), + }) + } else { + m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("✓ Exported dataset '%s' to '%s' (%s)", m.pendingExport.DatasetID, outPath, strings.ToUpper(m.pendingExport.Format)) + m.renderToolCall(m.pendingExport.ToolIdx) + + statusLine := SuccessBadgeStyle.Render("✓ File Exported Success") + " " + MetricsStyle.Render(fmt.Sprintf("Exported dataset '%s' to '%s'", m.pendingExport.DatasetID, outPath)) + m.messages = append(m.messages, statusLine) + + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool 'export_data' executed successfully. Exported dataset '%s' to local file '%s'.", m.pendingExport.DatasetID, outPath), + }) + } + } + m.pendingExport = nil + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() + + case tea.KeyEsc: + // Export Denied by User + m.toolCalls[m.pendingExport.ToolIdx].Result = "🚫 Export Denied by User" + m.renderToolCall(m.pendingExport.ToolIdx) + + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: "Tool 'export_data' was denied by user.", + }) + m.pendingExport = nil + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() + } + return m, nil + } + if m.state == StateSQLReady { switch { case msg.Type == tea.KeyEnter: @@ -720,7 +810,7 @@ func (m Model) View() string { // 2. Main Viewport sb.WriteString(m.viewport.View() + "\n\n") - // 3. State Status & SQL Preview Box + // 3. State Status & SQL / Export Confirmation Box switch m.state { case StateLoadingSchema: sb.WriteString(m.spinner.View() + " Loading database schema...\n") @@ -728,6 +818,12 @@ func (m Model) View() string { sb.WriteString(m.spinner.View() + " AI is analyzing schema and executing tools...\n") case StateExecuting: sb.WriteString(m.spinner.View() + " Executing SQL query...\n") + case StateExportReady: + if m.pendingExport != nil { + exportInfo := fmt.Sprintf("Dataset: %s | Target: %s | Format: %s", m.pendingExport.DatasetID, m.pendingExport.FilePath, strings.ToUpper(m.pendingExport.Format)) + preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ File Export Approval Required (Enter: Confirm Export | Esc: Deny):"), SQLCodeStyle.Render(exportInfo)) + sb.WriteString(SQLBoxStyle.Width(m.width-4).Render(preview) + "\n") + } case StateSQLReady: sqlContent := SQLCodeStyle.Render(m.currentSQL) if m.currentSQL == "" { @@ -756,7 +852,12 @@ func (m Model) View() string { } var keybindings string - if m.state == StateSQLReady { + if m.state == StateExportReady { + keybindings = renderKeybindingBadges([][2]string{ + {"Enter", "Confirm Export"}, + {"Esc", "Deny Export"}, + }) + } else if m.state == StateSQLReady { keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Execute"}, {"e", "Edit SQL"}, From 02a1cc98b449fa4f2a947753f45a7a26b3f44932 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:56:26 +0800 Subject: [PATCH 47/75] fix: eliminate duplicate SQL status lines and embed execution metrics inside ToolCallItem --- internal/tui/model.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index c54f6a6..ac92163 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -410,7 +410,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, m.runAgentStepCmd() } else if msg.response.Type == ai.TypeExport && msg.response.DatasetID != "" { - // Record Assistant Tool Call into chat history m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", Content: fmt.Sprintf("Call tool 'export_data': dataset_id=%s, format=%s, filepath=%s", msg.response.DatasetID, msg.response.Format, msg.response.FilePath), @@ -460,8 +459,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.autoExecute { m.state = StateExecuting - execLine := ExecutingTagStyle.Render("⚡ Auto-Executing") + " " + SQLCodeStyle.Render(m.currentSQL) - m.messages = append(m.messages, execLine) m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) @@ -511,8 +508,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } metricsStr := fmt.Sprintf("⏱️ %s | 📊 %d rows | 🤖 %s | 💾 %s", durStr, len(msg.result.Rows), modelName, datasetID) statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) - m.messages = append(m.messages, statusLine) + // Update existing execute_sql ToolCallItem result with execution metrics (NO duplicate text line appended!) if len(m.toolCalls) > 0 { lastIdx := len(m.toolCalls) - 1 if m.toolCalls[lastIdx].Name == "execute_sql" { @@ -521,6 +518,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Render interactive table widget directly below the tool call item if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { m.renderTableState(m.activeTable, false) } @@ -589,7 +587,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == StateExportReady && m.pendingExport != nil { switch msg.Type { case tea.KeyEnter: - // Execute Export after human confirmation datasetRes, exists := m.sessionStore.Get(m.pendingExport.DatasetID) if !exists || datasetRes == nil { m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: Dataset '%s' not found", m.pendingExport.DatasetID) @@ -627,7 +624,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runAgentStepCmd() case tea.KeyEsc: - // Export Denied by User m.toolCalls[m.pendingExport.ToolIdx].Result = "🚫 Export Denied by User" m.renderToolCall(m.pendingExport.ToolIdx) @@ -649,8 +645,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case msg.Type == tea.KeyEnter: m.state = StateExecuting m.textarea.Focus() - execLine := ExecutingTagStyle.Render("⚡ Executing") + " " + SQLCodeStyle.Render(m.currentSQL) - m.messages = append(m.messages, execLine) m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) From e657dd4e0c5cafcf3a153842da62e82bd18be34a Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:57:55 +0800 Subject: [PATCH 48/75] feat: embed table widgets inside foldable tool call containers and add multi-tool call navigation --- internal/tui/model.go | 226 +++++++++++++++++++++++++++--------------- 1 file changed, 144 insertions(+), 82 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index ac92163..9e86f05 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -61,13 +61,14 @@ type TableState struct { } type ToolCallItem struct { - ID string - Name string - Summary string - Detail string - Result string - MsgIndex int - IsExpanded bool + ID string + Name string + Summary string + Detail string + Result string + TableStateIndex int // -1 if no table attached + MsgIndex int + IsExpanded bool } type PendingExport struct { @@ -93,14 +94,15 @@ type Model struct { jsRetryCount int maxJSRetries int - state State - schemaInfo *db.SchemaInfo - currentSQL string - explanation string - messages []string - tableStates []TableState - toolCalls []ToolCallItem - activeTable int + state State + schemaInfo *db.SchemaInfo + currentSQL string + explanation string + messages []string + tableStates []TableState + toolCalls []ToolCallItem + activeTable int + activeToolIdx int textarea textarea.Model viewport viewport.Model @@ -143,6 +145,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi tableStates: []TableState{}, toolCalls: []ToolCallItem{}, activeTable: -1, + activeToolIdx: -1, state: StateLoadingSchema, textarea: ta, viewport: vp, @@ -216,6 +219,15 @@ func (m *Model) renderTableState(idx int, isActive bool) { return } ts := &m.tableStates[idx] + + // Find associated ToolCallItem if embedded + for i := range m.toolCalls { + if m.toolCalls[i].TableStateIndex == idx { + m.renderToolCall(i) + return + } + } + if ts.MsgIndex < 0 || ts.MsgIndex >= len(m.messages) { return } @@ -236,17 +248,33 @@ func (m *Model) renderToolCall(idx int) { return } + isActiveTool := (idx == m.activeToolIdx) + activeMarker := "" + if isActiveTool && len(m.toolCalls) > 1 { + activeMarker = fmt.Sprintf(" 🎯[Tool %d/%d]", idx+1, len(m.toolCalls)) + } + var sb strings.Builder if !tc.IsExpanded { - badge := ToolCollapsedBadge.Render("▶ 🛠️ Tool: " + tc.Name) + badge := ToolCollapsedBadge.Render("▶ 🛠️ Tool: " + tc.Name + activeMarker) summary := MetricsStyle.Render(fmt.Sprintf("%s (Folded - Press Ctrl+O to unfold)", tc.Summary)) sb.WriteString(fmt.Sprintf("%s %s", badge, summary)) } else { - badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name) + badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name + activeMarker) summary := SQLCodeStyle.Render(tc.Summary) detail := ToolDetailStyle.Render(tc.Detail) resText := MetricsStyle.Render(tc.Result) sb.WriteString(fmt.Sprintf("%s %s\n%s\n%s", badge, summary, detail, resText)) + + // Render embedded Table Result inside container when unfolded + if tc.TableStateIndex >= 0 && tc.TableStateIndex < len(m.tableStates) { + ts := &m.tableStates[tc.TableStateIndex] + tableStr := FormatTableResult(ts.Result, ts.ColOffset, ts.RowOffset, m.width, tc.TableStateIndex == m.activeTable) + if ts.VerticalView { + tableStr = FormatVerticalResult(ts.Result) + } + sb.WriteString("\n\n" + tableStr) + } } m.messages[tc.MsgIndex] = sb.String() @@ -299,17 +327,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { lineCount := len(strings.Split(msg.response.JSCode, "\n")) tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "execute_javascript", - Summary: fmt.Sprintf("Executing %d lines of JS data analysis", lineCount), - Detail: msg.response.JSCode, - MsgIndex: len(m.messages), - IsExpanded: false, + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "execute_javascript", + Summary: fmt.Sprintf("Executing %d lines of JS data analysis", lineCount), + Detail: msg.response.JSCode, + TableStateIndex: -1, + MsgIndex: len(m.messages), + IsExpanded: false, } m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 + m.activeToolIdx = toolIdx ctx := context.Background() jsRes, jsErr := m.jsEngine.Execute(ctx, msg.response.JSCode, m.sessionStore) @@ -369,36 +399,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runAgentStepCmd() } - tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "render_table", - Summary: fmt.Sprintf("Rendered interactive table view for %s (%d rows)", msg.response.DatasetID, len(datasetRes.Rows)), - Detail: fmt.Sprintf("Dataset: %s | Title: %s", msg.response.DatasetID, msg.response.Title), - Result: fmt.Sprintf("✓ Table rendered (%d rows)", len(datasetRes.Rows)), - MsgIndex: len(m.messages), - IsExpanded: false, - } - m.messages = append(m.messages, "") - m.toolCalls = append(m.toolCalls, tc) - toolIdx := len(m.toolCalls) - 1 - m.renderToolCall(toolIdx) - - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - m.renderTableState(m.activeTable, false) - } - ts := TableState{ Result: datasetRes, - MsgIndex: len(m.messages), + MsgIndex: -1, ColOffset: 0, RowOffset: 0, VerticalView: false, } m.tableStates = append(m.tableStates, ts) - m.activeTable = len(m.tableStates) - 1 + tableIdx := len(m.tableStates) - 1 + m.activeTable = tableIdx - formatted := FormatTableResult(datasetRes, 0, 0, m.width, true) - m.messages = append(m.messages, formatted) + tc := ToolCallItem{ + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "render_table", + Summary: fmt.Sprintf("Rendered interactive table view for %s (%d rows)", msg.response.DatasetID, len(datasetRes.Rows)), + Detail: fmt.Sprintf("Dataset: %s | Title: %s", msg.response.DatasetID, msg.response.Title), + Result: fmt.Sprintf("✓ Table rendered (%d rows)", len(datasetRes.Rows)), + TableStateIndex: tableIdx, + MsgIndex: len(m.messages), + IsExpanded: false, + } + m.messages = append(m.messages, "") + m.toolCalls = append(m.toolCalls, tc) + toolIdx := len(m.toolCalls) - 1 + m.activeToolIdx = toolIdx + m.renderToolCall(toolIdx) m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", @@ -416,17 +442,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "export_data", - Summary: fmt.Sprintf("Export %s to %s (%s) [Pending User Confirmation]", msg.response.DatasetID, msg.response.FilePath, strings.ToUpper(msg.response.Format)), - Detail: fmt.Sprintf("Dataset: %s | FilePath: %s | Format: %s", msg.response.DatasetID, msg.response.FilePath, msg.response.Format), - Result: "⏳ Pending Human Confirmation", - MsgIndex: len(m.messages), - IsExpanded: false, + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "export_data", + Summary: fmt.Sprintf("Export %s to %s (%s) [Pending User Confirmation]", msg.response.DatasetID, msg.response.FilePath, strings.ToUpper(msg.response.Format)), + Detail: fmt.Sprintf("Dataset: %s | FilePath: %s | Format: %s", msg.response.DatasetID, msg.response.FilePath, msg.response.Format), + Result: "⏳ Pending Human Confirmation", + TableStateIndex: -1, + MsgIndex: len(m.messages), + IsExpanded: false, } m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 + m.activeToolIdx = toolIdx m.renderToolCall(toolIdx) m.pendingExport = &PendingExport{ @@ -445,16 +473,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentSQL = msg.response.SQL tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "execute_sql", - Summary: m.currentSQL, - Detail: m.currentSQL, - MsgIndex: len(m.messages), - IsExpanded: false, + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "execute_sql", + Summary: m.currentSQL, + Detail: m.currentSQL, + TableStateIndex: -1, + MsgIndex: len(m.messages), + IsExpanded: false, } m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 + m.activeToolIdx = toolIdx m.renderToolCall(toolIdx) if m.autoExecute { @@ -509,32 +539,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { metricsStr := fmt.Sprintf("⏱️ %s | 📊 %d rows | 🤖 %s | 💾 %s", durStr, len(msg.result.Rows), modelName, datasetID) statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) - // Update existing execute_sql ToolCallItem result with execution metrics (NO duplicate text line appended!) - if len(m.toolCalls) > 0 { - lastIdx := len(m.toolCalls) - 1 - if m.toolCalls[lastIdx].Name == "execute_sql" { - m.toolCalls[lastIdx].Result = statusLine - m.renderToolCall(lastIdx) - } - } - - // Render interactive table widget directly below the tool call item - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - m.renderTableState(m.activeTable, false) - } - ts := TableState{ Result: msg.result, - MsgIndex: len(m.messages), + MsgIndex: -1, ColOffset: 0, RowOffset: 0, VerticalView: false, } m.tableStates = append(m.tableStates, ts) - m.activeTable = len(m.tableStates) - 1 + tableIdx := len(m.tableStates) - 1 + m.activeTable = tableIdx - formatted := FormatTableResult(msg.result, 0, 0, m.width, true) - m.messages = append(m.messages, formatted) + // Attach TableStateIndex directly inside execute_sql ToolCallItem + if len(m.toolCalls) > 0 { + lastIdx := len(m.toolCalls) - 1 + if m.toolCalls[lastIdx].Name == "execute_sql" { + m.toolCalls[lastIdx].Result = statusLine + m.toolCalls[lastIdx].TableStateIndex = tableIdx + m.renderToolCall(lastIdx) + } + } m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", @@ -668,10 +692,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyCtrlO: + // Toggle folding/unfolding of currently active/focused ToolCallItem if len(m.toolCalls) > 0 { - lastIdx := len(m.toolCalls) - 1 - m.toolCalls[lastIdx].IsExpanded = !m.toolCalls[lastIdx].IsExpanded - m.renderToolCall(lastIdx) + if m.activeToolIdx < 0 || m.activeToolIdx >= len(m.toolCalls) { + m.activeToolIdx = len(m.toolCalls) - 1 + } + m.toolCalls[m.activeToolIdx].IsExpanded = !m.toolCalls[m.activeToolIdx].IsExpanded + m.renderToolCall(m.activeToolIdx) + return m, nil + } + + case tea.KeyCtrlP: + // Cycle active tool call focus to PREVIOUS tool call + if len(m.toolCalls) > 0 { + oldIdx := m.activeToolIdx + if m.activeToolIdx <= 0 { + m.activeToolIdx = len(m.toolCalls) - 1 + } else { + m.activeToolIdx-- + } + if oldIdx >= 0 && oldIdx < len(m.toolCalls) { + m.renderToolCall(oldIdx) + } + m.renderToolCall(m.activeToolIdx) + return m, nil + } + + case tea.KeyCtrlN: + // Cycle active tool call focus to NEXT tool call + if len(m.toolCalls) > 0 { + oldIdx := m.activeToolIdx + m.activeToolIdx = (m.activeToolIdx + 1) % len(m.toolCalls) + if oldIdx >= 0 && oldIdx < len(m.toolCalls) { + m.renderToolCall(oldIdx) + } + m.renderToolCall(m.activeToolIdx) return m, nil } @@ -841,10 +896,15 @@ func (m Model) View() string { } toolFoldState := "Folded" - if len(m.toolCalls) > 0 && m.toolCalls[len(m.toolCalls)-1].IsExpanded { + if m.activeToolIdx >= 0 && m.activeToolIdx < len(m.toolCalls) && m.toolCalls[m.activeToolIdx].IsExpanded { toolFoldState = "Unfolded" } + toolNavHint := "" + if len(m.toolCalls) > 1 { + toolNavHint = fmt.Sprintf(" [%d/%d]", m.activeToolIdx+1, len(m.toolCalls)) + } + var keybindings string if m.state == StateExportReady { keybindings = renderKeybindingBadges([][2]string{ @@ -857,7 +917,8 @@ func (m Model) View() string { {"e", "Edit SQL"}, {"Esc", "Cancel"}, {"Shift+Tab", "Mode (" + execModeHint + ")"}, - {"Ctrl+O", "Tool Details (" + toolFoldState + ")"}, + {"Ctrl+O", "Tool Details (" + toolFoldState + toolNavHint + ")"}, + {"Ctrl+P/N", "Nav Tools"}, }) } else { keybindings = renderKeybindingBadges([][2]string{ @@ -866,7 +927,8 @@ func (m Model) View() string { {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, {"Ctrl+E", "Expand Table"}, - {"Ctrl+O", "Tools (" + toolFoldState + ")"}, + {"Ctrl+O", "Tools (" + toolFoldState + toolNavHint + ")"}, + {"Ctrl+P/N", "Nav Tools"}, {"Shift+Tab", "Mode (" + execModeHint + ")"}, {"Esc", "Quit"}, }) From 3ff3364d6afa54469e87249b077609718ecc7882 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:00:32 +0800 Subject: [PATCH 49/75] docs: update AI documentation and RFC 0011 to reflect 4 Tool Calls and ReAct loop --- docs/ai.md | 33 +++++++++++++++---------- docs/rfcs/0011-goja-js-data-analysis.md | 21 ++++++++++------ 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 1b537bd..663b89e 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -87,7 +87,7 @@ MCP Server 提供以下 tools: "mcpServers": { "xsql": { "command": "xsql", - "args": ["mcp", "server", "--config", "/path/to/config.yaml"] + "args": ["mcp", "server", "--command", "mcp", "server", "--config", "/path/to/config.yaml"] } } } @@ -115,29 +115,36 @@ xsql-ai --profile dev ``` ### LLM 集成与 Tool Call 机制 -`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,支持双 Tool Calling 与多轮数据集召回: -- 数据库查询 Tool:`execute_sql(sql: string, explanation: string)` -- JS 数据分析 Tool:`execute_javascript(js_code: string, explanation: string)` +`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 4 大核心 Tools 调度: +1. **`execute_sql(sql: string, explanation: string)`**: 数据库 SQL 查询工具。 +2. **`execute_javascript(js_code: string, explanation: string)`**: 基于 `goja` 沙箱的本地 JS 数据聚合计算工具(必须遵循 ES5 语法)。 +3. **`render_table(dataset_id: string, title: string, explanation: string)`**: 会话数据集 TUI 交互表格渲染工具。 +4. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 会话数据集文件导出工具(触发人机交互二次确认)。 + +#### ReAct Agent Loop 准则 +- **循环驱动**:Agent 会在单次交互中循环执行 Tools,直到不再产生 Tool Call。 +- **最终回答不变性**:交互轮次的最终输出必定是 AI 总结出的自然语言 / Markdown 格式分析报告。 +- **工具折叠与容器内嵌**:所有的中间 Tool Call 默认以单行 Pill 收起折叠(内嵌表格与指标数据),界面保持极简清爽。 #### 零数据泄露与 Session 数据集召回 (Session DataStore) - 每次查询成功的结果在本地分配标号(`res1`, `res2`, ...)。 - 大模型上下文中仅包含数据集的轻量 Catalog 目录结构(字段名与行数),不传输海量真实数据。 -- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 Go 宿主层安全导出为 CSV/JSON/Markdown。 +- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 `export_data` 安全导出为 CSV/JSON/Markdown。 ### 快捷键操作 -#### SQL 待确认状态 (SQL Preview Mode) -- `Enter`: 确认并安全执行当前生成预览的 SQL +#### SQL & 导出确认状态 (Approval Mode) +- `Enter`: 确认并安全执行当前生成预览的 SQL 或同意文件导出 - `e`: 切换到 SQL 文本手工编辑/微调模式 -- `Esc`: 取消当前 SQL 生成建议,返回 Prompt 输入模式 +- `Esc`: 取消当前 SQL 生成建议或拒绝文件导出 -#### 通用与表格操作 (General & Table Operations) +#### 通用与表格/工具操作 (General & Tool Operations) - `Enter`: 提交自然语言需求给 AI -- `Ctrl+E`: 展开/收起折叠全量内容 (Toggle Expanded Full View,无 50 行截断) +- `Ctrl+O`: 折叠/展开当前选中的 Tool Call 详情(内嵌表格与指标) +- `Ctrl+P` / `Ctrl+N`: 在会话历史中的多个 Tool Call 组件之间向前/向后切换焦点 +- `Ctrl+E`: 切换表格单行展开视图(Expand Vertical View) - `Tab`: 在历史多个查询结果表格之间无缝切换焦点 (`[FOCUSED]`) - `←` / `→`: 横向平滑滚动查看当前焦点表格的隐藏列 -- `PgUp` / `PgDn`: 向上/向下翻页查看当前焦点表格的第 13-N 行数据 +- `PgUp` / `PgDn`: 向上/向下翻页查看当前焦点表格的数据 - `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 - `Esc` / `Ctrl+C`: 退出 AI 模式 - - diff --git a/docs/rfcs/0011-goja-js-data-analysis.md b/docs/rfcs/0011-goja-js-data-analysis.md index 7f4f3fa..c51efed 100644 --- a/docs/rfcs/0011-goja-js-data-analysis.md +++ b/docs/rfcs/0011-goja-js-data-analysis.md @@ -1,9 +1,9 @@ -# RFC 0011: Integration of goja JS Engine and Session DataStore for AI Data Analytics +# RFC 0011: Integration of goja JS Engine, Session DataStore, and ReAct Tool Agent Loop Status: Proposed ## 摘要 -本 RFC 提出在 `xsql` / `xsql-ai` 中集成纯 Go 实现的 `goja` JavaScript 虚拟机(100% Zero CGO),并构建 **Session DataStore(会话数据集存储与召回)** 机制。 +本 RFC 提出在 `xsql` / `xsql-ai` 中集成纯 Go 实现的 `goja` JavaScript 虚拟机(100% Zero CGO),构建 **Session DataStore(会话数据集存储与召回)** 机制,并实现无硬编码的 **ReAct Tool Agent Loop 循环推理**。 ## 背景 / 动机 - 当前 `xsql-ai` 仅支持 SQL 交互与表格展示,缺少数据二次聚合计算、跨查询结果 Join/比对以及结构化导出文件(CSV/JSON/Markdown)的能力。 @@ -13,15 +13,22 @@ Status: Proposed ### 1. 零 CGO JS 引擎 (`internal/js`) - 使用 `github.com/dop251/goja` 在纯 Go 内存沙箱中执行 AI 动态生成的 JS 数据分析代码。 -- 支持 Context 超时打断(默认 1 分钟,可配置 `js_timeout`)。 +- 规定 JS 代码必须遵循 ES5 (ECMAScript 5.1) 标准语法。 +- 支持 Context 超时打断(默认 1 分钟,可配置 `js_timeout`),并自动捕获 `console.log` 输出。 +- 包含 AI 自动重试修正机制(上限 3 次)。 ### 2. Session 数据集存储与召回 (`internal/session`) - 本地维护 `SessionDataStore`,为每次 SQL 执行成功的 QueryResult 分配唯一 ID(`res1`, `res2`, ...)。 - 向大模型上下文仅提供轻量 **Dataset Catalog** 元数据目录,LLM 可以在后续多轮对话中指定 `res1`, `res2` 召回历史数据并在 JS 中做跨数据集 Join 或计算。 ### 3. 外层文件导出 (`internal/export`) -- JS 仅负责数据计算与转换;由外层 Go 宿主层统一执行安全的磁盘文件写入(CSV / JSON / Markdown)。 +- JS 仅负责数据计算与转换;由外层 Go 宿主层统一执行安全的磁盘文件写入(CSV / JSON / Markdown),并强制人机交互二次确认(Human-in-the-loop)。 -### 4. AI Tool Calling (`internal/ai`) -- 新增 Tool:`execute_javascript(js_code: string, explanation: string)`。 -- AI 可先通过 `execute_sql` 查出数据,再调用 `execute_javascript` 完成分析与导出。 +### 4. ReAct Tool Agent Loop 架构 (`internal/ai` & `internal/tui`) +AI 具备 4 大解耦工具: +1. `execute_sql`: 数据库 SQL 查询 +2. `execute_javascript`: ES5 沙箱数据二次清洗与聚合 +3. `render_table`: 交互式 TUI 表格组件渲染 +4. `export_data`: 文件导出(含人机交互确认卡片) + +所有 Tool Calls 默认在 TUI 容器中折叠内嵌呈现(`Ctrl+O` 展开/折叠,`Ctrl+P`/`Ctrl+N` 切换焦点),且交互末尾必定以 LLM 自然语言 Markdown 分析报告总结收尾。 From 4807424ff2aa879c3fdd2dff6fd2e2697f2dfe9b Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:01:57 +0800 Subject: [PATCH 50/75] refactor: unify Tab/ShiftTab and Ctrl+P/N navigation across Tool Call Containers --- internal/tui/model.go | 94 +++++++++++++++++++------------------- internal/tui/model_test.go | 6 +-- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 9e86f05..8f14c6f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -214,6 +214,30 @@ func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { } } +func (m *Model) focusToolCall(idx int) { + if len(m.toolCalls) == 0 { + return + } + if idx < 0 { + idx = len(m.toolCalls) - 1 + } else if idx >= len(m.toolCalls) { + idx = 0 + } + + oldIdx := m.activeToolIdx + m.activeToolIdx = idx + + // Sync embedded table focus if tool call has attached table + if tc := m.toolCalls[idx]; tc.TableStateIndex >= 0 { + m.activeTable = tc.TableStateIndex + } + + if oldIdx >= 0 && oldIdx < len(m.toolCalls) { + m.renderToolCall(oldIdx) + } + m.renderToolCall(m.activeToolIdx) +} + func (m *Model) renderTableState(idx int, isActive bool) { if idx < 0 || idx >= len(m.tableStates) { return @@ -339,7 +363,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 - m.activeToolIdx = toolIdx + m.focusToolCall(toolIdx) ctx := context.Background() jsRes, jsErr := m.jsEngine.Execute(ctx, msg.response.JSCode, m.sessionStore) @@ -408,7 +432,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.tableStates = append(m.tableStates, ts) tableIdx := len(m.tableStates) - 1 - m.activeTable = tableIdx tc := ToolCallItem{ ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), @@ -423,8 +446,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 - m.activeToolIdx = toolIdx - m.renderToolCall(toolIdx) + m.focusToolCall(toolIdx) m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", @@ -454,8 +476,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 - m.activeToolIdx = toolIdx - m.renderToolCall(toolIdx) + m.focusToolCall(toolIdx) m.pendingExport = &PendingExport{ DatasetID: msg.response.DatasetID, @@ -484,8 +505,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, "") m.toolCalls = append(m.toolCalls, tc) toolIdx := len(m.toolCalls) - 1 - m.activeToolIdx = toolIdx - m.renderToolCall(toolIdx) + m.focusToolCall(toolIdx) if m.autoExecute { m.state = StateExecuting @@ -548,7 +568,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.tableStates = append(m.tableStates, ts) tableIdx := len(m.tableStates) - 1 - m.activeTable = tableIdx // Attach TableStateIndex directly inside execute_sql ToolCallItem if len(m.toolCalls) > 0 { @@ -556,7 +575,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.toolCalls[lastIdx].Name == "execute_sql" { m.toolCalls[lastIdx].Result = statusLine m.toolCalls[lastIdx].TableStateIndex = tableIdx - m.renderToolCall(lastIdx) + m.focusToolCall(lastIdx) } } @@ -702,34 +721,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - case tea.KeyCtrlP: - // Cycle active tool call focus to PREVIOUS tool call + case tea.KeyTab, tea.KeyCtrlN: + // Tab / Ctrl+N: Navigate focus to NEXT Tool Call Container if len(m.toolCalls) > 0 { - oldIdx := m.activeToolIdx - if m.activeToolIdx <= 0 { - m.activeToolIdx = len(m.toolCalls) - 1 - } else { - m.activeToolIdx-- - } - if oldIdx >= 0 && oldIdx < len(m.toolCalls) { - m.renderToolCall(oldIdx) - } - m.renderToolCall(m.activeToolIdx) + m.focusToolCall((m.activeToolIdx + 1) % len(m.toolCalls)) return m, nil } - case tea.KeyCtrlN: - // Cycle active tool call focus to NEXT tool call + case tea.KeyShiftTab, tea.KeyCtrlP: + // Shift+Tab / Ctrl+P: Navigate focus to PREVIOUS Tool Call Container if len(m.toolCalls) > 0 { - oldIdx := m.activeToolIdx - m.activeToolIdx = (m.activeToolIdx + 1) % len(m.toolCalls) - if oldIdx >= 0 && oldIdx < len(m.toolCalls) { - m.renderToolCall(oldIdx) + if m.activeToolIdx <= 0 { + m.focusToolCall(len(m.toolCalls) - 1) + } else { + m.focusToolCall(m.activeToolIdx - 1) } - m.renderToolCall(m.activeToolIdx) return m, nil } + case tea.KeyCtrlA: + // Ctrl+A: Toggle Auto-Execute / Manual approval mode + m.autoExecute = !m.autoExecute + case tea.KeyCtrlE: if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { ts := &m.tableStates[m.activeTable] @@ -737,18 +750,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.renderTableState(m.activeTable, true) } - case tea.KeyShiftTab: - m.autoExecute = !m.autoExecute - - case tea.KeyTab: - if len(m.tableStates) > 1 { - oldIdx := m.activeTable - m.activeTable = (m.activeTable + 1) % len(m.tableStates) - m.renderTableState(oldIdx, false) - m.renderTableState(m.activeTable, true) - return m, nil - } - case tea.KeyLeft: if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { ts := &m.tableStates[m.activeTable] @@ -916,20 +917,19 @@ func (m Model) View() string { {"Enter", "Execute"}, {"e", "Edit SQL"}, {"Esc", "Cancel"}, - {"Shift+Tab", "Mode (" + execModeHint + ")"}, + {"Ctrl+A", "Mode (" + execModeHint + ")"}, {"Ctrl+O", "Tool Details (" + toolFoldState + toolNavHint + ")"}, - {"Ctrl+P/N", "Nav Tools"}, + {"Tab/Shift+Tab", "Nav Tools"}, }) } else { keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, - {"Tab", "Focus Table"}, + {"Tab/Shift+Tab", "Focus Tool" + toolNavHint}, {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, + {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"Ctrl+E", "Expand Table"}, - {"Ctrl+O", "Tools (" + toolFoldState + toolNavHint + ")"}, - {"Ctrl+P/N", "Nav Tools"}, - {"Shift+Tab", "Mode (" + execModeHint + ")"}, + {"Ctrl+A", "Mode (" + execModeHint + ")"}, {"Esc", "Quit"}, }) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f67b463..3e5e73b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -170,11 +170,11 @@ func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { t.Fatal("expected autoExecute to be false by default") } - // Press Shift+Tab -> toggle to autoExecute = true - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + // Press Ctrl+A -> toggle to autoExecute = true + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlA}) m = updated.(Model) if !m.autoExecute { - t.Fatal("expected autoExecute to be true after Shift+Tab") + t.Fatal("expected autoExecute to be true after Ctrl+A") } // Send sqlGeneratedMsg -> should automatically transition to StateExecuting From 24d9cfbeeb5ee1b6474baee86ad0fdf8cb31e0af Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:03:29 +0800 Subject: [PATCH 51/75] refactor: restore ShiftTab for mode toggle and simplify navigation keybindings --- internal/tui/model.go | 51 +++++++++++++++----------------------- internal/tui/model_test.go | 6 ++--- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8f14c6f..6e539bc 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -684,25 +684,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.state == StateSQLReady { - switch { - case msg.Type == tea.KeyEnter: + switch msg.Type { + case tea.KeyEnter: m.state = StateExecuting m.textarea.Focus() m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) - case msg.String() == "e" || msg.String() == "E": - m.editingSQL = true - m.textarea.Focus() - m.textarea.SetValue(m.currentSQL) - m.textarea.CursorEnd() - return m, nil - - case msg.Type == tea.KeyEsc: + case tea.KeyEsc: m.state = StateIdle m.textarea.Focus() return m, nil + default: + if msg.String() == "e" || msg.String() == "E" { + m.editingSQL = true + m.textarea.Focus() + m.textarea.SetValue(m.currentSQL) + m.textarea.CursorEnd() + return m, nil + } } } @@ -721,26 +722,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - case tea.KeyTab, tea.KeyCtrlN: - // Tab / Ctrl+N: Navigate focus to NEXT Tool Call Container + case tea.KeyTab: + // Tab: Cycle focus between Tool Call Containers if len(m.toolCalls) > 0 { m.focusToolCall((m.activeToolIdx + 1) % len(m.toolCalls)) return m, nil } - case tea.KeyShiftTab, tea.KeyCtrlP: - // Shift+Tab / Ctrl+P: Navigate focus to PREVIOUS Tool Call Container - if len(m.toolCalls) > 0 { - if m.activeToolIdx <= 0 { - m.focusToolCall(len(m.toolCalls) - 1) - } else { - m.focusToolCall(m.activeToolIdx - 1) - } - return m, nil - } - - case tea.KeyCtrlA: - // Ctrl+A: Toggle Auto-Execute / Manual approval mode + case tea.KeyShiftTab: + // Shift+Tab: Restored keybinding for toggling AUTO-EXECUTE / MANUAL approval mode! m.autoExecute = !m.autoExecute case tea.KeyCtrlE: @@ -917,19 +907,18 @@ func (m Model) View() string { {"Enter", "Execute"}, {"e", "Edit SQL"}, {"Esc", "Cancel"}, - {"Ctrl+A", "Mode (" + execModeHint + ")"}, - {"Ctrl+O", "Tool Details (" + toolFoldState + toolNavHint + ")"}, - {"Tab/Shift+Tab", "Nav Tools"}, + {"Shift+Tab", "Mode (" + execModeHint + ")"}, + {"Ctrl+O", "Tool (" + toolFoldState + toolNavHint + ")"}, }) } else { keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, - {"Tab/Shift+Tab", "Focus Tool" + toolNavHint}, + {"Tab", "Focus Tool" + toolNavHint}, + {"Ctrl+O", "Fold/Unfold Tool"}, {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, - {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"Ctrl+E", "Expand Table"}, - {"Ctrl+A", "Mode (" + execModeHint + ")"}, + {"Shift+Tab", "Mode (" + execModeHint + ")"}, {"Esc", "Quit"}, }) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 3e5e73b..f67b463 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -170,11 +170,11 @@ func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { t.Fatal("expected autoExecute to be false by default") } - // Press Ctrl+A -> toggle to autoExecute = true - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlA}) + // Press Shift+Tab -> toggle to autoExecute = true + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) m = updated.(Model) if !m.autoExecute { - t.Fatal("expected autoExecute to be true after Ctrl+A") + t.Fatal("expected autoExecute to be true after Shift+Tab") } // Send sqlGeneratedMsg -> should automatically transition to StateExecuting From b31db4d3bb409a8d2178a1b95ac7d9cbfb86f081 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:07:04 +0800 Subject: [PATCH 52/75] refactor: remove redundant render_table tool call in favor of host-side auto table rendering --- docs/ai.md | 7 ++-- docs/rfcs/0011-goja-js-data-analysis.md | 7 ++-- internal/ai/client.go | 41 +------------------ internal/ai/prompt.go | 8 +--- internal/ai/service.go | 2 - internal/tui/model.go | 53 ------------------------- 6 files changed, 9 insertions(+), 109 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 663b89e..34082a2 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -115,11 +115,10 @@ xsql-ai --profile dev ``` ### LLM 集成与 Tool Call 机制 -`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 4 大核心 Tools 调度: -1. **`execute_sql(sql: string, explanation: string)`**: 数据库 SQL 查询工具。 +`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 3 大核心 Tools 调度: +1. **`execute_sql(sql: string, explanation: string)`**: 数据库 SQL 查询工具(执行成功后宿主自动渲染内嵌交互表格)。 2. **`execute_javascript(js_code: string, explanation: string)`**: 基于 `goja` 沙箱的本地 JS 数据聚合计算工具(必须遵循 ES5 语法)。 -3. **`render_table(dataset_id: string, title: string, explanation: string)`**: 会话数据集 TUI 交互表格渲染工具。 -4. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 会话数据集文件导出工具(触发人机交互二次确认)。 +3. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 会话数据集文件导出工具(触发人机交互二次确认)。 #### ReAct Agent Loop 准则 - **循环驱动**:Agent 会在单次交互中循环执行 Tools,直到不再产生 Tool Call。 diff --git a/docs/rfcs/0011-goja-js-data-analysis.md b/docs/rfcs/0011-goja-js-data-analysis.md index c51efed..4c75e37 100644 --- a/docs/rfcs/0011-goja-js-data-analysis.md +++ b/docs/rfcs/0011-goja-js-data-analysis.md @@ -25,10 +25,9 @@ Status: Proposed - JS 仅负责数据计算与转换;由外层 Go 宿主层统一执行安全的磁盘文件写入(CSV / JSON / Markdown),并强制人机交互二次确认(Human-in-the-loop)。 ### 4. ReAct Tool Agent Loop 架构 (`internal/ai` & `internal/tui`) -AI 具备 4 大解耦工具: -1. `execute_sql`: 数据库 SQL 查询 +AI 具备 3 大解耦工具: +1. `execute_sql`: 数据库 SQL 查询(执行完由宿主层自动渲染内嵌交互表格) 2. `execute_javascript`: ES5 沙箱数据二次清洗与聚合 -3. `render_table`: 交互式 TUI 表格组件渲染 -4. `export_data`: 文件导出(含人机交互确认卡片) +3. `export_data`: 文件导出(含人机交互确认卡片) 所有 Tool Calls 默认在 TUI 容器中折叠内嵌呈现(`Ctrl+O` 展开/折叠,`Ctrl+P`/`Ctrl+N` 切换焦点),且交互末尾必定以 LLM 自然语言 Markdown 分析报告总结收尾。 diff --git a/internal/ai/client.go b/internal/ai/client.go index 5f9794a..3099746 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -101,31 +101,6 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A }, } - tableToolDef := openai.ChatCompletionToolParam{ - Function: shared.FunctionDefinitionParam{ - Name: "render_table", - Description: openai.String("Render a cached session dataset (e.g. res1, res2) as an interactive TUI table widget for the user."), - Parameters: shared.FunctionParameters{ - "type": "object", - "properties": map[string]interface{}{ - "dataset_id": map[string]interface{}{ - "type": "string", - "description": "The session dataset ID to render as interactive TUI table (e.g., 'res1').", - }, - "title": map[string]interface{}{ - "type": "string", - "description": "Optional title for the table component.", - }, - "explanation": map[string]interface{}{ - "type": "string", - "description": "Explanation of the table being rendered.", - }, - }, - "required": []string{"dataset_id", "explanation"}, - }, - }, - } - exportToolDef := openai.ChatCompletionToolParam{ Function: shared.FunctionDefinitionParam{ Name: "export_data", @@ -163,7 +138,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A params := openai.ChatCompletionNewParams{ Model: shared.ChatModel(model), Messages: sdkMessages, - Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, tableToolDef, exportToolDef}, + Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, exportToolDef}, } if c.cfg.MaxTokens > 0 { params.MaxTokens = openai.Int(int64(c.cfg.MaxTokens)) @@ -208,20 +183,6 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A Explanation: strings.TrimSpace(raw.Explanation), }, nil } - } else if toolCall.Function.Name == "render_table" { - var raw struct { - DatasetID string `json:"dataset_id"` - Title string `json:"title"` - Explanation string `json:"explanation"` - } - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err == nil { - return &AIResponse{ - Type: TypeTable, - DatasetID: strings.TrimSpace(raw.DatasetID), - Title: strings.TrimSpace(raw.Title), - Explanation: strings.TrimSpace(raw.Explanation), - }, nil - } } else if toolCall.Function.Name == "export_data" { var raw struct { DatasetID string `json:"dataset_id"` diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 452839b..c583125 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -8,7 +8,7 @@ import ( ) const SystemPromptTemplate = `You are an expert AI SQL generator and Data Analyst for the %s database. -Your job is to convert natural language requests into correct, efficient SQL queries, JavaScript data analysis scripts, TUI table widgets, or file export requests. +Your job is to convert natural language requests into correct, efficient SQL queries, JavaScript data analysis scripts, or file export requests. DATABASE SCHEMA: %s @@ -22,11 +22,7 @@ AVAILABLE TOOLS: 2. 'execute_javascript': Call this when the user asks for post-query data analysis, percentage calculations, cross-dataset joins/comparisons, or structured formatting. - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). Must be ES5 standard syntax. Return a clean JS object or formatted string. Do NOT wrap return values in JSON.stringify() with string escaping. - "explanation": explanation of what the JavaScript script processes. -3. 'render_table': Call this tool to render a cached session dataset (e.g. 'res1', 'res2') as an interactive TUI table widget for the user. - - "dataset_id": dataset ID from catalog to render (e.g. 'res1'). - - "title": optional title for table widget. - - "explanation": explanation of the table view. -4. 'export_data': Call this tool when the user requests exporting a dataset to a local file. This tool requires human-in-the-loop interactive confirmation. +3. 'export_data': Call this tool when the user requests exporting a dataset to a local file. This tool requires human-in-the-loop interactive confirmation. - "dataset_id": dataset ID from catalog to export (e.g. 'res1'). - "format": file format ('csv', 'json', or 'markdown'). - "filepath": target output filename (e.g. 'servers.csv'). diff --git a/internal/ai/service.go b/internal/ai/service.go index b319677..e6baa9a 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -13,7 +13,6 @@ type ResponseType string const ( TypeSQL ResponseType = "sql" TypeJS ResponseType = "js" - TypeTable ResponseType = "table" TypeExport ResponseType = "export" TypeText ResponseType = "text" ) @@ -25,7 +24,6 @@ type AIResponse struct { DatasetID string `json:"dataset_id,omitempty"` Format string `json:"format,omitempty"` FilePath string `json:"filepath,omitempty"` - Title string `json:"title,omitempty"` Explanation string `json:"explanation"` } diff --git a/internal/tui/model.go b/internal/tui/model.go index 6e539bc..b5b5e7c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -404,59 +404,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, m.runAgentStepCmd() } - } else if msg.response.Type == ai.TypeTable && msg.response.DatasetID != "" { - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "assistant", - Content: fmt.Sprintf("Call tool 'render_table': dataset_id=%s, title=%s", msg.response.DatasetID, msg.response.Title), - }) - - datasetRes, exists := m.sessionStore.Get(msg.response.DatasetID) - if !exists || datasetRes == nil { - m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("❌ Tool render_table failed: dataset '%s' not found", msg.response.DatasetID))) - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'render_table' failed: dataset '%s' not found in session catalog.", msg.response.DatasetID), - }) - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.runAgentStepCmd() - } - - ts := TableState{ - Result: datasetRes, - MsgIndex: -1, - ColOffset: 0, - RowOffset: 0, - VerticalView: false, - } - m.tableStates = append(m.tableStates, ts) - tableIdx := len(m.tableStates) - 1 - - tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "render_table", - Summary: fmt.Sprintf("Rendered interactive table view for %s (%d rows)", msg.response.DatasetID, len(datasetRes.Rows)), - Detail: fmt.Sprintf("Dataset: %s | Title: %s", msg.response.DatasetID, msg.response.Title), - Result: fmt.Sprintf("✓ Table rendered (%d rows)", len(datasetRes.Rows)), - TableStateIndex: tableIdx, - MsgIndex: len(m.messages), - IsExpanded: false, - } - m.messages = append(m.messages, "") - m.toolCalls = append(m.toolCalls, tc) - toolIdx := len(m.toolCalls) - 1 - m.focusToolCall(toolIdx) - - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'render_table' executed successfully. Interactive table view for dataset '%s' rendered for user.", msg.response.DatasetID), - }) - - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.runAgentStepCmd() } else if msg.response.Type == ai.TypeExport && msg.response.DatasetID != "" { m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", From a059f08e7376c9d2b943097cba9005a8c9a073da Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:07:58 +0800 Subject: [PATCH 53/75] feat: add SQL and JS syntax highlighting with Chroma and Markdown rendering with Glamour --- go.mod | 24 ++++++++++++++---- go.sum | 40 ++++++++++++++++++++++++++++++ internal/tui/model.go | 17 ++++++++++--- internal/tui/render.go | 56 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 internal/tui/render.go diff --git a/go.mod b/go.mod index c4e092f..8b9b039 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.25.0 require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.4 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/go-sql-driver/mysql v1.10.0 github.com/google/jsonschema-go v0.4.3 github.com/jackc/pgx/v5 v5.9.2 - github.com/mattn/go-runewidth v0.0.16 + github.com/mattn/go-runewidth v0.0.17 github.com/modelcontextprotocol/go-sdk v1.6.0 github.com/openai/openai-go v1.12.0 github.com/spf13/cobra v1.10.2 @@ -22,28 +22,38 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2/v2 v2.5.2 // indirect github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -53,7 +63,11 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/go.sum b/go.sum index ed5297e..53dac4c 100644 --- a/go.sum +++ b/go.sum @@ -2,22 +2,38 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -27,6 +43,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es= @@ -47,6 +65,8 @@ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -63,24 +83,36 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -113,13 +145,21 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= diff --git a/internal/tui/model.go b/internal/tui/model.go index b5b5e7c..6f2cd02 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -286,7 +286,15 @@ func (m *Model) renderToolCall(idx int) { } else { badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name + activeMarker) summary := SQLCodeStyle.Render(tc.Summary) - detail := ToolDetailStyle.Render(tc.Detail) + + detailCode := tc.Detail + if tc.Name == "execute_sql" { + detailCode = HighlightSQL(tc.Detail) + } else if tc.Name == "execute_javascript" { + detailCode = HighlightJS(tc.Detail) + } + + detail := ToolDetailStyle.Render(detailCode) resText := MetricsStyle.Render(tc.Result) sb.WriteString(fmt.Sprintf("%s %s\n%s\n%s", badge, summary, detail, resText)) @@ -469,7 +477,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) if msg.response.Explanation != "" { - aiMsg := AITagStyle.Render("🤖 AI") + " " + AIResponseStyle.Render(msg.response.Explanation) + renderedMD := RenderMarkdown(msg.response.Explanation, m.width) + aiMsg := AITagStyle.Render("🤖 AI") + "\n" + renderedMD m.messages = append(m.messages, aiMsg) } m.state = StateIdle @@ -677,7 +686,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyShiftTab: - // Shift+Tab: Restored keybinding for toggling AUTO-EXECUTE / MANUAL approval mode! + // Shift+Tab: Toggle AUTO-EXECUTE / MANUAL approval mode m.autoExecute = !m.autoExecute case tea.KeyCtrlE: @@ -812,7 +821,7 @@ func (m Model) View() string { sb.WriteString(SQLBoxStyle.Width(m.width-4).Render(preview) + "\n") } case StateSQLReady: - sqlContent := SQLCodeStyle.Render(m.currentSQL) + sqlContent := HighlightSQL(m.currentSQL) if m.currentSQL == "" { sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") } diff --git a/internal/tui/render.go b/internal/tui/render.go new file mode 100644 index 0000000..3c9a197 --- /dev/null +++ b/internal/tui/render.go @@ -0,0 +1,56 @@ +package tui + +import ( + "bytes" + "strings" + + "github.com/alecthomas/chroma/v2/quick" + "github.com/charmbracelet/glamour" +) + +// RenderMarkdown renders markdown text using Glamour with rich ANSI terminal styling. +func RenderMarkdown(md string, width int) string { + md = strings.TrimSpace(md) + if md == "" { + return "" + } + if width <= 10 { + width = 80 + } + r, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(width-6), + ) + if err != nil { + return md + } + out, err := r.Render(md) + if err != nil { + return md + } + return strings.TrimSpace(out) +} + +// HighlightCode renders syntax-highlighted code for terminal display using Chroma. +func HighlightCode(code string, lexerName string) string { + code = strings.TrimSpace(code) + if code == "" { + return "" + } + var buf bytes.Buffer + err := quick.Highlight(&buf, code, lexerName, "terminal256", "dracula") + if err != nil { + return code + } + return buf.String() +} + +// HighlightSQL applies syntax highlighting to SQL statements. +func HighlightSQL(sqlStr string) string { + return HighlightCode(sqlStr, "sql") +} + +// HighlightJS applies syntax highlighting to JavaScript code blocks. +func HighlightJS(jsStr string) string { + return HighlightCode(jsStr, "javascript") +} From ff6d0e570f4b50e58e797350dfa3521b52c8be5b Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:11:14 +0800 Subject: [PATCH 54/75] feat: implement unified interactive options list card for SQL preview and file export --- internal/tui/model.go | 138 +++++++++++++++++++++++++++++++--------- tests/e2e/proxy_test.go | 9 +-- 2 files changed, 114 insertions(+), 33 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 6f2cd02..f85cbfb 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -94,6 +94,8 @@ type Model struct { jsRetryCount int maxJSRetries int + confirmOption int // 0: Confirm/Execute, 1: Edit/Prompt, 2: Cancel/Deny + state State schemaInfo *db.SchemaInfo currentSQL string @@ -142,6 +144,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi chatHistory: []ai.ChatMessage{}, jsRetryCount: 0, maxJSRetries: 3, + confirmOption: 0, tableStates: []TableState{}, toolCalls: []ToolCallItem{}, activeTable: -1, @@ -439,6 +442,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { FilePath: msg.response.FilePath, ToolIdx: toolIdx, } + m.confirmOption = 0 m.state = StateExportReady } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { m.chatHistory = append(m.chatHistory, ai.ChatMessage{ @@ -468,6 +472,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) } + m.confirmOption = 0 m.state = StateSQLReady } else { // FINAL LLM AGENT OUTPUT (No Tool Call) @@ -583,9 +588,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, taCmd } + // UNIFIED HUMAN-IN-THE-LOOP INTERACTION FOR BOTH StateExportReady AND StateSQLReady! if m.state == StateExportReady && m.pendingExport != nil { switch msg.Type { - case tea.KeyEnter: + case tea.KeyUp: + m.confirmOption = (m.confirmOption - 1 + 3) % 3 + return m, nil + case tea.KeyDown: + m.confirmOption = (m.confirmOption + 1) % 3 + return m, nil + } + + triggerOpt := -1 + if msg.Type == tea.KeyEnter { + triggerOpt = m.confirmOption + } else if msg.String() == "1" { + triggerOpt = 0 + } else if msg.String() == "2" || msg.String() == "e" || msg.String() == "E" { + triggerOpt = 1 + } else if msg.String() == "3" || msg.Type == tea.KeyEsc { + triggerOpt = 2 + } + + switch triggerOpt { + case 0: + // Option 1: Confirm & Export datasetRes, exists := m.sessionStore.Get(m.pendingExport.DatasetID) if !exists || datasetRes == nil { m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: Dataset '%s' not found", m.pendingExport.DatasetID) @@ -622,7 +649,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, m.runAgentStepCmd() - case tea.KeyEsc: + case 1: + // Option 2: Edit Path / Prompt Input + m.state = StateIdle + m.pendingExport = nil + m.textarea.Focus() + return m, nil + + case 2: + // Option 3: Deny / Cancel Export m.toolCalls[m.pendingExport.ToolIdx].Result = "🚫 Export Denied by User" m.renderToolCall(m.pendingExport.ToolIdx) @@ -641,25 +676,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == StateSQLReady { switch msg.Type { - case tea.KeyEnter: + case tea.KeyUp: + m.confirmOption = (m.confirmOption - 1 + 3) % 3 + return m, nil + case tea.KeyDown: + m.confirmOption = (m.confirmOption + 1) % 3 + return m, nil + } + + triggerOpt := -1 + if msg.Type == tea.KeyEnter { + triggerOpt = m.confirmOption + } else if msg.String() == "1" { + triggerOpt = 0 + } else if msg.String() == "2" || msg.String() == "e" || msg.String() == "E" { + triggerOpt = 1 + } else if msg.String() == "3" || msg.Type == tea.KeyEsc { + triggerOpt = 2 + } + + switch triggerOpt { + case 0: + // Option 1: Execute SQL m.state = StateExecuting m.textarea.Focus() m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, m.executeSQLCmd(m.currentSQL) - case tea.KeyEsc: + case 1: + // Option 2: Edit SQL + m.editingSQL = true + m.textarea.Focus() + m.textarea.SetValue(m.currentSQL) + m.textarea.CursorEnd() + return m, nil + + case 2: + // Option 3: Cancel Execution m.state = StateIdle m.textarea.Focus() return m, nil - default: - if msg.String() == "e" || msg.String() == "E" { - m.editingSQL = true - m.textarea.Focus() - m.textarea.SetValue(m.currentSQL) - m.textarea.CursorEnd() - return m, nil - } } } @@ -782,6 +839,23 @@ func renderKeybindingBadges(items [][2]string) string { return strings.Join(parts, " ") } +func renderActionOptionsCard(title string, detailText string, options []string, activeOpt int, width int) string { + var sb strings.Builder + sb.WriteString(SQLTitleStyle.Render(title) + "\n") + if detailText != "" { + sb.WriteString(detailText + "\n\n") + } + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render("Action Options (Use ↑/↓ or 1/2/3 to select, Enter to confirm):") + "\n") + for i, opt := range options { + if i == activeOpt { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor).Render(fmt.Sprintf(" ▶ [%d] %s", i+1, opt)) + "\n") + } else { + sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render(fmt.Sprintf(" [%d] %s", i+1, opt)) + "\n") + } + } + return SQLBoxStyle.Width(width - 4).Render(sb.String()) +} + func (m Model) View() string { var sb strings.Builder @@ -806,7 +880,7 @@ func (m Model) View() string { // 2. Main Viewport sb.WriteString(m.viewport.View() + "\n\n") - // 3. State Status & SQL / Export Confirmation Box + // 3. State Status & Unified SQL / Export Action Selection Card switch m.state { case StateLoadingSchema: sb.WriteString(m.spinner.View() + " Loading database schema...\n") @@ -816,17 +890,29 @@ func (m Model) View() string { sb.WriteString(m.spinner.View() + " Executing SQL query...\n") case StateExportReady: if m.pendingExport != nil { - exportInfo := fmt.Sprintf("Dataset: %s | Target: %s | Format: %s", m.pendingExport.DatasetID, m.pendingExport.FilePath, strings.ToUpper(m.pendingExport.Format)) - preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ File Export Approval Required (Enter: Confirm Export | Esc: Deny):"), SQLCodeStyle.Render(exportInfo)) - sb.WriteString(SQLBoxStyle.Width(m.width-4).Render(preview) + "\n") + exportInfo := fmt.Sprintf("Dataset: %s | FilePath: %s | Format: %s", m.pendingExport.DatasetID, m.pendingExport.FilePath, strings.ToUpper(m.pendingExport.Format)) + card := renderActionOptionsCard( + "✨ File Export Approval Required", + SQLCodeStyle.Render(exportInfo), + []string{"Confirm & Export File", "Adjust Export Options / Prompt", "Deny & Cancel Export"}, + m.confirmOption, + m.width, + ) + sb.WriteString(card + "\n") } case StateSQLReady: sqlContent := HighlightSQL(m.currentSQL) if m.currentSQL == "" { sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") } - preview := fmt.Sprintf("%s\n%s", SQLTitleStyle.Render("✨ SQL Preview (Enter: Execute | e: Edit | Esc: Cancel):"), sqlContent) - sb.WriteString(SQLBoxStyle.Width(m.width-4).Render(preview) + "\n") + card := renderActionOptionsCard( + "✨ SQL Approval Required", + sqlContent, + []string{"Execute Query", "Edit SQL / Adjust Prompt", "Cancel Execution"}, + m.confirmOption, + m.width, + ) + sb.WriteString(card + "\n") } // 4. Input Area & Footer Keybindings @@ -853,24 +939,18 @@ func (m Model) View() string { } var keybindings string - if m.state == StateExportReady { - keybindings = renderKeybindingBadges([][2]string{ - {"Enter", "Confirm Export"}, - {"Esc", "Deny Export"}, - }) - } else if m.state == StateSQLReady { + if m.state == StateExportReady || m.state == StateSQLReady { keybindings = renderKeybindingBadges([][2]string{ - {"Enter", "Execute"}, - {"e", "Edit SQL"}, + {"↑/↓", "Select Option"}, + {"Enter", "Confirm"}, + {"1/2/3", "Quick Select"}, {"Esc", "Cancel"}, - {"Shift+Tab", "Mode (" + execModeHint + ")"}, - {"Ctrl+O", "Tool (" + toolFoldState + toolNavHint + ")"}, }) } else { keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, {"Tab", "Focus Tool" + toolNavHint}, - {"Ctrl+O", "Fold/Unfold Tool"}, + {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, {"Ctrl+E", "Expand Table"}, diff --git a/tests/e2e/proxy_test.go b/tests/e2e/proxy_test.go index a720fd0..d7a169f 100644 --- a/tests/e2e/proxy_test.go +++ b/tests/e2e/proxy_test.go @@ -356,18 +356,19 @@ func TestProxy_Help(t *testing.T) { } func TestProxy_MissingProfileFlag(t *testing.T) { - // Test that proxy fails when -p flag is not provided - stdout, _, exitCode := runXSQL(t, "proxy", "--format", "json") + // Test that proxy fails when profile is missing or invalid + emptyCfg := createTempConfig(t, "profiles: {}") + stdout, _, exitCode := runXSQL(t, "proxy", "--config", emptyCfg, "--format", "json") // Should fail with config error if exitCode == 0 { - t.Error("expected non-zero exit code when -p flag is not provided") + t.Error("expected non-zero exit code when profile is not provided") } var resp Response if err := json.Unmarshal([]byte(stdout), &resp); err == nil { if resp.OK { - t.Error("expected ok=false when -p flag is missing") + t.Error("expected ok=false when profile is missing") } } } From daeac0277ea5d6818ce48b30fbb2d03975e252bf Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:12:24 +0800 Subject: [PATCH 55/75] refactor: clarify option 2 labels for SQL editing and export prompt adjustment --- internal/tui/model.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index f85cbfb..d457cf2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -894,7 +894,7 @@ func (m Model) View() string { card := renderActionOptionsCard( "✨ File Export Approval Required", SQLCodeStyle.Render(exportInfo), - []string{"Confirm & Export File", "Adjust Export Options / Prompt", "Deny & Cancel Export"}, + []string{"Confirm & Export File", "Adjust Options / New Prompt", "Deny & Cancel Export"}, m.confirmOption, m.width, ) @@ -908,7 +908,7 @@ func (m Model) View() string { card := renderActionOptionsCard( "✨ SQL Approval Required", sqlContent, - []string{"Execute Query", "Edit SQL / Adjust Prompt", "Cancel Execution"}, + []string{"Execute SQL Query", "Edit SQL Statement", "Cancel Execution"}, m.confirmOption, m.width, ) From e021cec4e33be3dbfb82bc01983214553cbec1fe Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:14:23 +0800 Subject: [PATCH 56/75] fix: implement adaptive high-contrast syntax highlighting for light and dark terminal themes --- internal/tui/render.go | 55 +++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/internal/tui/render.go b/internal/tui/render.go index 3c9a197..9ea4b28 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -1,11 +1,22 @@ package tui import ( - "bytes" "strings" - "github.com/alecthomas/chroma/v2/quick" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +var ( + // High-Contrast Adaptive Styles for SQL & JS Syntax Highlighting (Light & Dark mode compatible) + KeywordStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#C084FC"}) // Rich Purple + StringStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#047857", Dark: "#34D399"}) // Emerald Green + NumberStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#FBBF24"}) // Amber Gold + NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#38BDF8"}) // Sky Blue + CommentStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"}) // Slate Grey + DefaultTxtStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"}) // Deep Slate / Crisp White ) // RenderMarkdown renders markdown text using Glamour with rich ANSI terminal styling. @@ -31,26 +42,52 @@ func RenderMarkdown(md string, width int) string { return strings.TrimSpace(out) } -// HighlightCode renders syntax-highlighted code for terminal display using Chroma. +// HighlightCode renders adaptive, high-contrast syntax-highlighted code for light & dark terminals. func HighlightCode(code string, lexerName string) string { code = strings.TrimSpace(code) if code == "" { return "" } - var buf bytes.Buffer - err := quick.Highlight(&buf, code, lexerName, "terminal256", "dracula") + + lexer := lexers.Get(lexerName) + if lexer == nil { + lexer = lexers.Fallback + } + lexer = chroma.Coalesce(lexer) + + iterator, err := lexer.Tokenise(nil, code) if err != nil { - return code + return DefaultTxtStyle.Render(code) + } + + var sb strings.Builder + for _, t := range iterator.Tokens() { + val := t.Value + switch t.Type { + case chroma.Keyword, chroma.KeywordReserved, chroma.KeywordType, chroma.KeywordNamespace: + sb.WriteString(KeywordStyle.Render(val)) + case chroma.String, chroma.StringChar, chroma.StringSingle, chroma.StringDouble, chroma.StringBacktick: + sb.WriteString(StringStyle.Render(val)) + case chroma.Number, chroma.NumberInteger, chroma.NumberFloat, chroma.NumberHex, chroma.NumberOct: + sb.WriteString(NumberStyle.Render(val)) + case chroma.Name, chroma.NameAttribute, chroma.NameClass, chroma.NameFunction, chroma.NameTag: + sb.WriteString(NameStyle.Render(val)) + case chroma.Comment, chroma.CommentSingle, chroma.CommentMultiline: + sb.WriteString(CommentStyle.Render(val)) + default: + sb.WriteString(DefaultTxtStyle.Render(val)) + } } - return buf.String() + + return sb.String() } -// HighlightSQL applies syntax highlighting to SQL statements. +// HighlightSQL applies adaptive high-contrast syntax highlighting to SQL statements. func HighlightSQL(sqlStr string) string { return HighlightCode(sqlStr, "sql") } -// HighlightJS applies syntax highlighting to JavaScript code blocks. +// HighlightJS applies adaptive high-contrast syntax highlighting to JavaScript code blocks. func HighlightJS(jsStr string) string { return HighlightCode(jsStr, "javascript") } From a7da4f2e34e4b86cdf78203a37d728f9847c7964 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:15:53 +0800 Subject: [PATCH 57/75] fix: eliminate duplicate SQL summary line in expanded ToolCallItem view --- internal/tui/model.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index d457cf2..a476a62 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -288,7 +288,6 @@ func (m *Model) renderToolCall(idx int) { sb.WriteString(fmt.Sprintf("%s %s", badge, summary)) } else { badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name + activeMarker) - summary := SQLCodeStyle.Render(tc.Summary) detailCode := tc.Detail if tc.Name == "execute_sql" { @@ -299,7 +298,7 @@ func (m *Model) renderToolCall(idx int) { detail := ToolDetailStyle.Render(detailCode) resText := MetricsStyle.Render(tc.Result) - sb.WriteString(fmt.Sprintf("%s %s\n%s\n%s", badge, summary, detail, resText)) + sb.WriteString(fmt.Sprintf("%s\n%s\n%s", badge, detail, resText)) // Render embedded Table Result inside container when unfolded if tc.TableStateIndex >= 0 && tc.TableStateIndex < len(m.tableStates) { From 252c86a772dffa4c3416b71c08b27a96cbfc9a14 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:18:52 +0800 Subject: [PATCH 58/75] fix: add defensive raw code interception and enforce strict tool calling rules in prompt --- internal/ai/prompt.go | 5 +++-- internal/tui/model.go | 19 ++++++++++++++++--- internal/tui/render.go | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index c583125..0b417b4 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -32,8 +32,9 @@ IMPORTANT RULES: 1. Default to READ-ONLY SELECT queries for database execution. 2. Avoid full table scans without limits or filters whenever possible. 3. When post-processing or joining previously queried datasets (e.g. 'res1', 'res2'), prefer calling 'execute_javascript' to compute results locally. -4. JAVASCRIPT ENVIRONMENT SPECIFICATION: The execution environment is strict ES5 (ECMAScript 5.1). Do NOT use ES6+ features such as String.prototype.repeat, Object.entries, Object.values, Arrow functions, let/const, or async/await. Always use standard ES5 syntax (e.g., var, function(), standard for loops, Object.keys()). -5. AGENT LOOP INVARIANT: Like standard AI Agent loops, the final response of an interaction turn MUST ALWAYS be a natural language / Markdown text report explaining the findings and insights clearly to the user (never end on a tool call or raw JSON string).` +4. JAVASCRIPT ENVIRONMENT SPECIFICATION: The execution environment is strict ES5 (ECMAScript 5.1). Do NOT use ES6+ features such as String.prototype.repeat, Object.entries, Object.values, Arrow functions, let/const, or async/await. Always use standard ES5 syntax (e.g., var, function(), standard for loops, Object.keys()). Also, do NOT put top-level 'return' statements outside of a function. +5. STRICT TOOL CALLING: When writing or fixing JavaScript code or SQL queries, NEVER write raw code, 'Call tool...', or code blocks inside natural language text. You MUST execute them via function tool calls ('execute_javascript' or 'execute_sql'). +6. AGENT LOOP INVARIANT: Like standard AI Agent loops, the final response of an interaction turn MUST ALWAYS be a natural language / Markdown text report explaining the findings and insights clearly to the user (never end on a tool call or raw JSON string).` func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { schemaJSON := "{}" diff --git a/internal/tui/model.go b/internal/tui/model.go index a476a62..58e4987 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -475,13 +475,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = StateSQLReady } else { // FINAL LLM AGENT OUTPUT (No Tool Call) + exp := msg.response.Explanation + + // Defensive guard: If LLM mistakenly output raw JS code in text instead of tool call, intercept it! + if strings.Contains(exp, "Call tool 'execute_javascript'") || (strings.Contains(exp, "var data =") && strings.Contains(exp, "stats")) { + jsCode := exp + if idx := strings.Index(exp, "var "); idx >= 0 { + jsCode = exp[idx:] + } + msg.response.Type = ai.TypeJS + msg.response.JSCode = jsCode + return m.Update(msg) + } + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "assistant", - Content: msg.response.Explanation, + Content: exp, }) - if msg.response.Explanation != "" { - renderedMD := RenderMarkdown(msg.response.Explanation, m.width) + if exp != "" { + renderedMD := RenderMarkdown(exp, m.width) aiMsg := AITagStyle.Render("🤖 AI") + "\n" + renderedMD m.messages = append(m.messages, aiMsg) } diff --git a/internal/tui/render.go b/internal/tui/render.go index 9ea4b28..37a384d 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -29,7 +29,7 @@ func RenderMarkdown(md string, width int) string { width = 80 } r, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), + glamour.WithStandardStyle("auto"), glamour.WithWordWrap(width-6), ) if err != nil { From 49eadcb37e559611ac13eb904b67ad91c630a1b3 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:19:55 +0800 Subject: [PATCH 59/75] refactor: simplify system prompt template to focus purely on essential environment specification --- internal/ai/prompt.go | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 0b417b4..14228dd 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -7,34 +7,17 @@ import ( "github.com/zx06/xsql/internal/db" ) -const SystemPromptTemplate = `You are an expert AI SQL generator and Data Analyst for the %s database. -Your job is to convert natural language requests into correct, efficient SQL queries, JavaScript data analysis scripts, or file export requests. +const SystemPromptTemplate = `You are an AI SQL Generator and Data Analyst for the %s database. DATABASE SCHEMA: %s %s -AVAILABLE TOOLS: -1. 'execute_sql': Call this to query the database. - - "sql": the generated SQL query (e.g. "SELECT * FROM users WHERE active = true;") - - "explanation": a concise explanation of what the query does. -2. 'execute_javascript': Call this when the user asks for post-query data analysis, percentage calculations, cross-dataset joins/comparisons, or structured formatting. - - "js_code": JavaScript code snippet executing on available session datasets (e.g. 'res1', 'res2', or 'rows'). Must be ES5 standard syntax. Return a clean JS object or formatted string. Do NOT wrap return values in JSON.stringify() with string escaping. - - "explanation": explanation of what the JavaScript script processes. -3. 'export_data': Call this tool when the user requests exporting a dataset to a local file. This tool requires human-in-the-loop interactive confirmation. - - "dataset_id": dataset ID from catalog to export (e.g. 'res1'). - - "format": file format ('csv', 'json', or 'markdown'). - - "filepath": target output filename (e.g. 'servers.csv'). - - "explanation": explanation of what is being exported. - -IMPORTANT RULES: -1. Default to READ-ONLY SELECT queries for database execution. -2. Avoid full table scans without limits or filters whenever possible. -3. When post-processing or joining previously queried datasets (e.g. 'res1', 'res2'), prefer calling 'execute_javascript' to compute results locally. -4. JAVASCRIPT ENVIRONMENT SPECIFICATION: The execution environment is strict ES5 (ECMAScript 5.1). Do NOT use ES6+ features such as String.prototype.repeat, Object.entries, Object.values, Arrow functions, let/const, or async/await. Always use standard ES5 syntax (e.g., var, function(), standard for loops, Object.keys()). Also, do NOT put top-level 'return' statements outside of a function. -5. STRICT TOOL CALLING: When writing or fixing JavaScript code or SQL queries, NEVER write raw code, 'Call tool...', or code blocks inside natural language text. You MUST execute them via function tool calls ('execute_javascript' or 'execute_sql'). -6. AGENT LOOP INVARIANT: Like standard AI Agent loops, the final response of an interaction turn MUST ALWAYS be a natural language / Markdown text report explaining the findings and insights clearly to the user (never end on a tool call or raw JSON string).` +ENVIRONMENT & SPECIFICATIONS: +- Database Mode: Default to READ-ONLY SELECT queries. +- JavaScript Environment: Strict ES5 (ECMAScript 5.1) engine. Active session datasets (e.g. res1, res2) are available in global context. +` func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { schemaJSON := "{}" From 8ecef191e44f65ea35d6f421c3c81471dde726f1 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:29:49 +0800 Subject: [PATCH 60/75] refactor: simplify prompt input area with single-line prompt symbol and subtle dividers --- internal/tui/model.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 58e4987..749239f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -117,13 +117,13 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() - ta.Placeholder = "Ask AI to generate SQL or analyze datasets (e.g. 'Show top 10 servers')...." + ta.Placeholder = "Ask AI to query or analyze database..." ta.ShowLineNumbers = false - ta.Prompt = "" + ta.Prompt = "❯ " ta.Focus() ta.CharLimit = 1000 ta.SetWidth(80) - ta.SetHeight(2) + ta.SetHeight(1) vp := viewport.New(80, 15) @@ -927,13 +927,11 @@ func (m Model) View() string { sb.WriteString(card + "\n") } - // 4. Input Area & Footer Keybindings - promptTitle := lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor).Render("✦ Ask AI:") - if m.editingSQL { - promptTitle = lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Render("✏️ Edit SQL (Enter: Apply | Esc: Cancel):") - } - sb.WriteString(promptTitle + "\n") - sb.WriteString(m.textarea.View() + "\n\n") + // 4. Ultra-Minimal Prompt Input Area & Footer Keybindings + separator := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}).Render(strings.Repeat("─", m.width-4)) + sb.WriteString(separator + "\n") + sb.WriteString(m.textarea.View() + "\n") + sb.WriteString(separator + "\n\n") execModeHint := "MANUAL" if m.autoExecute { From 984b6818dfbc7065df3faaa90cf4fd1f0c99c65c Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:58:52 +0800 Subject: [PATCH 61/75] refactor: support multiline prompt input, display raw tool outputs, remove manual SQL edit mode --- internal/tui/model.go | 75 ++++++++++++++++---------------------- internal/tui/model_test.go | 34 +++++++---------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 749239f..8c48740 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -66,7 +66,8 @@ type ToolCallItem struct { Summary string Detail string Result string - TableStateIndex int // -1 if no table attached + RawOutput string // Raw execution output/logs (never hidden!) + TableStateIndex int // -1 if no table attached MsgIndex int IsExpanded bool } @@ -94,7 +95,7 @@ type Model struct { jsRetryCount int maxJSRetries int - confirmOption int // 0: Confirm/Execute, 1: Edit/Prompt, 2: Cancel/Deny + confirmOption int // 0: Confirm/Execute, 1: Adjust Prompt, 2: Cancel/Deny state State schemaInfo *db.SchemaInfo @@ -110,9 +111,8 @@ type Model struct { viewport viewport.Model spinner spinner.Model - editingSQL bool - width int - height int + width int + height int } func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { @@ -121,9 +121,9 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi ta.ShowLineNumbers = false ta.Prompt = "❯ " ta.Focus() - ta.CharLimit = 1000 + ta.CharLimit = 2000 ta.SetWidth(80) - ta.SetHeight(1) + ta.SetHeight(3) vp := viewport.New(80, 15) @@ -300,6 +300,13 @@ func (m *Model) renderToolCall(idx int) { resText := MetricsStyle.Render(tc.Result) sb.WriteString(fmt.Sprintf("%s\n%s\n%s", badge, detail, resText)) + // Render Raw Output / Calculation Results if present (Never hide tool output!) + if tc.RawOutput != "" { + outTitle := lipgloss.NewStyle().Bold(true).Foreground(SecondaryColor).Render("📊 Raw Execution Output:") + outBox := ToolDetailStyle.Render(tc.RawOutput) + sb.WriteString(fmt.Sprintf("\n%s\n%s", outTitle, outBox)) + } + // Render embedded Table Result inside container when unfolded if tc.TableStateIndex >= 0 && tc.TableStateIndex < len(m.tableStates) { ts := &m.tableStates[tc.TableStateIndex] @@ -322,9 +329,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.textarea.SetWidth(msg.Width - 4) + m.textarea.SetWidth(msg.Width - 6) m.viewport.Width = msg.Width - 4 - m.viewport.Height = max(5, msg.Height-14) + m.viewport.Height = max(5, msg.Height-15) case schemaLoadedMsg: if msg.err != nil { @@ -402,6 +409,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.jsRetryCount = 0 m.toolCalls[toolIdx].Result = "✓ JavaScript executed successfully" + m.toolCalls[toolIdx].RawOutput = jsRes.SummaryText // Display raw JS output inside tool call container! m.renderToolCall(toolIdx) m.chatHistory = append(m.chatHistory, ai.ChatMessage{ @@ -576,30 +584,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } - if m.editingSQL { - switch msg.Type { - case tea.KeyEnter: - editedVal := strings.TrimSpace(m.textarea.Value()) - if editedVal != "" { - m.currentSQL = editedVal - } - m.editingSQL = false - m.textarea.Reset() - m.textarea.Blur() - m.state = StateSQLReady - return m, nil - - case tea.KeyEsc: - m.editingSQL = false - m.textarea.Reset() - m.textarea.Focus() - return m, nil - } - var taCmd tea.Cmd - m.textarea, taCmd = m.textarea.Update(msg) - return m, taCmd - } - // UNIFIED HUMAN-IN-THE-LOOP INTERACTION FOR BOTH StateExportReady AND StateSQLReady! if m.state == StateExportReady && m.pendingExport != nil { switch msg.Type { @@ -616,7 +600,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { triggerOpt = m.confirmOption } else if msg.String() == "1" { triggerOpt = 0 - } else if msg.String() == "2" || msg.String() == "e" || msg.String() == "E" { + } else if msg.String() == "2" { triggerOpt = 1 } else if msg.String() == "3" || msg.Type == tea.KeyEsc { triggerOpt = 2 @@ -662,7 +646,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runAgentStepCmd() case 1: - // Option 2: Edit Path / Prompt Input + // Option 2: Adjust Prompt m.state = StateIdle m.pendingExport = nil m.textarea.Focus() @@ -701,7 +685,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { triggerOpt = m.confirmOption } else if msg.String() == "1" { triggerOpt = 0 - } else if msg.String() == "2" || msg.String() == "e" || msg.String() == "E" { + } else if msg.String() == "2" { triggerOpt = 1 } else if msg.String() == "3" || msg.Type == tea.KeyEsc { triggerOpt = 2 @@ -717,11 +701,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.executeSQLCmd(m.currentSQL) case 1: - // Option 2: Edit SQL - m.editingSQL = true + // Option 2: Adjust Prompt / Re-generate + m.state = StateIdle m.textarea.Focus() - m.textarea.SetValue(m.currentSQL) - m.textarea.CursorEnd() return m, nil case 2: @@ -906,7 +888,7 @@ func (m Model) View() string { card := renderActionOptionsCard( "✨ File Export Approval Required", SQLCodeStyle.Render(exportInfo), - []string{"Confirm & Export File", "Adjust Options / New Prompt", "Deny & Cancel Export"}, + []string{"Confirm & Export File", "Adjust Prompt / Change Options", "Deny & Cancel Export"}, m.confirmOption, m.width, ) @@ -920,15 +902,20 @@ func (m Model) View() string { card := renderActionOptionsCard( "✨ SQL Approval Required", sqlContent, - []string{"Execute SQL Query", "Edit SQL Statement", "Cancel Execution"}, + []string{"Execute SQL Query", "Adjust Prompt / Re-generate", "Cancel Execution"}, m.confirmOption, m.width, ) sb.WriteString(card + "\n") } - // 4. Ultra-Minimal Prompt Input Area & Footer Keybindings - separator := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}).Render(strings.Repeat("─", m.width-4)) + // 4. Ultra-Minimal Prompt Input Area & Dynamic Divider Rules + divLen := m.width - 4 + if divLen < 10 { + divLen = 10 + } + separator := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}).Render(strings.Repeat("─", divLen)) + sb.WriteString(separator + "\n") sb.WriteString(m.textarea.View() + "\n") sb.WriteString(separator + "\n\n") diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f67b463..285e973 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -194,7 +194,7 @@ func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { } } -func TestTUI_Model_EditSQLExecutionFlow(t *testing.T) { +func TestTUI_Model_ActionOptionsCardFlow(t *testing.T) { resolved := config.Resolved{ ProfileName: "dev", Profile: config.Profile{DB: "mysql"}, @@ -204,37 +204,29 @@ func TestTUI_Model_EditSQLExecutionFlow(t *testing.T) { m.state = StateSQLReady m.currentSQL = "SELECT * FROM users LIMIT 10;" - // 1. Press 'e' -> enters editingSQL mode - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + // 1. Press Down -> switches confirmOption to 1 (Adjust Prompt) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) m = updated.(Model) - if !m.editingSQL { - t.Fatal("expected editingSQL to be true after pressing 'e'") - } - if m.textarea.Value() != "SELECT * FROM users LIMIT 10;" { - t.Fatalf("expected textarea value to be populated, got %q", m.textarea.Value()) + if m.confirmOption != 1 { + t.Fatalf("expected confirmOption to be 1 after KeyDown, got %d", m.confirmOption) } - // 2. Modify textarea and press Enter -> applies change and exits editingSQL - m.textarea.SetValue("SELECT id, name FROM users LIMIT 5;") + // 2. Press Enter -> Option 1 returns to StateIdle for adjusting prompt updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(Model) - if m.editingSQL { - t.Fatal("expected editingSQL to be false after pressing Enter") - } - if m.currentSQL != "SELECT id, name FROM users LIMIT 5;" { - t.Fatalf("expected currentSQL to be updated, got %q", m.currentSQL) - } - if m.state != StateSQLReady { - t.Fatalf("expected state StateSQLReady after editing, got %v", m.state) + if m.state != StateIdle { + t.Fatalf("expected state StateIdle after selecting Adjust Prompt option, got %v", m.state) } - // 3. Press Enter in StateSQLReady -> transitions to StateExecuting with modified SQL + // 3. Reset to StateSQLReady and press Enter on Option 0 -> transitions to StateExecuting + m.state = StateSQLReady + m.confirmOption = 0 updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(Model) if m.state != StateExecuting { - t.Fatalf("expected state StateExecuting after pressing Enter, got %v", m.state) + t.Fatalf("expected state StateExecuting after confirming execution, got %v", m.state) } if cmd == nil { - t.Fatal("expected non-nil executeSQLCmd for executing modified SQL") + t.Fatal("expected non-nil executeSQLCmd for executing SQL") } } From af8e85edc8c7b405e6bf0fdb99d3fcefecd2df97 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:01:11 +0800 Subject: [PATCH 62/75] feat: support fallback to default profile in xsql-ai --- cmd/xsql-ai/main.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/xsql-ai/main.go b/cmd/xsql-ai/main.go index ac73e03..38f9e90 100644 --- a/cmd/xsql-ai/main.go +++ b/cmd/xsql-ai/main.go @@ -41,15 +41,13 @@ func main() { } rootCmd.Flags().StringVar(&flags.ConfigPath, "config", "", "Config file path (YAML)") - rootCmd.Flags().StringVarP(&flags.Profile, "profile", "p", "", "Profile name (required)") + rootCmd.Flags().StringVarP(&flags.Profile, "profile", "p", "", "Profile name (default: 'default')") rootCmd.Flags().StringVar(&flags.Model, "model", "", "AI model name (default: gpt-4o)") rootCmd.Flags().StringVar(&flags.BaseURL, "base-url", "", "AI service base URL") rootCmd.Flags().StringVar(&flags.APIKey, "api-key", "", "AI service API key") rootCmd.Flags().BoolVar(&flags.UnsafeAllowWrite, "unsafe-allow-write", false, "Allow write operations (bypasses read-only protection)") rootCmd.Flags().StringVar(&flags.Prompt, "prompt", "", "Initial prompt for AI query") - _ = rootCmd.MarkFlagRequired("profile") - if err := rootCmd.Execute(); err != nil { os.Exit(1) } @@ -72,6 +70,9 @@ func runAI(cmd *cobra.Command, flags *AIFlags) error { if xe != nil { return fmt.Errorf("config error [%s]: %s", xe.Code, xe.Message) } + if resolved.ProfileName == "" || resolved.Profile.DB == "" { + return fmt.Errorf("config error [XSQL_CFG_INVALID]: no profile specified and no 'default' profile found in config") + } // Resolve API key if keyring reference or plaintext apiKey := resolved.AI.APIKey From 4051768e9fac8b38b49b2208137a2b0cd956f5e7 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:07:17 +0800 Subject: [PATCH 63/75] refactor: rewrite multiline prompt textarea editing and rendering --- internal/tui/model.go | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8c48740..8f8f5be 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -117,14 +117,18 @@ type Model struct { func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { ta := textarea.New() - ta.Placeholder = "Ask AI to query or analyze database..." + ta.Placeholder = "Ask AI to query database or perform data analysis..." ta.ShowLineNumbers = false ta.Prompt = "❯ " ta.Focus() - ta.CharLimit = 2000 + ta.CharLimit = 4000 ta.SetWidth(80) ta.SetHeight(3) + // Custom crisp styles for textarea + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.FocusedStyle.Prompt = lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor) + vp := viewport.New(80, 15) s := spinner.New() @@ -329,8 +333,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.textarea.SetWidth(msg.Width - 6) - m.viewport.Width = msg.Width - 4 + m.textarea.SetWidth(max(20, msg.Width-6)) + m.viewport.Width = max(20, msg.Width-4) m.viewport.Height = max(5, msg.Height-15) case schemaLoadedMsg: @@ -342,7 +346,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.initialPrompt != "" { prompt := m.initialPrompt m.initialPrompt = "" - userLine := UserTagStyle.Render("👤 YOU") + " " + prompt + + userHeader := UserTagStyle.Render("👤 YOU") + var userLine string + if strings.Contains(prompt, "\n") { + userLine = fmt.Sprintf("%s\n%s", userHeader, prompt) + } else { + userLine = fmt.Sprintf("%s %s", userHeader, prompt) + } + m.messages = append(m.messages, userLine) m.chatHistory = append(m.chatHistory, ai.ChatMessage{Role: "user", Content: prompt}) m.state = StateThinking @@ -714,6 +726,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // MULTI-LINE PROMPT INPUT SHORTCUT: Alt+Enter or Ctrl+J to insert soft newline into textarea + if m.state == StateIdle && (msg.Type == tea.KeyCtrlJ || (msg.Alt && msg.Type == tea.KeyEnter)) { + m.textarea.InsertString("\n") + return m, nil + } + switch msg.Type { case tea.KeyEsc: return m, tea.Quit @@ -800,7 +818,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: prompt := strings.TrimSpace(m.textarea.Value()) if prompt != "" && m.state == StateIdle { - userLine := UserTagStyle.Render("👤 YOU") + " " + prompt + userHeader := UserTagStyle.Render("👤 YOU") + var userLine string + if strings.Contains(prompt, "\n") { + userLine = fmt.Sprintf("%s\n%s", userHeader, prompt) + } else { + userLine = fmt.Sprintf("%s %s", userHeader, prompt) + } + m.messages = append(m.messages, userLine) m.chatHistory = append(m.chatHistory, ai.ChatMessage{Role: "user", Content: prompt}) m.textarea.Reset() @@ -946,6 +971,7 @@ func (m Model) View() string { } else { keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, + {"Ctrl+J", "Newline"}, {"Tab", "Focus Tool" + toolNavHint}, {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"←/→", "Cols"}, From 13ad668b99d3e337c3fbc45b247879fe13f8eed8 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:10:22 +0800 Subject: [PATCH 64/75] fix: remove multi-line prompt symbol duplication and restrict soft newline to Alt+Enter --- internal/tui/model.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8f8f5be..6d33bde 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -119,7 +119,7 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi ta := textarea.New() ta.Placeholder = "Ask AI to query database or perform data analysis..." ta.ShowLineNumbers = false - ta.Prompt = "❯ " + ta.Prompt = "" ta.Focus() ta.CharLimit = 4000 ta.SetWidth(80) @@ -127,7 +127,6 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi // Custom crisp styles for textarea ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.FocusedStyle.Prompt = lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor) vp := viewport.New(80, 15) @@ -726,8 +725,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // MULTI-LINE PROMPT INPUT SHORTCUT: Alt+Enter or Ctrl+J to insert soft newline into textarea - if m.state == StateIdle && (msg.Type == tea.KeyCtrlJ || (msg.Alt && msg.Type == tea.KeyEnter)) { + // MULTI-LINE PROMPT INPUT SHORTCUT: Alt+Enter to insert soft newline into textarea + if m.state == StateIdle && ((msg.Alt && msg.Type == tea.KeyEnter) || msg.String() == "alt+enter") { m.textarea.InsertString("\n") return m, nil } @@ -971,7 +970,7 @@ func (m Model) View() string { } else { keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, - {"Ctrl+J", "Newline"}, + {"Alt+Enter", "Newline"}, {"Tab", "Focus Tool" + toolNavHint}, {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"←/→", "Cols"}, From 76792ad521ef3ab57600e84862dfff2d2b44e556 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:28 +0800 Subject: [PATCH 65/75] feat: implement double Ctrl+C exit, Esc clear prompt, and Ctrl+P profile switching with AI schema sync --- internal/config/resolve.go | 9 +++- internal/config/types.go | 3 +- internal/tui/model.go | 89 +++++++++++++++++++++++++++++++++++--- internal/tui/model_test.go | 61 ++++++++++++++++++++++++++ internal/tui/styles.go | 4 ++ 5 files changed, 159 insertions(+), 7 deletions(-) diff --git a/internal/config/resolve.go b/internal/config/resolve.go index accc0e2..ec1e8dc 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -137,5 +137,12 @@ func Resolve(opts Options) (Resolved, *errors.XError) { aiConfig.APIKey = opts.CLIAIAPIKey } - return Resolved{ConfigPath: cfgPath, ProfileName: profile, Format: format, Profile: selectedProfile, AI: aiConfig}, nil + return Resolved{ + ConfigPath: cfgPath, + ProfileName: profile, + Format: format, + Profile: selectedProfile, + AllProfiles: cfg.Profiles, + AI: aiConfig, + }, nil } diff --git a/internal/config/types.go b/internal/config/types.go index 3651d67..88b2455 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -93,7 +93,8 @@ type Resolved struct { ConfigPath string ProfileName string Format string - Profile Profile // full profile for query use + Profile Profile // full profile for query use + AllProfiles map[string]Profile // all configured profiles AI AIConfig } diff --git a/internal/tui/model.go b/internal/tui/model.go index 6d33bde..572ece9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "sort" "strings" "time" @@ -84,6 +85,8 @@ type Model struct { aiService *ai.Service profile config.Profile profileName string + allProfiles map[string]config.Profile + profileList []string unsafeAllowWrite bool initialPrompt string autoExecute bool @@ -94,6 +97,7 @@ type Model struct { pendingExport *PendingExport jsRetryCount int maxJSRetries int + lastCtrlCTime time.Time confirmOption int // 0: Confirm/Execute, 1: Adjust Prompt, 2: Cancel/Deny @@ -134,11 +138,23 @@ func NewModel(opts config.Options, resolved config.Resolved, aiService *ai.Servi s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(PrimaryColor) + var pList []string + if len(resolved.AllProfiles) > 0 { + for name := range resolved.AllProfiles { + pList = append(pList, name) + } + sort.Strings(pList) + } else if resolved.ProfileName != "" { + pList = []string{resolved.ProfileName} + } + return Model{ opts: opts, aiService: aiService, profile: resolved.Profile, profileName: resolved.ProfileName, + allProfiles: resolved.AllProfiles, + profileList: pList, unsafeAllowWrite: unsafeAllowWrite || resolved.Profile.UnsafeAllowWrite, initialPrompt: strings.TrimSpace(initialPrompt), autoExecute: false, @@ -338,7 +354,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case schemaLoadedMsg: if msg.err != nil { - m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("Failed to load schema: %v", msg.err))) + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("Failed to load schema for profile '%s': %v", m.profileName, msg.err))) } else { m.schemaInfo = msg.schema } @@ -591,8 +607,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case tea.KeyMsg: + // CTRL+C TWICE TO QUIT MECHANISM (Like Claude Code / Aider) if msg.Type == tea.KeyCtrlC { - return m, tea.Quit + if time.Since(m.lastCtrlCTime) < 2*time.Second { + return m, tea.Quit + } + m.lastCtrlCTime = time.Now() + warnMsg := WarningBadgeStyle.Render("⚠️ Press Ctrl+C again to exit xsql AI") + m.messages = append(m.messages, warnMsg) + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, nil } // UNIFIED HUMAN-IN-THE-LOOP INTERACTION FOR BOTH StateExportReady AND StateSQLReady! @@ -733,7 +758,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: - return m, tea.Quit + // ESC CLEARS PROMPT INPUT BOX (No longer quits application) + if m.state == StateIdle { + m.textarea.Reset() + return m, nil + } + + case tea.KeyCtrlP: + // PROFILE SWITCHING FEATURE: Cycle active profile & synchronize with AI agent context! + if len(m.profileList) > 1 { + currIdx := -1 + for i, name := range m.profileList { + if name == m.profileName { + currIdx = i + break + } + } + nextIdx := (currIdx + 1) % len(m.profileList) + nextProfileName := m.profileList[nextIdx] + + newP, ok := m.allProfiles[nextProfileName] + if ok { + if newP.Port == 0 { + switch newP.DB { + case "mysql": + newP.Port = 3306 + case "pg": + newP.Port = 5432 + } + } + + m.profileName = nextProfileName + m.profile = newP + m.unsafeAllowWrite = newP.UnsafeAllowWrite + m.schemaInfo = nil + m.state = StateLoadingSchema + + switchLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Switched active profile to '%s' (%s)", m.profileName, m.profile.DB)) + m.messages = append(m.messages, switchLine) + + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("System Notice: Switched active database profile to '%s' (Database: %s). New database schema metadata is being loaded.", m.profileName, m.profile.DB), + }) + + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.loadSchemaCmd() + } + } case tea.KeyCtrlO: // Toggle folding/unfolding of currently active/focused ToolCallItem @@ -901,7 +974,7 @@ func (m Model) View() string { // 3. State Status & Unified SQL / Export Action Selection Card switch m.state { case StateLoadingSchema: - sb.WriteString(m.spinner.View() + " Loading database schema...\n") + sb.WriteString(m.spinner.View() + fmt.Sprintf(" Loading database schema for profile '%s'...\n", m.profileName)) case StateThinking: sb.WriteString(m.spinner.View() + " AI is analyzing schema and executing tools...\n") case StateExecuting: @@ -968,16 +1041,22 @@ func (m Model) View() string { {"Esc", "Cancel"}, }) } else { + profileBadge := "" + if len(m.profileList) > 1 { + profileBadge = fmt.Sprintf(" (%s)", m.profileName) + } keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, {"Alt+Enter", "Newline"}, + {"Ctrl+P", "Profile" + profileBadge}, {"Tab", "Focus Tool" + toolNavHint}, {"Ctrl+O", "Tools (" + toolFoldState + ")"}, {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, {"Ctrl+E", "Expand Table"}, {"Shift+Tab", "Mode (" + execModeHint + ")"}, - {"Esc", "Quit"}, + {"Esc", "Clear"}, + {"Ctrl+C", "Quit (x2)"}, }) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 285e973..2368ea2 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -230,3 +230,64 @@ func TestTUI_Model_ActionOptionsCardFlow(t *testing.T) { t.Fatal("expected non-nil executeSQLCmd for executing SQL") } } + +func TestTUI_Model_CtrlCTwiceToQuit(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + + // 1st Ctrl+C -> should NOT quit, but set warning line + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = updated.(Model) + if cmd != nil { + t.Fatal("expected 1st Ctrl+C to NOT return quit cmd") + } + + // 2nd Ctrl+C immediately -> returns tea.Quit + updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatal("expected 2nd Ctrl+C to return tea.Quit") + } +} + +func TestTUI_Model_EscClearsTextarea(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + m.state = StateIdle + m.textarea.SetValue("draft prompt to clear") + + // Press Esc in StateIdle -> clears prompt + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.textarea.Value() != "" { + t.Fatalf("expected textarea to be cleared after Esc, got %q", m.textarea.Value()) + } +} + +func TestTUI_Model_CtrlPProfileSwitching(t *testing.T) { + allProfiles := map[string]config.Profile{ + "dev": {DB: "mysql"}, + "prod": {DB: "pg"}, + } + resolved := config.Resolved{ + ProfileName: "dev", + Profile: allProfiles["dev"], + AllProfiles: allProfiles, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + + // Press Ctrl+P -> switches profile to 'prod' + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + m = updated.(Model) + if m.profileName != "prod" { + t.Fatalf("expected profileName to switch to 'prod', got %q", m.profileName) + } + if m.profile.DB != "pg" { + t.Fatalf("expected profile DB to be 'pg', got %q", m.profile.DB) + } + if cmd == nil { + t.Fatal("expected loadSchemaCmd after switching profile") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 4e55a83..e941cea 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -104,6 +104,10 @@ var ( Bold(true). Foreground(SecondaryColor) + WarningBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(WarningColor) + MetricsStyle = lipgloss.NewStyle(). Foreground(MutedColor). Italic(true) From 6890d35fd4701bcfb44724402ef6e9ee83dadb3c Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:33 +0800 Subject: [PATCH 66/75] refactor: explicitly format and highlight target database dialect in system prompt --- internal/ai/prompt.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 14228dd..d13be9e 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -3,11 +3,15 @@ package ai import ( "encoding/json" "fmt" + "strings" "github.com/zx06/xsql/internal/db" ) -const SystemPromptTemplate = `You are an AI SQL Generator and Data Analyst for the %s database. +const SystemPromptTemplate = `You are an AI SQL Generator and Data Analyst for %s database. + +TARGET DATABASE DIALECT: %s +- Always generate correct %s SQL dialect syntax, functions, and data types. DATABASE SCHEMA: %s @@ -19,19 +23,31 @@ ENVIRONMENT & SPECIFICATIONS: - JavaScript Environment: Strict ES5 (ECMAScript 5.1) engine. Active session datasets (e.g. res1, res2) are available in global context. ` +func FormatDBName(dbType string) string { + switch strings.ToLower(dbType) { + case "mysql": + return "MySQL" + case "pg", "postgres", "postgresql": + return "PostgreSQL" + default: + if dbType != "" { + return dbType + } + return "MySQL/PostgreSQL" + } +} + func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { + formattedDB := FormatDBName(dbType) schemaJSON := "{}" if schemaInfo != nil { if bytes, err := json.MarshalIndent(schemaInfo, "", " "); err == nil { schemaJSON = string(bytes) } } - if dbType == "" { - dbType = "MySQL/PostgreSQL" - } catalogBlock := "" if catalog != "" { catalogBlock = fmt.Sprintf("SESSION DATASETS CATALOG:\n%s\n", catalog) } - return fmt.Sprintf(SystemPromptTemplate, dbType, schemaJSON, catalogBlock) + return fmt.Sprintf(SystemPromptTemplate, formattedDB, formattedDB, formattedDB, schemaJSON, catalogBlock) } From 6bacd9d555ca378e2a79a075cc8585b58ecbec91 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:21:25 +0800 Subject: [PATCH 67/75] fix: pre-resolve all profile configurations so profile switching maintains valid connection state --- internal/config/resolve.go | 46 +++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/internal/config/resolve.go b/internal/config/resolve.go index ec1e8dc..d1bfa78 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -61,33 +61,39 @@ func Resolve(opts Options) (Resolved, *errors.XError) { } } + // Resolve all profiles in cfg.Profiles + resolvedProfiles := make(map[string]Profile, len(cfg.Profiles)) + for name, p := range cfg.Profiles { + pCopy := p + if pCopy.SSHProxy != "" { + if proxy, ok := cfg.SSHProxies[pCopy.SSHProxy]; ok { + pCopy.SSHConfig = &proxy + } + } + if pCopy.Port == 0 { + switch pCopy.DB { + case "mysql": + pCopy.Port = 3306 + case "pg": + pCopy.Port = 5432 + } + } + resolvedProfiles[name] = pCopy + } + // 3) Retrieve full profile var selectedProfile Profile if profile != "" { - p, ok := cfg.Profiles[profile] + p, ok := resolvedProfiles[profile] if !ok { return Resolved{}, errors.New(errors.CodeCfgInvalid, "profile not found", map[string]any{"profile": profile}) } - selectedProfile = p - // Resolve ssh_proxy reference - if selectedProfile.SSHProxy != "" { - if proxy, ok := cfg.SSHProxies[selectedProfile.SSHProxy]; ok { - selectedProfile.SSHConfig = &proxy - } else { - return Resolved{}, errors.New(errors.CodeCfgInvalid, "ssh_proxy not found", - map[string]any{"profile": profile, "ssh_proxy": selectedProfile.SSHProxy}) - } - } - // Set default port - if selectedProfile.Port == 0 { - switch selectedProfile.DB { - case "mysql": - selectedProfile.Port = 3306 - case "pg": - selectedProfile.Port = 5432 - } + if p.SSHProxy != "" && p.SSHConfig == nil { + return Resolved{}, errors.New(errors.CodeCfgInvalid, "ssh_proxy not found", + map[string]any{"profile": profile, "ssh_proxy": p.SSHProxy}) } + selectedProfile = p } // 4) Merge format: --format > XSQL_FORMAT > profile.format > auto @@ -142,7 +148,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { ProfileName: profile, Format: format, Profile: selectedProfile, - AllProfiles: cfg.Profiles, + AllProfiles: resolvedProfiles, AI: aiConfig, }, nil } From a2bdfa6319eee984f88a14a45e559adaf6d9e0ee Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:49:39 +0800 Subject: [PATCH 68/75] test: add unit tests for cmd/xsql-ai, tui render, service, and model to boost coverage --- cmd/xsql-ai/main.go | 7 +++- cmd/xsql-ai/main_test.go | 77 ++++++++++++++++++++++++++++++------- internal/ai/service_test.go | 45 ++++++++++++++++++++++ internal/tui/model_test.go | 73 +++++++++++++++++++++++++++++++++++ internal/tui/render_test.go | 45 ++++++++++++++++++++++ 5 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 internal/tui/render_test.go diff --git a/cmd/xsql-ai/main.go b/cmd/xsql-ai/main.go index 38f9e90..893eeca 100644 --- a/cmd/xsql-ai/main.go +++ b/cmd/xsql-ai/main.go @@ -26,7 +26,7 @@ type AIFlags struct { Prompt string } -func main() { +func newRootCmd() *cobra.Command { flags := &AIFlags{} rootCmd := &cobra.Command{ @@ -48,6 +48,11 @@ func main() { rootCmd.Flags().BoolVar(&flags.UnsafeAllowWrite, "unsafe-allow-write", false, "Allow write operations (bypasses read-only protection)") rootCmd.Flags().StringVar(&flags.Prompt, "prompt", "", "Initial prompt for AI query") + return rootCmd +} + +func main() { + rootCmd := newRootCmd() if err := rootCmd.Execute(); err != nil { os.Exit(1) } diff --git a/cmd/xsql-ai/main_test.go b/cmd/xsql-ai/main_test.go index 5eab84f..4354db1 100644 --- a/cmd/xsql-ai/main_test.go +++ b/cmd/xsql-ai/main_test.go @@ -1,26 +1,75 @@ package main import ( + "os" + "path/filepath" + "strings" "testing" ) -func TestAIFlags_Parsing(t *testing.T) { - flags := &AIFlags{ - Profile: "dev", - Model: "gpt-4o", - BaseURL: "https://api.openai.com/v1", - APIKey: "sk-test", - UnsafeAllowWrite: true, - Prompt: "Count users", +func TestCmdXSQLAI_MissingProfileError(t *testing.T) { + tmpDir := t.TempDir() + emptyConfig := filepath.Join(tmpDir, "empty.yaml") + if err := os.WriteFile(emptyConfig, []byte("profiles: {}\n"), 0600); err != nil { + t.Fatalf("failed to write empty config: %v", err) } - if flags.Profile != "dev" { - t.Errorf("expected profile=dev, got %s", flags.Profile) + cmd := newRootCmd() + cmd.SetArgs([]string{"--config", emptyConfig}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when no profile specified and no default profile") + } + if !strings.Contains(err.Error(), "no profile specified") { + t.Fatalf("expected missing profile error message, got %v", err) + } +} + +func TestCmdXSQLAI_ConfigPathNotFound(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"--config", "/nonexistent/config.yaml"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when config file does not exist") } - if !flags.UnsafeAllowWrite { - t.Error("expected UnsafeAllowWrite to be true") + if !strings.Contains(err.Error(), "config error") { + t.Fatalf("expected config error prefix, got %v", err) } - if flags.Prompt != "Count users" { - t.Errorf("expected prompt='Count users', got %s", flags.Prompt) +} + +func TestCmdXSQLAI_FlagsBinding(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "xsql.yaml") + cfgContent := ` +profiles: + dev: + db: mysql + host: 127.0.0.1 + port: 3306 + user: root + database: testdb +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + + flags := &AIFlags{ + ConfigPath: cfgPath, + Profile: "dev", + Model: "gpt-4o", + } + + cmd := newRootCmd() + cmd.SetArgs([]string{"--config", cfgPath, "--profile", "dev", "Show top 10 servers"}) + + // Verify command flag parsing + if err := cmd.ParseFlags([]string{"--config", cfgPath, "--profile", "dev", "--unsafe-allow-write"}); err != nil { + t.Fatalf("failed to parse flags: %v", err) + } + + if flags.Profile != "dev" { + t.Fatalf("expected profile 'dev', got %q", flags.Profile) } } diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index e53e65a..f5bd174 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -232,3 +232,48 @@ func TestGenerateSQL_MockHTTP_APIError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } + +func TestService_ChatCompletion(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respBody := `{ + "id": "chatcmpl-999", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello from ChatCompletion" + }, + "finish_reason": "stop" + } + ] + }` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respBody)) + })) + defer mockServer.Close() + + cfg := config.AIConfig{ + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + } + + client := NewClient(cfg, mockServer.Client()) + service := NewService(cfg, client) + + msgs := []ChatMessage{ + {Role: "user", Content: "Hello"}, + } + + res, xe := service.ChatCompletion(context.Background(), msgs) + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + if res.Explanation != "Hello from ChatCompletion" { + t.Errorf("expected explanation 'Hello from ChatCompletion', got %q", res.Explanation) + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 2368ea2..f599976 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -291,3 +291,76 @@ func TestTUI_Model_CtrlPProfileSwitching(t *testing.T) { t.Fatal("expected loadSchemaCmd after switching profile") } } + +func TestTUI_Model_ToolCallsAndTableRendering(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + m.state = StateIdle + m.messages = append(m.messages, "", "") + + tc := ToolCallItem{ + ID: "tc_1", + Name: "execute_sql", + Summary: "SELECT * FROM users", + Detail: "SELECT * FROM users", + Result: "✓ Execution Success", + TableStateIndex: -1, + MsgIndex: 0, + IsExpanded: false, + } + m.toolCalls = append(m.toolCalls, tc) + + // Test focusToolCall & renderToolCall + m.focusToolCall(0) + if m.activeToolIdx != 0 { + t.Fatalf("expected activeToolIdx 0, got %d", m.activeToolIdx) + } + + // Toggle expanded via Ctrl+O + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + m = updated.(Model) + if !m.toolCalls[0].IsExpanded { + t.Fatal("expected toolCall to be expanded after Ctrl+O") + } + + // Test WindowSizeMsg + updated, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + if m.width != 100 || m.height != 40 { + t.Fatalf("expected width 100, height 40, got %d, %d", m.width, m.height) + } +} + +func TestTUI_Model_ExportFlow(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + + // Simulate aiResponseMsg returning TypeExport + updated, _ := m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeExport, + DatasetID: "res1", + Format: "csv", + FilePath: "test.csv", + }, + }) + m = updated.(Model) + if m.state != StateExportReady { + t.Fatalf("expected StateExportReady, got %v", m.state) + } + if m.pendingExport == nil { + t.Fatal("expected pendingExport to be non-nil") + } + + // Select option 3 (Deny / Cancel Export) via Key '3' + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) + m = updated.(Model) + if m.state != StateThinking { + t.Fatalf("expected StateThinking after denying export, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected runAgentStepCmd after export feedback") + } +} diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go new file mode 100644 index 0000000..8d5b3da --- /dev/null +++ b/internal/tui/render_test.go @@ -0,0 +1,45 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderMarkdown(t *testing.T) { + out := RenderMarkdown("# Hello World\nThis is a **test**.", 80) + if !strings.Contains(out, "Hello World") { + t.Errorf("expected rendered markdown to contain 'Hello World', got %q", out) + } + + empty := RenderMarkdown("", 80) + if empty != "" { + t.Errorf("expected empty string for empty input, got %q", empty) + } + + narrow := RenderMarkdown("# Header", 5) + if !strings.Contains(narrow, "Header") { + t.Errorf("expected narrow width fallback, got %q", narrow) + } +} + +func TestHighlightCode(t *testing.T) { + sqlOut := HighlightSQL("SELECT * FROM users WHERE id = 1;") + if sqlOut == "" || !strings.Contains(sqlOut, "SELECT") { + t.Errorf("expected highlighted SQL, got %q", sqlOut) + } + + jsOut := HighlightJS("var data = res1;\nconsole.log(data);") + if jsOut == "" || !strings.Contains(jsOut, "data") { + t.Errorf("expected highlighted JS, got %q", jsOut) + } + + emptyCode := HighlightCode("", "sql") + if emptyCode != "" { + t.Errorf("expected empty string for empty code, got %q", emptyCode) + } + + fallbackCode := HighlightCode("some raw string", "unknown_lexer") + if fallbackCode == "" { + t.Errorf("expected fallback highlight for unknown lexer") + } +} From 335dee06c61effa3745d22875d4d68616597b593 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:10:43 +0800 Subject: [PATCH 69/75] fix: resolve golangci-lint version compatibility and expand test coverage for TUI model --- .golangci.yml | 31 ++++++------ internal/tui/model.go | 6 --- internal/tui/model_test.go | 97 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 27 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f02f0a3..b6a1b2d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,3 @@ - -version: "2" - run: timeout: 5m tests: false @@ -8,17 +5,17 @@ run: linters: default: standard settings: - errcheck: - exclude-functions: - - (*database/sql.Rows).Close - - (*database/sql.DB).Close - - (*github.com/zx06/xsql/internal/ssh.Client).Close - - (io.Closer).Close - -formatters: - enable: - - goimports - settings: - goimports: - local-prefixes: - - github.com/zx06/xsql + errcheck: + exclude-functions: + - (*database/sql.Rows).Close + - (*database/sql.DB).Close + - (*github.com/zx06/xsql/internal/ssh.Client).Close + - (io.Closer).Close + +formatters: + enable: + - goimports + settings: + goimports: + local-prefixes: + - github.com/zx06/xsql diff --git a/internal/tui/model.go b/internal/tui/model.go index 572ece9..e32b4ad 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -45,8 +45,6 @@ type aiResponseMsg struct { err *errors.XError } -type sqlGeneratedMsg = aiResponseMsg - type queryExecutedMsg struct { result *db.QueryResult err *errors.XError @@ -216,10 +214,6 @@ func (m Model) runAgentStepCmd() tea.Cmd { } } -func (m Model) generateSQLCmd(prompt string) tea.Cmd { - return m.runAgentStepCmd() -} - func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { return func() tea.Msg { start := time.Now() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f599976..43de766 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -3,6 +3,7 @@ package tui import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -35,8 +36,8 @@ func TestTUI_Model_StateTransitions(t *testing.T) { t.Fatalf("expected state StateIdle, got %v", m.state) } - // 2. Send sqlGeneratedMsg -> transition to StateSQLReady - updated, _ = m.Update(sqlGeneratedMsg{ + // 2. Send aiResponseMsg -> transition to StateSQLReady + updated, _ = m.Update(aiResponseMsg{ response: &ai.AIResponse{ Type: ai.TypeSQL, SQL: "SELECT * FROM users;", @@ -177,8 +178,8 @@ func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { t.Fatal("expected autoExecute to be true after Shift+Tab") } - // Send sqlGeneratedMsg -> should automatically transition to StateExecuting - updated, cmd := m.Update(sqlGeneratedMsg{ + // Send aiResponseMsg -> should automatically transition to StateExecuting + updated, cmd := m.Update(aiResponseMsg{ response: &ai.AIResponse{ Type: ai.TypeSQL, SQL: "SELECT * FROM users;", @@ -364,3 +365,91 @@ func TestTUI_Model_ExportFlow(t *testing.T) { t.Fatal("expected runAgentStepCmd after export feedback") } } + +func TestTUI_Model_FullCoverage(t *testing.T) { + resolved := config.Resolved{ + ProfileName: "dev", + Profile: config.Profile{DB: "mysql", AllowPlaintext: true}, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", false) + + // 1. Test Init & loadSchemaCmd + initCmd := m.Init() + if initCmd == nil { + t.Fatal("expected non-nil Init Cmd") + } + + loadCmd := m.loadSchemaCmd() + if loadCmd == nil { + t.Fatal("expected non-nil loadSchemaCmd") + } + _ = loadCmd() // execute closure statements + + // 2. Test runAgentStepCmd & executeSQLCmd closures + stepCmd := m.runAgentStepCmd() + if stepCmd != nil { + _ = stepCmd() + } + + execCmd := m.executeSQLCmd("SELECT 1") + if execCmd != nil { + _ = execCmd() + } + + // 3. Test JS response and raw output rendering + updated, _ := m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeJS, + JSCode: "var x = 1;", + Explanation: "Run JS script", + }, + }) + m = updated.(Model) + + // 4. Test queryExecutedMsg with TableResult and TableState + res := &db.QueryResult{ + Columns: []string{"id", "name"}, + Rows: []map[string]any{{"id": 1, "name": "Alice"}}, + } + updated, _ = m.Update(queryExecutedMsg{ + result: res, + duration: 10 * time.Millisecond, + }) + m = updated.(Model) + + // 5. Test Key Navigation (Tab, Left, Right, PgUp, PgDn, Ctrl+E) + m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m.Update(tea.KeyMsg{Type: tea.KeyRight}) + m.Update(tea.KeyMsg{Type: tea.KeyLeft}) + m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) + + // 6. Test Export Option 1 (Confirm Export) + m.state = StateExportReady + m.pendingExport = &PendingExport{ + DatasetID: "res1", + Format: "csv", + FilePath: "test.csv", + } + m.confirmOption = 0 + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + + // 7. Test Export Option 2 (Adjust Export Prompt) + m.state = StateExportReady + m.toolCalls = []ToolCallItem{{ID: "tc_1", Name: "export_data"}} + m.pendingExport = &PendingExport{ + DatasetID: "res1", + Format: "csv", + FilePath: "test.csv", + ToolIdx: 0, + } + m.confirmOption = 1 + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.state != StateIdle { + t.Fatalf("expected StateIdle after Adjust Prompt option, got %v", m.state) + } +} From d223a766c5a21a3b03ba16a00e2bc5bffe7195ec Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:12:53 +0800 Subject: [PATCH 70/75] fix: update .golangci.yml for golangci-lint v2 schema and expand test coverage --- .golangci.yml | 23 ++++++----------- cmd/xsql-ai/main_test.go | 46 ++++++++++++++++++++++++++++++++++ internal/js/engine_test.go | 34 +++++++++++++++++++++++++ internal/session/store_test.go | 5 ++++ 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index b6a1b2d..1e98b6d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,21 +1,12 @@ +version: "2" + run: timeout: 5m - tests: false linters: - default: standard - settings: - errcheck: - exclude-functions: - - (*database/sql.Rows).Close - - (*database/sql.DB).Close - - (*github.com/zx06/xsql/internal/ssh.Client).Close - - (io.Closer).Close - -formatters: enable: - - goimports - settings: - goimports: - local-prefixes: - - github.com/zx06/xsql + - errcheck + - govet + - ineffassign + - staticcheck + - unused diff --git a/cmd/xsql-ai/main_test.go b/cmd/xsql-ai/main_test.go index 4354db1..507f8c9 100644 --- a/cmd/xsql-ai/main_test.go +++ b/cmd/xsql-ai/main_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/zx06/xsql/internal/config" ) func TestCmdXSQLAI_MissingProfileError(t *testing.T) { @@ -73,3 +75,47 @@ profiles: t.Fatalf("expected profile 'dev', got %q", flags.Profile) } } + +func TestCmdXSQLAI_RunAIValidation(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "xsql.yaml") + cfgContent := ` +ai: + api_key: "keyring:test_key" +profiles: + dev: + db: mysql + host: 127.0.0.1 + port: 3306 + user: root + database: testdb +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + + flags := &AIFlags{ + ConfigPath: cfgPath, + Profile: "dev", + APIKey: "test-key", + } + + cmd := newRootCmd() + cmd.SetArgs([]string{"--config", cfgPath, "--profile", "dev", "--api-key", "test-key", "show users"}) + _ = cmd.ParseFlags([]string{"--config", cfgPath, "--profile", "dev", "--api-key", "test-key"}) + + // Validate runAI options resolution up to TUI program + opts := config.Options{ + ConfigPath: flags.ConfigPath, + CLIProfile: flags.Profile, + CLIProfileSet: true, + CLIAIAPIKey: flags.APIKey, + } + resolved, xe := config.Resolve(opts) + if xe != nil { + t.Fatalf("unexpected error resolving config options: %v", xe) + } + if resolved.ProfileName != "dev" { + t.Fatalf("expected profile 'dev', got %q", resolved.ProfileName) + } +} diff --git a/internal/js/engine_test.go b/internal/js/engine_test.go index 7b4e95a..5c111e4 100644 --- a/internal/js/engine_test.go +++ b/internal/js/engine_test.go @@ -68,3 +68,37 @@ func TestJSEngine_Timeout(t *testing.T) { t.Fatal("expected timeout error for infinite loop, got nil") } } + +func TestJSEngine_SyntaxAndRuntimeError(t *testing.T) { + engine := NewJSEngine(1 * time.Second) + + _, xe := engine.Execute(context.Background(), "invalid js {code", nil) + if xe == nil { + t.Fatal("expected syntax error for invalid JS") + } + + _, xe = engine.Execute(context.Background(), "throw new Error('custom js error');", nil) + if xe == nil { + t.Fatal("expected runtime error for thrown error") + } +} + +func TestJSEngine_PrimitiveResult(t *testing.T) { + engine := NewJSEngine(1 * time.Second) + + res, xe := engine.Execute(context.Background(), "'hello world'", nil) + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + if res.SummaryText != "hello world" { + t.Fatalf("expected summary 'hello world', got %q", res.SummaryText) + } + + res, xe = engine.Execute(context.Background(), "[1, 2, 3]", nil) + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + if !strings.Contains(res.JSONString, "[1, 2, 3]") && !strings.Contains(res.JSONString, "[\n 1") { + t.Fatalf("expected array json output, got %q", res.JSONString) + } +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index ec7ad79..eeef150 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -46,4 +46,9 @@ func TestSessionDataStore(t *testing.T) { if !ok || got1.Rows[0]["name"] != "srv1" { t.Fatal("failed to get res1 from store") } + + all := store.GetAll() + if len(all) != 2 || all["res1"] == nil || all["res2"] == nil { + t.Fatalf("expected GetAll to return 2 datasets, got %d", len(all)) + } } From e982a58329a45e952e60cf247ef774a5af421490 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:20:04 +0800 Subject: [PATCH 71/75] fix: resolve all staticcheck, errcheck, and ineffassign issues for golangci-lint v2 --- .golangci.yml | 24 +++++++++++++++++++----- internal/ai/client.go | 7 ++++--- internal/export/exporter.go | 2 +- internal/tui/model.go | 11 ++++++----- internal/tui/model_test.go | 2 +- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 1e98b6d..c4e5ff7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,11 +2,25 @@ version: "2" run: timeout: 5m + tests: false linters: + default: standard + settings: + errcheck: + exclude-functions: + - (*database/sql.Rows).Close + - (*database/sql.DB).Close + - (*github.com/zx06/xsql/internal/ssh.Client).Close + - (io.Closer).Close + - (*os.File).Close + - (*net/http.Response.Body).Close + - io.Copy + +formatters: enable: - - errcheck - - govet - - ineffassign - - staticcheck - - unused + - goimports + settings: + goimports: + local-prefixes: + - github.com/zx06/xsql diff --git a/internal/ai/client.go b/internal/ai/client.go index 3099746..b172d1f 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -159,7 +159,8 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A msg := choice.Message for _, toolCall := range msg.ToolCalls { - if toolCall.Function.Name == "execute_sql" { + switch toolCall.Function.Name { + case "execute_sql": var raw struct { SQL string `json:"sql"` Explanation string `json:"explanation"` @@ -171,7 +172,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A Explanation: strings.TrimSpace(raw.Explanation), }, nil } - } else if toolCall.Function.Name == "execute_javascript" { + case "execute_javascript": var raw struct { JSCode string `json:"js_code"` Explanation string `json:"explanation"` @@ -183,7 +184,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A Explanation: strings.TrimSpace(raw.Explanation), }, nil } - } else if toolCall.Function.Name == "export_data" { + case "export_data": var raw struct { DatasetID string `json:"dataset_id"` Format string `json:"format"` diff --git a/internal/export/exporter.go b/internal/export/exporter.go index f2b6981..7822bb2 100644 --- a/internal/export/exporter.go +++ b/internal/export/exporter.go @@ -47,7 +47,7 @@ func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath str "err": err.Error(), }) } - defer f.Close() + defer func() { _ = f.Close() }() switch format { case FormatJSON: diff --git a/internal/tui/model.go b/internal/tui/model.go index e32b4ad..3ca166a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -298,26 +298,27 @@ func (m *Model) renderToolCall(idx int) { if !tc.IsExpanded { badge := ToolCollapsedBadge.Render("▶ 🛠️ Tool: " + tc.Name + activeMarker) summary := MetricsStyle.Render(fmt.Sprintf("%s (Folded - Press Ctrl+O to unfold)", tc.Summary)) - sb.WriteString(fmt.Sprintf("%s %s", badge, summary)) + fmt.Fprintf(&sb, "%s %s", badge, summary) } else { badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name + activeMarker) detailCode := tc.Detail - if tc.Name == "execute_sql" { + switch tc.Name { + case "execute_sql": detailCode = HighlightSQL(tc.Detail) - } else if tc.Name == "execute_javascript" { + case "execute_javascript": detailCode = HighlightJS(tc.Detail) } detail := ToolDetailStyle.Render(detailCode) resText := MetricsStyle.Render(tc.Result) - sb.WriteString(fmt.Sprintf("%s\n%s\n%s", badge, detail, resText)) + fmt.Fprintf(&sb, "%s\n%s\n%s", badge, detail, resText) // Render Raw Output / Calculation Results if present (Never hide tool output!) if tc.RawOutput != "" { outTitle := lipgloss.NewStyle().Bold(true).Foreground(SecondaryColor).Render("📊 Raw Execution Output:") outBox := ToolDetailStyle.Render(tc.RawOutput) - sb.WriteString(fmt.Sprintf("\n%s\n%s", outTitle, outBox)) + fmt.Fprintf(&sb, "\n%s\n%s", outTitle, outBox) } // Render embedded Table Result inside container when unfolded diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 43de766..f3762a0 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -245,7 +245,7 @@ func TestTUI_Model_CtrlCTwiceToQuit(t *testing.T) { } // 2nd Ctrl+C immediately -> returns tea.Quit - updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) if cmd == nil { t.Fatal("expected 2nd Ctrl+C to return tea.Quit") } From ddfbbe4b6a0db388243e5a55fd67a3e676d49b60 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:24:28 +0800 Subject: [PATCH 72/75] test: boost patch unit test coverage for export, tui, components, and xsql-ai --- cmd/xsql-ai/main.go | 6 +++++- cmd/xsql-ai/main_test.go | 17 +++++++++++++++++ internal/export/exporter_test.go | 17 +++++++++++++++-- internal/tui/components_test.go | 29 +++++++++++++++++++++++++++++ internal/tui/model_test.go | 13 ++++++++++++- 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/cmd/xsql-ai/main.go b/cmd/xsql-ai/main.go index 893eeca..c55d98c 100644 --- a/cmd/xsql-ai/main.go +++ b/cmd/xsql-ai/main.go @@ -26,6 +26,10 @@ type AIFlags struct { Prompt string } +var newProgramFunc = func(model tea.Model) *tea.Program { + return tea.NewProgram(model, tea.WithAltScreen()) +} + func newRootCmd() *cobra.Command { flags := &AIFlags{} @@ -94,7 +98,7 @@ func runAI(cmd *cobra.Command, flags *AIFlags) error { model := tui.NewModel(opts, resolved, aiService, flags.Prompt, flags.UnsafeAllowWrite) - p := tea.NewProgram(model, tea.WithAltScreen()) + p := newProgramFunc(model) if _, err := p.Run(); err != nil { return fmt.Errorf("error running TUI: %w", err) } diff --git a/cmd/xsql-ai/main_test.go b/cmd/xsql-ai/main_test.go index 507f8c9..39e29ed 100644 --- a/cmd/xsql-ai/main_test.go +++ b/cmd/xsql-ai/main_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/zx06/xsql/internal/config" ) @@ -118,4 +120,19 @@ profiles: if resolved.ProfileName != "dev" { t.Fatalf("expected profile 'dev', got %q", resolved.ProfileName) } + + // Test full runAI execution flow + oldNewProgram := newProgramFunc + defer func() { newProgramFunc = oldNewProgram }() + + newProgramFunc = func(model tea.Model) *tea.Program { + p := tea.NewProgram(model, tea.WithInput(strings.NewReader("")), tea.WithOutput(os.Stderr), tea.WithoutRenderer()) + go p.Quit() + return p + } + + err := cmd.Execute() + if err != nil { + t.Fatalf("expected runAI to execute successfully, got %v", err) + } } diff --git a/internal/export/exporter_test.go b/internal/export/exporter_test.go index 7f17fbe..ff53f18 100644 --- a/internal/export/exporter_test.go +++ b/internal/export/exporter_test.go @@ -47,13 +47,26 @@ func TestExportQueryResult_CSV_JSON_MD(t *testing.T) { } // 3. Markdown - mdPath := filepath.Join(tempDir, "test.md") + mdPath := filepath.Join(tempDir, "sub", "test.md") + res.Rows[1]["status"] = "multiline\ntext|pipe" absPath, xe = ExportQueryResult(res, FormatMarkdown, mdPath) if xe != nil { t.Fatalf("Markdown export failed: %v", xe) } content, _ = os.ReadFile(absPath) - if !strings.Contains(string(content), "| username |") { + if !strings.Contains(string(content), "| username |") || !strings.Contains(string(content), "text\\|pipe") { t.Fatalf("unexpected Markdown content: %s", string(content)) } + + // 4. Nil result & empty filePath fallback + _, xe = ExportQueryResult(nil, FormatCSV, "") + if xe == nil { + t.Fatal("expected error for nil QueryResult") + } + + absDefault, xe := ExportQueryResult(res, FormatCSV, "") + if xe != nil { + t.Fatalf("unexpected error for empty filePath: %v", xe) + } + _ = os.Remove(absDefault) } diff --git a/internal/tui/components_test.go b/internal/tui/components_test.go index 637f933..b2be21d 100644 --- a/internal/tui/components_test.go +++ b/internal/tui/components_test.go @@ -45,4 +45,33 @@ func TestFormatTableResult_CJKBorderProtection(t *testing.T) { } } } + + // Nil and empty result fallbacks + if nilOut := FormatTableResult(nil, 0, 0, 80, false); !strings.Contains(nilOut, "empty dataset") { + t.Errorf("expected empty dataset message for nil QueryResult, got %q", nilOut) + } + + if nilVert := FormatVerticalResult(nil); !strings.Contains(nilVert, "empty dataset") { + t.Errorf("expected empty dataset message for nil QueryResult vertical view, got %q", nilVert) + } + + emptyRes := &db.QueryResult{Columns: []string{"id"}, Rows: []map[string]any{}} + if emptyOut := FormatTableResult(emptyRes, 0, 0, 80, false); !strings.Contains(emptyOut, "0 rows returned") { + t.Fatalf("expected '0 rows returned', got %q", emptyOut) + } + + // Test offset and inactive view + offsetFormatted := FormatTableResult(res, 1, 1, 40, false) + if !strings.Contains(offsetFormatted, "status") { + t.Errorf("expected status column in offset view, got:\n%s", offsetFormatted) + } + + // Test sanitizeCell & sanitizeCellWithStatus + if s, _ := sanitizeCellWithStatus(nil, 10); s != "NULL" { + t.Errorf("expected 'NULL' for nil input, got %q", s) + } + + if s := sanitizeCell("short", 10); s != "short" { + t.Errorf("expected 'short', got %q", s) + } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f3762a0..b4f8bd1 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -418,7 +418,18 @@ func TestTUI_Model_FullCoverage(t *testing.T) { }) m = updated.(Model) - // 5. Test Key Navigation (Tab, Left, Right, PgUp, PgDn, Ctrl+E) + // 5. Test Key Navigation & renderTableState + m.tableStates = append(m.tableStates, TableState{ + Result: res, + MsgIndex: 0, + VerticalView: false, + }) + m.messages = []string{"msg0"} + m.renderTableState(0, true) + m.renderTableState(0, false) + m.tableStates[0].VerticalView = true + m.renderTableState(0, true) + m.Update(tea.KeyMsg{Type: tea.KeyTab}) m.Update(tea.KeyMsg{Type: tea.KeyRight}) m.Update(tea.KeyMsg{Type: tea.KeyLeft}) From 0cf62cfdb820c65bcce0a4b7622a14743fe21ba6 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:31:02 +0800 Subject: [PATCH 73/75] test: increase test coverage across all new AI TUI modules above 80% --- internal/js/engine_test.go | 45 +++++++++++++++++++++++ internal/tui/model_test.go | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/internal/js/engine_test.go b/internal/js/engine_test.go index 5c111e4..8710d3f 100644 --- a/internal/js/engine_test.go +++ b/internal/js/engine_test.go @@ -102,3 +102,48 @@ func TestJSEngine_PrimitiveResult(t *testing.T) { t.Fatalf("expected array json output, got %q", res.JSONString) } } + +func TestJSEngine_ConsoleAndNullAndDefaultTimeout(t *testing.T) { + engine := NewJSEngine(0) // Default 1 min + if engine.DefaultTimeout <= 0 { + t.Fatal("expected positive default timeout") + } + + store := session.NewSessionDataStore() + store.Save("test query", &db.QueryResult{ + Columns: []string{"id"}, + Rows: []map[string]any{{"id": 10}}, + }) + + // 1. Console log and error with undefined return + jsCode := ` + console.log("log msg", 123); + console.error("error msg"); + rows.length; + ` + res, xe := engine.Execute(nil, jsCode, store) + if xe != nil { + t.Fatalf("unexpected execution error: %v", xe) + } + if len(res.Logs) != 2 || !strings.Contains(res.SummaryText, "[ERROR] error msg") { + t.Fatalf("expected 2 log entries in summary, got:\n%s", res.SummaryText) + } + + // 2. Null/Undefined return with console logs + resNull, xe := engine.Execute(nil, `console.log("hello"); null;`, nil) + if xe != nil { + t.Fatalf("unexpected execution error: %v", xe) + } + if resNull.JSONString != "null" || !strings.Contains(resNull.SummaryText, "hello") { + t.Fatalf("expected null return with logs, got %q", resNull.SummaryText) + } + + // 3. JSON String return + resJSON, xe := engine.Execute(nil, `JSON.stringify({status: "ok"})`, nil) + if xe != nil { + t.Fatalf("unexpected execution error: %v", xe) + } + if !strings.Contains(resJSON.JSONString, `"status": "ok"`) { + t.Fatalf("expected pretty formatted JSON string, got %q", resJSON.JSONString) + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index b4f8bd1..150bcb2 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -10,6 +10,7 @@ import ( "github.com/zx06/xsql/internal/ai" "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/errors" ) func TestTUI_Model_StateTransitions(t *testing.T) { @@ -464,3 +465,76 @@ func TestTUI_Model_FullCoverage(t *testing.T) { t.Fatalf("expected StateIdle after Adjust Prompt option, got %v", m.state) } } + +func TestTUI_Model_ViewAndAllStatesCoverage(t *testing.T) { + resolved := config.Resolved{ + ProfileName: "dev", + Profile: config.Profile{DB: "mysql"}, + } + aiService := ai.NewService(config.AIConfig{}, nil) + m := NewModel(config.Options{}, resolved, aiService, "", true) // unsafeAllowWrite = true + m.autoExecute = true + + // 1. Test View in StateLoadingSchema + m.state = StateLoadingSchema + if view := m.View(); !strings.Contains(view, "READ-WRITE") || !strings.Contains(view, "AUTO-EXEC") { + t.Fatalf("expected badges in header view, got:\n%s", view) + } + + // 2. Test View in all states + states := []State{ + StateIdle, StateThinking, StateSQLReady, StateExecuting, StateExportReady, + } + for _, st := range states { + m.state = st + _ = m.View() + } + + // 3. Test schemaLoadedMsg with error + updated, _ := m.Update(schemaLoadedMsg{ + err: errors.New("XSQL_CFG_INVALID", "invalid config", nil), + }) + m = updated.(Model) + + // 4. Test aiResponseMsg with error & TypeText + updated, _ = m.Update(aiResponseMsg{ + err: errors.New("XSQL_AI_API_ERROR", "api failed", nil), + }) + m = updated.(Model) + if m.state != StateIdle { + t.Fatalf("expected StateIdle after AI error, got %v", m.state) + } + + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeText, + Explanation: "Here is text response", + }, + }) + m = updated.(Model) + if m.state != StateIdle { + t.Fatalf("expected StateIdle after TypeText response, got %v", m.state) + } + + // 5. Test queryExecutedMsg with error + updated, _ = m.Update(queryExecutedMsg{ + err: errors.New("XSQL_SQL_SYNTAX_ERROR", "syntax error", nil), + }) + m = updated.(Model) + + // 6. Test Option keys '1', '2', '3' in StateSQLReady + m.state = StateSQLReady + m.currentSQL = "SELECT 1;" + + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + m.state = StateSQLReady + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) + m.state = StateSQLReady + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + + // 7. Test focusToolCall out of bounds & renderTableState invalid indices + m.focusToolCall(-1) + m.focusToolCall(999) + m.renderTableState(-1, false) + m.renderTableState(999, false) +} From 6bb07097f0a7c560a05090712dd28e5f2b8459a4 Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:42:46 +0800 Subject: [PATCH 74/75] build: add xsql-ai binary release target to goreleaser and CI workflow --- .github/workflows/ci.yml | 16 +++- .goreleaser.yaml | 181 +++++++++++++++++++++------------------ 2 files changed, 112 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d79d73..910c905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,16 +229,24 @@ jobs: working-directory: webui - name: Build Linux - run: GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o xsql-linux-amd64 ./cmd/xsql + run: | + GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o xsql-linux-amd64 ./cmd/xsql + GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o xsql-ai-linux-amd64 ./cmd/xsql-ai - name: Build Windows - run: GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o xsql-windows-amd64.exe ./cmd/xsql + run: | + GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o xsql-windows-amd64.exe ./cmd/xsql + GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o xsql-ai-windows-amd64.exe ./cmd/xsql-ai - name: Build macOS (amd64) - run: GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o xsql-darwin-amd64 ./cmd/xsql + run: | + GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o xsql-darwin-amd64 ./cmd/xsql + GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o xsql-ai-darwin-amd64 ./cmd/xsql-ai - name: Build macOS (arm64) - run: GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o xsql-darwin-arm64 ./cmd/xsql + run: | + GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o xsql-darwin-arm64 ./cmd/xsql + GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o xsql-ai-darwin-arm64 ./cmd/xsql-ai - name: Upload artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8dac074..8c7a3a6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,86 +1,105 @@ -version: 2 - -project_name: xsql - +version: 2 + +project_name: xsql + before: hooks: - npm --prefix webui ci - npm --prefix webui run build - go mod tidy - -builds: - - id: xsql - main: ./cmd/xsql - binary: xsql - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - - windows - goarch: - - amd64 - - arm64 - ldflags: - - -s -w - - -X main.version={{.Version}} - - -X main.commit={{.ShortCommit}} - - -X main.date={{.Date}} - -archives: - - id: default - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - format_overrides: - - goos: windows - format: zip - files: - - README.md - - LICENSE* - - docs/* - -checksum: - name_template: "checksums.txt" - -snapshot: - version_template: "{{ incpatch .Version }}-next" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - - "^chore:" - - "Merge pull request" - - "Merge branch" - -release: - github: - owner: zx06 - name: xsql - draft: false - prerelease: auto - name_template: "v{{.Version}}" - -brews: - - repository: - owner: zx06 - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" - directory: Formula - homepage: "https://github.com/zx06/xsql" - description: "AI-first cross-database CLI tool with SSH proxy support" - license: "MIT" - install: | - bin.install "xsql" - test: | - system "#{bin}/xsql", "version" - -scoops: - - repository: - owner: zx06 - name: scoop-bucket - token: "{{ .Env.SCOOP_BUCKET_TOKEN }}" - homepage: "https://github.com/zx06/xsql" - description: "AI-first cross-database CLI tool with SSH proxy support" - license: "MIT" + +builds: + - id: xsql + main: ./cmd/xsql + binary: xsql + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.ShortCommit}} + - -X main.date={{.Date}} + + - id: xsql-ai + main: ./cmd/xsql-ai + binary: xsql-ai + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.ShortCommit}} + - -X main.date={{.Date}} + +archives: + - id: default + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE* + - docs/* + +checksum: + name_template: "checksums.txt" + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "Merge pull request" + - "Merge branch" + +release: + github: + owner: zx06 + name: xsql + draft: false + prerelease: auto + name_template: "v{{.Version}}" + +brews: + - repository: + owner: zx06 + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + directory: Formula + homepage: "https://github.com/zx06/xsql" + description: "AI-first cross-database CLI tool with SSH proxy support" + license: "MIT" + install: | + bin.install "xsql" + bin.install "xsql-ai" + test: | + system "#{bin}/xsql", "version" + +scoops: + - repository: + owner: zx06 + name: scoop-bucket + token: "{{ .Env.SCOOP_BUCKET_TOKEN }}" + homepage: "https://github.com/zx06/xsql" + description: "AI-first cross-database CLI tool with SSH proxy support" + license: "MIT" From f418f2dd59e3d16dd06ad3acebfebd7a66c306be Mon Sep 17 00:00:00 2001 From: x_zhuo <12474586+zx06@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:46:14 +0800 Subject: [PATCH 75/75] feat: register ai subcommand to main xsql CLI --- cmd/xsql/ai.go | 83 +++++++++++++++++++++++++++++++++++++++++++++ cmd/xsql/ai_test.go | 45 ++++++++++++++++++++++++ cmd/xsql/main.go | 1 + 3 files changed, 129 insertions(+) create mode 100644 cmd/xsql/ai.go create mode 100644 cmd/xsql/ai_test.go diff --git a/cmd/xsql/ai.go b/cmd/xsql/ai.go new file mode 100644 index 0000000..8b8963f --- /dev/null +++ b/cmd/xsql/ai.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/zx06/xsql/internal/ai" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/secret" + "github.com/zx06/xsql/internal/tui" +) + +var newAIProgramFunc = func(model tea.Model) *tea.Program { + return tea.NewProgram(model, tea.WithAltScreen()) +} + +func NewAICommand() *cobra.Command { + var modelStr string + var baseURLStr string + var apiKeyStr string + var unsafeAllowWrite bool + + cmd := &cobra.Command{ + Use: "ai [PROMPT]", + Short: "Interactive AI assistant mode (TUI)", + RunE: func(cmd *cobra.Command, args []string) error { + prompt := "" + if len(args) > 0 { + prompt = strings.Join(args, " ") + } + + opts := config.Options{ + ConfigPath: GlobalConfig.ConfigStr, + CLIProfile: GlobalConfig.ProfileStr, + CLIProfileSet: cmd.Flags().Changed("profile"), + CLIAIModel: modelStr, + CLIAIModelSet: cmd.Flags().Changed("model"), + CLIAIBaseURL: baseURLStr, + CLIAIBaseURLSet: cmd.Flags().Changed("base-url"), + CLIAIAPIKey: apiKeyStr, + CLIAIAPIKeySet: cmd.Flags().Changed("api-key"), + } + + resolved, xe := config.Resolve(opts) + if xe != nil { + return xe + } + if resolved.ProfileName == "" || resolved.Profile.DB == "" { + return fmt.Errorf("config error [XSQL_CFG_INVALID]: no profile specified and no 'default' profile found in config") + } + + // Resolve API key if keyring reference or plaintext + apiKey := resolved.AI.APIKey + if secret.IsKeyringRef(apiKey) { + resolvedKey, xe := secret.Resolve(apiKey, secret.Options{AllowPlaintext: true}) + if xe == nil { + apiKey = resolvedKey + } + } + resolved.AI.APIKey = apiKey + + aiClient := ai.NewClient(resolved.AI, nil) + aiService := ai.NewService(resolved.AI, aiClient) + + m := tui.NewModel(opts, resolved, aiService, prompt, unsafeAllowWrite) + p := newAIProgramFunc(m) + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running TUI: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVar(&modelStr, "model", "", "AI model name (default: gpt-4o)") + cmd.Flags().StringVar(&baseURLStr, "base-url", "", "AI service base URL") + cmd.Flags().StringVar(&apiKeyStr, "api-key", "", "AI service API key") + cmd.Flags().BoolVar(&unsafeAllowWrite, "unsafe-allow-write", false, "Allow write operations (bypasses read-only protection)") + + return cmd +} diff --git a/cmd/xsql/ai_test.go b/cmd/xsql/ai_test.go new file mode 100644 index 0000000..386d7c9 --- /dev/null +++ b/cmd/xsql/ai_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestCmdXSQL_AICommand(t *testing.T) { + oldNewAIProgram := newAIProgramFunc + defer func() { newAIProgramFunc = oldNewAIProgram }() + + newAIProgramFunc = func(model tea.Model) *tea.Program { + p := tea.NewProgram(model, tea.WithInput(strings.NewReader("")), tea.WithOutput(os.Stderr), tea.WithoutRenderer()) + go p.Quit() + return p + } + + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "xsql.yaml") + cfgContent := ` +profiles: + dev: + db: mysql + host: 127.0.0.1 + port: 3306 + user: root + database: testdb +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + + root := NewRootCommand() + root.AddCommand(NewAICommand()) + root.SetArgs([]string{"ai", "--config", cfgPath, "--profile", "dev", "Show top 10 users"}) + + err := root.Execute() + if err != nil { + t.Fatalf("expected xsql ai command execution to succeed, got %v", err) + } +} diff --git a/cmd/xsql/main.go b/cmd/xsql/main.go index 218da23..e50ad20 100644 --- a/cmd/xsql/main.go +++ b/cmd/xsql/main.go @@ -34,6 +34,7 @@ func run() int { root.AddCommand(NewServeCommand(&w)) root.AddCommand(NewWebCommand(&w)) root.AddCommand(NewStatsCommand(&w)) + root.AddCommand(NewAICommand()) // Execute and handle errors if err := root.Execute(); err != nil {