diff --git a/.gitignore b/.gitignore
index fbe24427..6a651a7b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/.luarc.json
README.zh.md
+.playwright-mcp/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..0c562791
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,61 @@
+# AGENTS.md
+
+Agent map for `leetcode.nvim`.
+
+Read ADRs first when changing session behavior, hooks, or the CodeCompanion bridge. Use `README.md` and nearby Lua modules after that.
+
+## What This Repo Owns
+
+- LeetCode UI, commands, and question lifecycle inside Neovim
+- hook dispatch around test / submit / timer events
+- CodeCompanion bridge for LeetCode context injection
+- editor-side rendering for submission-service-backed companion events
+
+## Start Here
+
+1. [docs/adrs/001-codecompanion-session-failure-event-bridge.md](./docs/adrs/001-codecompanion-session-failure-event-bridge.md)
+2. [README.md](./README.md)
+3. [lua/leetcode/integrations/codecompanion.lua](./lua/leetcode/integrations/codecompanion.lua)
+4. [lua/leetcode/config/hooks.lua](./lua/leetcode/config/hooks.lua)
+5. [lua/leetcode/runner/init.lua](./lua/leetcode/runner/init.lua)
+
+## File Map
+
+- `lua/leetcode/integrations/codecompanion.lua`
+ CodeCompanion bridge, hidden context injection, question-bound chat tracking, failure-event rendering.
+- `lua/leetcode/config/hooks.lua`
+ Canonical hook registration and defaults.
+- `lua/leetcode/runner/init.lua`
+ Execution path for test / submit flows and hook dispatch.
+- `lua/leetcode/command/init.lua`
+ User-facing `:Leet` commands.
+- `lua/leetcode-ui/`
+ Question, result, and side-panel rendering.
+- `docs/adrs/`
+ Durable behavior decisions for this fork.
+
+## Guardrails
+
+- Keep `leetcode.nvim` as a UI/context bridge; do not move provider or model ownership into this repo.
+- Prefer stable data from submission service over reconstructing session truth inside Lua.
+- Keep hook payload shapes backward compatible unless the user explicitly asks for a contract change.
+- Prefer hidden context for companion-side lifecycle metadata over visible user-message injection.
+
+## D2 Diagram Style Preference
+
+When adding or updating D2 diagrams, prefer the user's established high-level architecture style:
+
+- Treat D2 as a system overview first, not an implementation-detail dump.
+- Group nodes by runtime boundary before drawing flows, for example `Strict local`.
+- Clearly separate entry points, runtime surfaces, external systems, and data planes.
+- Prefer a left-to-right primary reading direction with straight main flows and minimal crossing lines.
+- Optimize for ownership and data flow clarity, not source-file completeness.
+- Use a small number of stable responsibility-oriented node labels rather than many file/module names.
+- Keep edge labels short and protocol/action-oriented, such as `JSON over TCP`.
+- Preserve generous whitespace and a clean overview feel; prefer one readable system map plus optional focused sub-diagrams.
+
+## When To Update Docs
+
+- Update ADRs when the companion bridge, session binding, or hook semantics change.
+- Update `README.md` when user-facing setup or behavior changes.
+- Update this file only when the navigation order or edit guardrails change.
diff --git a/README.md b/README.md
index 08f224af..87247f79 100644
--- a/README.md
+++ b/README.md
@@ -1,547 +1,202 @@
-# leetcode.nvim
+# 🍴 leetcode.nvim (Extended Fork)
-🔥 Solve [LeetCode] problems within [Neovim] 🔥
+**Extended hooks and external integration for [kawre/leetcode.nvim](https://github.com/kawre/leetcode.nvim)**
-
+The original project seems not be not actively maintained thats why i forked it
+Track problems, time spent, and submissions via external services.
-https://github.com/kawre/leetcode.nvim/assets/69250723/aee6584c-e099-4409-b114-123cb32b7563
+## ✨ What's New
-## ✨ Features
+This fork adds submission tracking and session management capabilities to leetcode.nvim:
-- 📌 an intuitive dashboard for effortless navigation within [leetcode.nvim]
+- **Session Timer Commands**: `:Leet timer_start` / `:Leet timer_stop`
+- **AI Assist Toggle**: `:Leet ai_assist [on|off|toggle]`
+- **Extended Hooks**: `timer_start`, `question_leave`, `on_test_result`, and improved upload hooks
+- **Submission Side Panel**: Optional left-bottom panel for past submissions
+- **External Integration**: Wire hooks to external APIs for persistence
+- **CodeCompanion Bridge**: `:Leet companion` can inject LeetCode context into CodeCompanion while delegating the actual LLM call to your own service
+- **Bug Fix**: Resolved `nvim_win_is_valid` fast event crash in timer display
-- 😍 question description formatting for a better readability
+## 🎯 New Hooks
-- 📈 [LeetCode] profile statistics within [Neovim]
+Beyond the original hooks, this fork adds:
-- 🔀 support for daily and random questions
+| Hook | Signature | When |
+|------|-----------|------|
+| `problem_description_open` | `fun(question: lc.ui.Question)` | After the problem description buffer mounts |
+| `timer_start` | `fun(question: lc.ui.Question)` | Session timer started (`:Leet session start`) |
+| `question_leave` | `fun(question: lc.ui.Question)` | Question window closed or session stopped |
-- 💾 caching for optimized performance
+Existing hooks also trigger on submissions:
-## 📬 Requirements
+| Hook | Signature | When |
+|------|-----------|------|
+| `on_test_result` | `fun(question, buffer, item_json)` | After `:Leet run` / `:Leet test` |
+| `upload_submit_result` | `fun(question, buffer, item_json)` | After `:Leet submit` |
+| `upload_test_result` | `fun(question, buffer, item_json)` | After `:Leet upload_test_result` |
-- [Neovim] >= 0.9.0
+## 🛠️ New Commands
-- [Picker](#picker)
-
-- [plenary.nvim]
-
-- [nui.nvim]
-
-- [tree-sitter-html] _**(optional, but highly recommended)**_
- used for formatting the question description.
- Can be installed with [nvim-treesitter].
-
-- [Nerd Font][nerd-font] & [nvim-web-devicons] _**(optional)**_
-
-## 📦 Installation
-
-- [lazy.nvim]
-
-```lua
-{
- "kawre/leetcode.nvim",
- build = ":TSUpdate html", -- if you have `nvim-treesitter` installed
- dependencies = {
- -- include a picker of your choice, see picker section for more details
- "nvim-lua/plenary.nvim",
- "MunifTanjim/nui.nvim",
- },
- opts = {
- -- configuration goes here
- },
-}
+```vim
+:Leet timer_start " Start tracking session time
+:Leet timer_stop " Stop tracking (drop session without saving)
+:Leet session start " Alias for timer_start
+:Leet session stop " Alias for timer_stop
+:Leet ai_assist " Toggle auto AI failure analysis for the current question
+:Leet ai_assist on " Force auto AI failure analysis on
+:Leet ai_assist off " Force auto AI failure analysis off
+:Leet companion " Open a CodeCompanion chat seeded with the current LeetCode context
```
-## 🛠️ Configuration
+## 🤖 CodeCompanion Bridge
-To see full configuration types see [template.lua](./lua/leetcode/config/template.lua)
-
-### ⚙️ default configuration
+If you want `CodeCompanion` to be just the UI bridge while your own submission service owns the provider, model, and system prompt, configure a local OpenAI-compatible adapter in `CodeCompanion` and point `leetcode.nvim` at it:
```lua
-{
- ---@type string
- arg = "leetcode.nvim",
-
- ---@type lc.lang
- lang = "cpp",
-
- cn = { -- leetcode.cn
- enabled = false, ---@type boolean
- translator = true, ---@type boolean
- translate_problems = true, ---@type boolean
+require("leetcode").setup({
+ companion = {
+ adapter = "submission_service",
+ default_prompt = "Help me understand the bug in my current approach.",
+ auto_fence_code = true,
},
-
- ---@type lc.storage
- storage = {
- home = vim.fn.stdpath("data") .. "/leetcode",
- cache = vim.fn.stdpath("cache") .. "/leetcode",
+ ai_assist = {
+ enabled = true,
},
+})
+```
- ---@type table
- plugins = {
- non_standalone = false,
- },
+Then run `:Leet companion` on an open problem buffer. The plugin injects:
- ---@type boolean
- logging = true,
+- problem title / slug / difficulty
+- problem description
+- tags and hints
+- active testcase
+- current code from the editor
- injector = {}, ---@type table
+as hidden chat context, while the visible prompt stays focused on what you want help with.
- cache = {
- update_interval = 60 * 60 * 24 * 7, ---@type integer 7 days
- },
+When `auto_fence_code` is enabled, `leetcode.nvim` will also wrap obviously code-like assistant replies in a fenced code block before re-rendering the CodeCompanion buffer. This gives Treesitter a better chance to apply syntax highlighting even when the submission service forgets to fence the snippet itself.
- editor = {
- reset_previous_code = true, ---@type boolean
- fold_imports = true, ---@type boolean
- },
+When the session timer is running, the winbar also shows `AI:on` or `AI:off` so you can see whether failed test and submit events will trigger your external static-analysis assistant.
- console = {
- open_on_runcode = true, ---@type boolean
+## 📡 External Integration Example
- dir = "row", ---@type lc.direction
+Connect to a submission server to save data:
- size = { ---@type lc.size
- width = "90%",
- height = "75%",
- },
+```lua
+-- ~/.config/nvim/init.lua or plugin spec
+local db_saver = require("submission_db_saver")
- result = {
- size = "60%", ---@type lc.size
+return {
+ "kawre/leetcode.nvim",
+ opts = {
+ description = {
+ submissions = {
+ enabled = true,
+ height = "28%",
+ limit = 10,
+ },
},
-
- testcase = {
- virt_text = true, ---@type boolean
-
- size = "40%", ---@type lc.size
+ hooks = {
+ -- Track time on a problem
+ ["timer_start"] = {
+ function(question)
+ db_saver.timer_start(question)
+ question:start_timer_display()
+ end,
+ },
+ ["problem_description_open"] = {
+ function(question)
+ db_saver.show_past_submissions(question, 10)
+ end,
+ },
+ -- Clean up timer on close
+ ["question_leave"] = {
+ function(question)
+ db_saver.drop_session(question)
+ end,
+ },
+ -- Save test runs
+ ["on_test_result"] = {
+ function(question, buffer, item)
+ db_saver.save_submission(question, buffer, item)
+ end,
+ },
+ -- Save final submissions
+ ["upload_submit_result"] = {
+ function(question, buffer, item)
+ db_saver.save_submission(question, buffer, item)
+ end,
+ },
},
},
-
- description = {
- position = "left", ---@type lc.position
-
- width = "40%", ---@type lc.size
-
- show_stats = true, ---@type boolean
- },
-
- ---@type lc.picker
- picker = { provider = nil },
-
- hooks = {
- ---@type fun()[]
- ["enter"] = {},
-
- ---@type fun(question: lc.ui.Question)[]
- ["question_enter"] = {},
-
- ---@type fun()[]
- ["leave"] = {},
- },
-
- keys = {
- toggle = { "q" }, ---@type string|string[]
- confirm = { "" }, ---@type string|string[]
-
- reset_testcases = "r", ---@type string
- use_testcase = "U", ---@type string
- focus_testcases = "H", ---@type string
- focus_result = "L", ---@type string
- },
-
- ---@type lc.highlights
- theme = {},
-
- ---@type boolean
- image_support = false,
}
```
-### arg
-
-Argument for [Neovim]
-
-```lua
----@type string
-arg = "leetcode.nvim"
-```
-
-See [usage](#-usage) for more info
-
-### lang
-
-Language to start your session with
-
-```lua
----@type lc.lang
-lang = "cpp"
-```
-
-
- available languages
-
-| Language | lang |
-| ---------- | ---------- |
-| C++ | cpp |
-| Java | java |
-| Python | python |
-| Python3 | python3 |
-| C | c |
-| C# | csharp |
-| JavaScript | javascript |
-| TypeScript | typescript |
-| PHP | php |
-| Swift | swift |
-| Kotlin | kotlin |
-| Dart | dart |
-| Go | golang |
-| Ruby | ruby |
-| Scala | scala |
-| Rust | rust |
-| Racket | racket |
-| Erlang | erlang |
-| Elixir | elixir |
-| Bash | bash |
-
-
-
-### cn
-
-Use [leetcode.cn] instead of [leetcode.com][leetcode]
-
-```lua
-cn = { -- leetcode.cn
- enabled = false, ---@type boolean
- translator = true, ---@type boolean
- translate_problems = true, ---@type boolean
-},
-```
-
-### storage
-
-storage directories
-
-```lua
----@type lc.storage
-storage = {
- home = vim.fn.stdpath("data") .. "/leetcode",
- cache = vim.fn.stdpath("cache") .. "/leetcode",
-},
-```
-
-### plugins
+## 🖥️ Server API
-[plugins list](#-plugins)
+Expected server endpoints:
-```lua
----@type table
-plugins = {
- non_standalone = false,
-},
```
-
-### logging
-
-Whether to log [leetcode.nvim] status notifications
-
-```lua
----@type boolean
-logging = true
-```
-
-### injector
-
-Inject code before or after your solution, injected code won't be submitted or run.
-
-Imports will be injected at the top of the buffer, automatically
-folded by default.
-
-```lua
-injector = { ---@type table
- ["python3"] = {
- imports = function(default_imports)
- vim.list_extend(default_imports, { "from .leetcode import *" })
- return default_imports
- end,
- after = { "def test():", " print('test')" },
- },
- ["cpp"] = {
- imports = function()
- -- return a different list to omit default imports
- return { "#include ", "using namespace std;" }
- end,
- after = "int main() {}",
- },
-},
+POST /api/session
+{
+ "action": "start_timer" | "drop_timer" | "save_submission" | "get_past_submissions",
+ "title_slug": "two-sum",
+ "limit": 10, // optional, for get_past_submissions
+ "content": "...", // for save_submission
+ "item": {...} // for save_submission
+}
```
-### picker
-
-Supported picker providers are:
-
-- [`snacks-picker`][snacks.nvim]
-- [`fzf-lua`][fzf-lua]
-- [`telescope`][telescope.nvim]
-- [`mini-picker`][mini-picker]
-
-If `provider` is `nil`, [leetcode.nvim] will try to resolve the first
-available one in the order above.
-
-```lua
----@type lc.picker
-picker = { provider = nil },
+Server should respond with:
+```json
+{
+ "success": true,
+ "action": "start_timer"
+}
```
-### hooks
+## 📝 Implementation Details
-List of functions that get executed on specified event
+### Files Modified
-```lua
-hooks = {
- ---@type fun()[]
- ["enter"] = {},
-
- ---@type fun(question: lc.ui.Question)[]
- ["question_enter"] = {},
+- `lua/leetcode/command/init.lua` - Added `timer_start` / `timer_stop` commands
+- `lua/leetcode/config/hooks.lua` - Registered new hook events
+- `lua/leetcode/config/template.lua` - Hook type aliases
+- `lua/leetcode-ui/question.lua` - Fire `question_leave` on unmount, fixed timer fast event bug
+- `packages/submission_server/src/submission_server.py` - Added `DROP_TIMER` action
+- `packages/submission_server/src/submission_db_saver.lua` - Added `timer_start()` and `drop_session()`
- ---@type fun()[]
- ["leave"] = {},
-},
-```
+### Usage Pattern
-### theme
+1. User runs `:Leet session start` → `timer_start` hook fires → server starts session timer
+2. User closes question or runs `:Leet session stop` → `question_leave` hook fires → server drops timer
+3. User submits code → `upload_submit_result` hook fires → server saves submission
+4. Time and submissions are persisted in external database
-Override the [default theme](./lua/leetcode/theme/default.lua).
+## 🔧 Installation
-Each value is the same type as val parameter in `:help nvim_set_hl`
+Use this fork instead of the original:
```lua
----@type lc.highlights
-theme = {
- ["alt"] = {
- bg = "#FFFFFF",
+{
+ "jingyi-zhao-01/leetcode.nvim", -- or your fork
+ build = ":TSUpdate html",
+ dependencies = {
+ "nvim-lua/plenary.nvim",
+ "MunifTanjim/nui.nvim",
},
- ["normal"] = {
- fg = "#EA4AAA",
+ opts = {
+ -- See hooks example above
},
-},
-```
-
-### image support
-
-> [!NOTE]
-> Requires [image.nvim] v1.4.0+ which fixed text wrapping support (see https://github.com/3rd/image.nvim/pull/266)
-
-
-```lua
----@type boolean
-image_support = true,
-```
-
-## 📋 Commands
-
-### `Leet` opens menu dashboard
-
-- `menu` same as `Leet`
-
-- `exit` close [leetcode.nvim]
-
-- `console` opens console pop-up for currently opened question
-
-- `info` opens a pop-up containing information about the currently opened question
-
-- `tabs` opens a picker with all currently opened question tabs
-
-- `yank` yanks the code section
-
-- `lang` opens a picker to change the language of the current question
-
-- `run` run currently opened question
-
-- `test` same as `Leet run`
-
-- `submit` submit currently opened question
-
-- `random` opens a random question
-
-- `daily` opens the question of today problem
-
-- `list` opens a picker with all available leetcode problems
-
-- `open` opens the current question in a default browser
-
-- `restore` try to restore default question layout
-
-- `last_submit` tries to replace the editor code section with the latest submitted code
-
-- `reset` resets editor code section to the default snippet
-
-- `inject` re-injects editor code, keeping the code section intact
-
-- `fold` applies folding to the current question imports section
-
-
-
-
-
-
-
-- `desc` toggle question description
- - `toggle` same as `Leet desc`
-
- - `stats` toggle description stats visibility
-
-- `cookie`
- - `update` opens a prompt to enter a new cookie
-
- - `delete` deletes stored cookie and logs out of [leetcode.nvim]
-
-- `cache`
- - `update` fetches all available problems and updates the local cache of [leetcode.nvim]
-
-#### Some commands can take optional arguments. To stack argument values separate them by a `,`
-
-- `Leet list`
-
- ```
- Leet list status= difficulty=
- ```
-
-- `Leet random`
-
- ```
- Leet random status= difficulty= tags=
- ```
-
-## 🚀 Usage
-
-This plugin can be initiated in two ways:
-
-- To start [leetcode.nvim], simply pass [`arg`](#arg)
- as the _first and **only**_ [Neovim] argument
-
- ```
- nvim leetcode.nvim
- ```
-
-- Use `:Leet` command to open [leetcode.nvim]
- within your preferred dashboard plugin. The only requirement is that [Neovim]
- must not have any listed buffers open.
- To bypass this requirement use [`non_standalone`](#non-standalone-mode) plugin.
-
-### Sign In
-
-> [!WARNING]
-> Be sure to copy the `Cookie` from request headers, not the `set-cookie` from
-> response headers.
-
-> [!WARNING]
-> If you are using **brave browser**, see [this
-> issue](https://github.com/kawre/leetcode.nvim/issues/160#issuecomment-2619611920)
-
-https://github.com/kawre/leetcode.nvim/assets/69250723/b7be8b95-5e2c-4153-8845-4ad3abeda5c3
-
-## ❓ FAQ
-
-### I keep getting `cookie expired` error
-
-If you keep getting `Your cookie may have expired, or LeetCode has temporarily
-restricted API access`, it most likely means that LeetCode website is under
-heavy load and is restricting API access (mostly during contests).
-
-All you can do is wait it out, try disabling a VPN if you’re using one, and if
-the problem is persistent, open an issue.
-
-### Switching between test cases
-
-To switch between test cases, just press the number of the corresponding case:
-`1` for `Case (1)`, `2` for `Case (2)`, and so on.
-
-### Switching between questions
-
-To switch between questions, use `Leet tabs`
-
-### I'm not getting LSP completions
-
-Some languages require additional setup to get LSP completions.
-For example, Rust needs extra configuration — see [this issue](https://github.com/kawre/leetcode.nvim/issues/86).
-
-## 🍴 Recipes
-
-### 💤 lazy loading with [lazy.nvim]
-
-> [!WARNING]
-> opting for either option makes the alternative
-> launch method unavailable due to lazy loading
-
-- with [`arg`](#arg)
-
- ```lua
- local leet_arg = "leetcode.nvim"
- ```
-
- ```lua
- {
- "kawre/leetcode.nvim",
- lazy = leet_arg ~= vim.fn.argv(0, -1),
- opts = { arg = leet_arg },
- }
- ```
-
-- with `:Leet`
-
- ```lua
- {
- "kawre/leetcode.nvim",
- cmd = "Leet",
- }
- ```
-
-### 🪟 Windows
-
-If you are using Windows,
-it is recommended to use [Cygwin](https://www.cygwin.com/) for a more consistent and Unix-like experience.
-
-## 🧩 Plugins
-
-### Non-Standalone mode
-
-To run [leetcode.nvim] in a non-standalone mode (i.e. not with argument or an empty Neovim session),
-enable the `non_standalone` plugin in your config:
-
-```lua
-plugins = {
- non_standalone = true,
}
```
-You can then exit [leetcode.nvim] using `:Leet exit` command
-
-## 🙌 Credits
-
-- [Leetbuddy.nvim](https://github.com/Dhanus3133/Leetbuddy.nvim)
-
-- [alpha-nvim](https://github.com/goolord/alpha-nvim)
-
-[image.nvim]: https://github.com/3rd/image.nvim
-[lazy.nvim]: https://github.com/folke/lazy.nvim
-[leetcode]: https://leetcode.com
-[leetcode.cn]: https://leetcode.cn
-[leetcode.nvim]: https://github.com/kawre/leetcode.nvim
-[neovim]: https://github.com/neovim/neovim
-[nerd-font]: https://www.nerdfonts.com
-[nui.nvim]: https://github.com/MunifTanjim/nui.nvim
-[nvim-treesitter]: https://github.com/nvim-treesitter/nvim-treesitter
-[nvim-web-devicons]: https://github.com/nvim-tree/nvim-web-devicons
-[telescope.nvim]: https://github.com/nvim-telescope/telescope.nvim
-[fzf-lua]: https://github.com/ibhagwan/fzf-lua
-[snacks.nvim]: https://github.com/folke/snacks.nvim
-[tree-sitter-html]: https://github.com/tree-sitter/tree-sitter-html
-[plenary.nvim]: https://github.com/nvim-lua/plenary.nvim
-[mini-picker]: https://github.com/nvim-mini/mini.pick
+## 📚 Refer to Original
+
+For core leetcode.nvim features, installation, and configuration, see:
+https://github.com/kawre/leetcode.nvim
diff --git a/docs/adrs/001-codecompanion-session-failure-event-bridge.md b/docs/adrs/001-codecompanion-session-failure-event-bridge.md
new file mode 100644
index 00000000..eb03db4e
--- /dev/null
+++ b/docs/adrs/001-codecompanion-session-failure-event-bridge.md
@@ -0,0 +1,100 @@
+# CodeCompanion Session Failure Event Bridge
+
+日期: 2026-06-07
+
+相关背景:
+
+- [README.md](../../README.md)
+- `leetcode-qa` 中 submission service 的 session-bound companion memory 设计
+
+## 背景
+
+这个 fork 里的 `CodeCompanion` 集成不是一个独立 agent runtime。
+
+它的目标是:
+
+- `leetcode.nvim` 提供题目上下文和 UI bridge
+- 真正的 LLM provider / model / prompt / session memory 由 submission service 持有
+
+当用户在 companion chat 打开之后又触发了一次失败测试,如果 chat 里看不到这次 failure event,就会出现:
+
+- 当前 companion chat 不知道刚刚失败了什么
+- 用户只能手动再描述一遍
+- chat buffer 和 submission service 的 active session lifecycle 脱节
+
+## 决策
+
+### 1. `leetcode.nvim` 只做 bridge,不做 LLM ownership
+
+`lua/leetcode/integrations/codecompanion.lua` 的职责是:
+
+- 从当前题目页收集 LeetCode context
+- 打开 CodeCompanion chat
+- 以隐藏 context 形式注入题面、testcase、当前代码
+- 把 chat 绑定到当前 `title_slug`
+
+它不拥有:
+
+- provider 选择
+- model 选择
+- companion system prompt
+- solve session truth
+
+这些都由 submission service 决定。
+
+### 2. 当前 companion chat 按 `title_slug` 绑定
+
+bridge 需要记住“这道题当前对应哪个 CodeCompanion chat”。
+
+当前实现用 `title_slug -> chat instance` 的内存映射来做这件事。
+
+这样 submission-related hook 收到 failure event 时,才能把 event 投到正确的 chat,而不是任意最后一个 chat。
+
+### 3. failure event 由 submission service 生成,nvim 负责渲染
+
+submission service 在 `analyze_failure` 成功后返回稳定的 `event_id`。
+
+`leetcode.nvim` 不自己发明 failure id,也不自己推导“哪次 failure 才算最新”。
+
+客户端只做两件事:
+
+1. 把 `>` 作为隐藏 context 加到当前 chat
+2. 在 chat buffer 里追加一条可见提示,说明这次 failure event 已经挂进当前会话
+
+### 4. failure event 通过现有 analyze_failure 客户端链路自动投递
+
+当前自动投递入口放在 `leetcode-qa/lua/submission_db_saver.lua` 的 `analyze_failure()` 回调里。
+
+也就是说,只要某次 test / submit failure 最终走到了这条分析路径,并且服务端返回了 `event_id`,bridge 就会自动尝试把它注入当前 companion chat。
+
+### 5. 失败事件默认作为隐藏 context,而不是普通聊天正文
+
+因为这条信息本质上是 solve lifecycle 里的结构化上下文,不是用户输入的自由文本。
+
+把它作为隐藏 context 的好处:
+
+- 不污染用户正在看的可见聊天正文
+- 可以被 submission service 的 companion endpoint 稳定识别
+- 可以避免它被误当成普通 user question
+
+## 结果
+
+现在 `leetcode.nvim` 里的 companion 行为分成两段:
+
+1. 打开 chat 时注入 `LeetCode Problem Context`
+2. failure 发生并分析成功后,再注入 `LeetCode Failure Event`
+
+这让单个 CodeCompanion chat 能跟上同一道题在 solve lifecycle 里的最新 failure state。
+
+## 当前边界
+
+- 这个 bridge 只保证“同题目当前 chat”会收到 event
+- 它不负责跨题会话恢复
+- 它不负责 failure event 的长期持久化
+- 它不自己请求 LLM 来解释 failure,解释仍由 submission service 返回
+
+## 后续约束
+
+- 新增 companion 相关功能时,优先沿用 `title_slug` 绑定,而不是退回“last chat”这种弱绑定
+- 新增 session-aware context 时,优先走隐藏 context 注入
+- 如果 submission service 扩展了更多 session event,客户端优先复用服务端给的 stable ids / payloads,而不是本地重建语义
diff --git a/docs/d2/codecompanion-bridge.d2 b/docs/d2/codecompanion-bridge.d2
new file mode 100644
index 00000000..79f801e2
--- /dev/null
+++ b/docs/d2/codecompanion-bridge.d2
@@ -0,0 +1,142 @@
+direction: right
+
+nvim: {
+ label: "nvim\nentry point"
+ shape: cylinder
+ style: {
+ fill: "#fecaca"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+}
+
+companion_client: {
+ label: "CodeCompanion\nexternal client"
+ shape: circle
+ style: {
+ fill: "#f5b4fc"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+}
+
+local_runtime: {
+ label: "Strict local"
+ direction: down
+ style: {
+ fill: "#fff7ed"
+ stroke: "#fdba74"
+ stroke-width: 2
+ }
+
+ leetcode_nvim: {
+ label: "leetcode.nvim"
+ style: {
+ fill: "#fdba74"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ question_runtime: {
+ label: "question runtime"
+ style: {
+ fill: "#fde68a"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ hook_router: {
+ label: "hook router"
+ style: {
+ fill: "#fde68a"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ codecompanion_bridge: {
+ label: "codecompanion bridge"
+ style: {
+ fill: "#fde68a"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ submission_db_saver: {
+ label: "submission_db_saver"
+ style: {
+ fill: "#fde68a"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+}
+
+service_runtime: {
+ label: "Strict local\nsubmission runtime"
+ direction: down
+ style: {
+ fill: "#eef2ff"
+ stroke: "#93c5fd"
+ stroke-width: 2
+ }
+
+ submission_service: {
+ label: "leetcode-submission-service"
+ style: {
+ fill: "#bfdbfe"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ companion_http: {
+ label: "companion HTTP"
+ style: {
+ fill: "#bfdbfe"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ failure_analysis: {
+ label: "failure analysis"
+ style: {
+ fill: "#bfdbfe"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+
+ session_scope: {
+ label: "active session scope\nin-memory"
+ style: {
+ fill: "#dbeafe"
+ stroke: "#2563eb"
+ stroke-width: 2
+ }
+ }
+}
+
+nvim -> local_runtime.leetcode_nvim: "editor runtime"
+companion_client -> service_runtime.companion_http: "OpenAI-compatible chat"
+
+local_runtime.leetcode_nvim -> local_runtime.question_runtime: "question lifecycle"
+local_runtime.leetcode_nvim -> local_runtime.codecompanion_bridge: ":Leet companion"
+local_runtime.question_runtime -> local_runtime.hook_router: "test / submit hooks"
+local_runtime.hook_router -> local_runtime.submission_db_saver: "failure callbacks"
+local_runtime.codecompanion_bridge -> companion_client: "hidden LeetCode context"
+
+local_runtime.submission_db_saver -> service_runtime.submission_service: "JSON over TCP"
+service_runtime.submission_service -> service_runtime.failure_analysis: "analyze_failure"
+service_runtime.submission_service -> service_runtime.companion_http: "local companion endpoint"
+
+service_runtime.failure_analysis -> service_runtime.session_scope: "write latest failure\nstatic analysis\nevent_id"
+service_runtime.companion_http -> service_runtime.session_scope: "read / write companion memory"
+
+service_runtime.submission_service -> local_runtime.submission_db_saver: "failure event payload"
+local_runtime.submission_db_saver -> local_runtime.codecompanion_bridge: "render failure event"
+local_runtime.codecompanion_bridge -> companion_client: "attach hidden failure context"
diff --git a/docs/d2/codecompanion-bridge.svg b/docs/d2/codecompanion-bridge.svg
new file mode 100644
index 00000000..3eccf941
--- /dev/null
+++ b/docs/d2/codecompanion-bridge.svg
@@ -0,0 +1,123 @@
+
diff --git a/lua/leetcode-ui/layout/console.lua b/lua/leetcode-ui/layout/console.lua
index b7e88c01..92cab6e4 100644
--- a/lua/leetcode-ui/layout/console.lua
+++ b/lua/leetcode-ui/layout/console.lua
@@ -59,7 +59,7 @@ function ConsoleLayout:mount()
})
end
-function ConsoleLayout:run(submit)
+function ConsoleLayout:run(submit, trigger_hook)
local range = self.question:editor_section_range("code")
if not range:is_valid_or_log() then
return
@@ -71,7 +71,7 @@ function ConsoleLayout:run(submit)
self.result:focus()
- Runner:init(self.question):run(submit)
+ Runner:init(self.question):run(submit, trigger_hook)
end
function ConsoleLayout:use_testcase()
diff --git a/lua/leetcode-ui/question.lua b/lua/leetcode-ui/question.lua
index a72ccafb..f7124401 100644
--- a/lua/leetcode-ui/question.lua
+++ b/lua/leetcode-ui/question.lua
@@ -1,4 +1,5 @@
local Description = require("leetcode-ui.split.description")
+local Submissions = require("leetcode-ui.split.submissions")
local Console = require("leetcode-ui.layout.console")
local Info = require("leetcode-ui.popup.info")
local Object = require("nui.object")
@@ -15,6 +16,8 @@ local log = require("leetcode.logger")
---@field file Path
---@field q lc.question_res
---@field description lc.ui.Description
+---@field submissions? lc.ui.Submissions
+---@field mem0_recall_summary? table
---@field bufnr integer
---@field console lc.ui.Console
---@field lang string
@@ -22,6 +25,54 @@ local log = require("leetcode.logger")
---@field reset boolean
local Question = Object("LeetQuestion")
+local function ai_assist_default_enabled()
+ local ai_assist = config.user.ai_assist or {}
+ return ai_assist.enabled ~= false
+end
+
+local function ai_assist_label(enabled)
+ local state = enabled and "on" or "off"
+ local hl = enabled and "leetcode_easy" or "leetcode_error"
+ return string.format(" %%#leetcode_alt#AI%%*:%%#%s#%s%%*", hl, state)
+end
+
+function Question:is_auto_ai_assist_enabled()
+ if self._auto_ai_assist_enabled == nil then
+ self._auto_ai_assist_enabled = ai_assist_default_enabled()
+ end
+ return self._auto_ai_assist_enabled
+end
+
+function Question:set_auto_ai_assist_enabled(enabled)
+ self._auto_ai_assist_enabled = not not enabled
+ if self._session_timer then
+ self:render_session_winbar()
+ end
+end
+
+function Question:toggle_auto_ai_assist()
+ local enabled = not self:is_auto_ai_assist_enabled()
+ self:set_auto_ai_assist_enabled(enabled)
+ return enabled
+end
+
+function Question:render_session_winbar(elapsed_s)
+ if not (self.winid and vim.api.nvim_win_is_valid(self.winid)) then
+ return
+ end
+
+ elapsed_s = elapsed_s or 0
+ local mins = math.floor(elapsed_s / 60)
+ local secs = elapsed_s % 60
+ local label = string.format(
+ " %%#leetcode_timer#⏱ %02d:%02d%%*%s",
+ mins,
+ secs,
+ ai_assist_label(self:is_auto_ai_assist_enabled())
+ )
+ vim.api.nvim_set_option_value("winbar", label, { win = self.winid })
+end
+
---@param raw? boolean
function Question:snippet(raw)
local snippets = self.q.code_snippets ~= vim.NIL and self.q.code_snippets or {}
@@ -135,7 +186,7 @@ function Question:open_buffer(existed)
ui_utils.buf_set_opts(self.bufnr, { buflisted = true })
ui_utils.win_set_buf(self.winid, self.bufnr, true)
- vim.cmd([[match DiagnosticHint /@leet/]])
+ vim.cmd([[match DiagnosticHint /@leet\|@thought/]])
if config.user.editor.fold_imports then
self:editor_fold_imports(false)
@@ -266,6 +317,8 @@ function Question:injector(code)
end
function Question:_unmount()
+ self:run_leave_hooks()
+
if vim.v.dying ~= 0 then
return
end
@@ -273,6 +326,9 @@ function Question:_unmount()
vim.schedule(function()
self.info:unmount()
self.console:unmount()
+ if self.submissions then
+ self.submissions:unmount()
+ end
self.description:unmount()
if self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr) then
@@ -294,6 +350,17 @@ function Question:unmount()
end
local group = vim.api.nvim_create_augroup("leetcode_questions", { clear = true })
+
+function Question:run_leave_hooks()
+ if self._leave_hooks_ran then
+ return
+ end
+
+ self._leave_hooks_ran = true
+ utils.exec_hooks("timer_stop", self)
+ utils.exec_hooks("question_leave", self)
+end
+
function Question:autocmds()
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
@@ -302,12 +369,70 @@ function Question:autocmds()
self:_unmount()
end,
})
+
+ vim.api.nvim_create_autocmd("TabLeave", {
+ group = group,
+ callback = function()
+ local tabp = utils.question_tabp(self)
+ if tabp and tabp == vim.api.nvim_get_current_tabpage() and self._session_timer then
+ utils.exec_hooks("timer_stop", self)
+ end
+ end,
+ })
+
+ vim.api.nvim_create_autocmd("VimLeavePre", {
+ group = group,
+ callback = function()
+ self:run_leave_hooks()
+ end,
+ })
+end
+
+function Question:start_timer_display()
+ self:stop_timer_display() -- reset if already running
+
+ local start_ms = vim.loop.hrtime() / 1e6 -- milliseconds
+
+ local function update_winbar()
+ local elapsed_s = math.floor((vim.loop.hrtime() / 1e6 - start_ms) / 1000)
+ vim.schedule(function()
+ if not (self.winid and vim.api.nvim_win_is_valid(self.winid)) then
+ self:stop_timer_display()
+ return
+ end
+ self:render_session_winbar(elapsed_s)
+ end)
+ end
+
+ update_winbar() -- render immediately
+ self._session_timer = vim.loop.new_timer()
+ self._session_timer:start(1000, 1000, update_winbar)
+end
+
+function Question:stop_timer_display()
+ if self._session_timer then
+ self._session_timer:stop()
+ if not self._session_timer:is_closing() then
+ self._session_timer:close()
+ end
+ self._session_timer = nil
+ end
+ vim.schedule(function()
+ if self.winid and vim.api.nvim_win_is_valid(self.winid) then
+ vim.api.nvim_set_option_value("winbar", "", { win = self.winid })
+ end
+ end)
end
function Question:handle_mount()
self:create_buffer()
self.description = Description(self):mount()
+ if config.user.description.submissions.enabled then
+ self.submissions = Submissions(self):mount()
+ end
+ utils.exec_hooks("problem_description_open", self)
+
self.console = Console(self)
self.info = Info(self)
@@ -319,6 +444,14 @@ function Question:handle_mount()
return self
end
+---@param submissions table[]
+function Question:set_past_submissions(submissions)
+ self.past_submissions = submissions or {}
+ if self.submissions then
+ self.submissions:update_submissions(self.past_submissions)
+ end
+end
+
function Question:mount()
local tabp = utils.detect_duplicate_question(self.cache.title_slug, config.lang)
if tabp then
diff --git a/lua/leetcode-ui/split/submissions.lua b/lua/leetcode-ui/split/submissions.lua
new file mode 100644
index 00000000..952dd806
--- /dev/null
+++ b/lua/leetcode-ui/split/submissions.lua
@@ -0,0 +1,580 @@
+local config = require("leetcode.config")
+
+local Group = require("leetcode-ui.group")
+local Line = require("leetcode-ui.line")
+local Padding = require("leetcode-ui.lines.padding")
+local Split = require("leetcode-ui.split")
+local ui_utils = require("leetcode-ui.utils")
+local active_panels_by_slug = {}
+
+---@class lc.ui.Submissions : lc-ui.Split
+---@field question lc.ui.Question
+---@field active_tab "submissions"|"memory"|"similar"
+---@field submissions_state "loading"|"ready"|"empty"|"error"
+---@field memory_state "loading"|"ready"|"empty"|"error"
+---@field similar_state "loading"|"ready"|"empty"|"error"
+---@field submissions table[]
+---@field message? string
+---@field memory_summary? table
+---@field memory_message? string
+---@field similar_message? string
+local Submissions = Split:extend("LeetSubmissions")
+
+local function debug_log(msg)
+ vim.schedule(function()
+ vim.fn.histadd("message", "[submissions_panel] " .. msg)
+ end)
+end
+
+local function status_hl(status)
+ if status == "Accepted" then
+ return "leetcode_easy"
+ end
+
+ if status == "Wrong Answer" or status == "Runtime Error" or status == "Time Limit Exceeded" then
+ return "leetcode_hard"
+ end
+
+ return "leetcode_normal"
+end
+
+local function status_icon(status)
+ if status == "Accepted" then
+ return config.icons.status.ac, "leetcode_easy"
+ end
+
+ return config.icons.status.notac, "leetcode_hard"
+end
+
+local function trim_timestamp(timestamp)
+ if not timestamp or timestamp == "" then
+ return "unknown"
+ end
+
+ return timestamp:gsub(" %u+$", "")
+end
+
+local function panel_content_width(self)
+ local win_width = ui_utils.win_width(self)
+ local usable_width = math.max(20, win_width - 6)
+ return math.min(usable_width, 88)
+end
+
+local function wrap_text(text, max_width)
+ local chunks = {}
+ local current = ""
+ local current_width = 0
+ local source = tostring(text or ""):gsub("\r\n", "\n")
+
+ for raw_line in source:gmatch("([^\n]*)\n?") do
+ if raw_line == "" then
+ if current ~= "" then
+ table.insert(chunks, current)
+ current = ""
+ current_width = 0
+ end
+ if #chunks == 0 or chunks[#chunks] ~= "" then
+ table.insert(chunks, "")
+ end
+ else
+ local index = 0
+ while true do
+ local char = vim.fn.strcharpart(raw_line, index, 1)
+ if char == nil or char == "" then
+ break
+ end
+
+ local char_width = vim.api.nvim_strwidth(char)
+ if current_width > 0 and current_width + char_width > max_width then
+ table.insert(chunks, current)
+ current = char
+ current_width = char_width
+ else
+ current = current .. char
+ current_width = current_width + char_width
+ end
+
+ index = index + 1
+ end
+
+ if current ~= "" then
+ table.insert(chunks, current)
+ current = ""
+ current_width = 0
+ end
+ end
+ end
+
+ while #chunks > 0 and chunks[#chunks] == "" do
+ table.remove(chunks)
+ end
+
+ return vim.tbl_isempty(chunks) and { "" } or chunks
+end
+
+local function insert_wrapped_value(layout, label, content, max_width, value_hl)
+ local label_width = vim.api.nvim_strwidth(label)
+ local wrapped = wrap_text(content, math.max(10, max_width - label_width))
+
+ for index, segment in ipairs(wrapped) do
+ local line = Line()
+ if index == 1 then
+ line:append(label, "leetcode_list")
+ else
+ line:append((" "):rep(label_width), "leetcode_list")
+ end
+ line:append(segment, value_hl or "leetcode_normal")
+ layout:insert(line)
+ end
+end
+
+local function compact_string_list(values, limit)
+ if type(values) ~= "table" then
+ return {}
+ end
+
+ local result = {}
+ local seen = {}
+
+ for _, value in ipairs(values) do
+ if value and value ~= vim.NIL and value ~= "" then
+ local text = tostring(value)
+ if not seen[text] then
+ table.insert(result, text)
+ seen[text] = true
+ end
+ end
+
+ if limit and #result >= limit then
+ break
+ end
+ end
+
+ return result
+end
+
+local function session_failure_summaries(session)
+ local summaries = compact_string_list(session.failureSummaries, 5)
+
+ if #summaries == 0 and session.failureSummary and session.failureSummary ~= vim.NIL and session.failureSummary ~= "" then
+ table.insert(summaries, tostring(session.failureSummary))
+ end
+
+ return summaries
+end
+
+local function memory_status_hl(status)
+ if status == "Accepted" then
+ return "leetcode_easy"
+ end
+
+ if status == "Wrong Answer" or status == "Runtime Error" or status == "Time Limit Exceeded" then
+ return "leetcode_hard"
+ end
+
+ return "leetcode_alt"
+end
+
+function Submissions:populate()
+ local max_width = panel_content_width(self)
+ local layout = Group({}, {
+ padding = {
+ left = 1,
+ right = 1,
+ top = 1,
+ },
+ })
+
+ local tabs = Line()
+ tabs:append("[1] 历史提交", self.active_tab == "submissions" and "leetcode_medium" or "leetcode_alt")
+ tabs:append(" | ", "leetcode_alt")
+ tabs:append("[2] 历史记忆", self.active_tab == "memory" and "leetcode_medium" or "leetcode_alt")
+ tabs:append(" | ", "leetcode_alt")
+ tabs:append("[3] 类似题", self.active_tab == "similar" and "leetcode_medium" or "leetcode_alt")
+ layout:insert(tabs)
+ layout:insert(Padding(1))
+
+ if self.active_tab == "submissions" and self.submissions_state == "loading" then
+ local line = Line()
+ line:append("正在加载历史提交...", "leetcode_alt")
+ layout:insert(line)
+ elseif self.active_tab == "submissions" and self.submissions_state == "error" then
+ local line = Line()
+ line:append(self.message or "提交服务暂不可用", "leetcode_hard")
+ layout:insert(line)
+ elseif self.active_tab == "submissions" and self.submissions_state == "empty" then
+ local line = Line()
+ line:append("还没有历史提交", "leetcode_alt")
+ layout:insert(line)
+ elseif self.active_tab == "submissions" then
+ for index, submission in ipairs(self.submissions) do
+ local title = Line()
+ title:append(("%d. "):format(index), "leetcode_list")
+ title:append(trim_timestamp(submission.submitted_at_pst), "leetcode_alt")
+ layout:insert(title)
+
+ local details = Line()
+ local icon, icon_hl = status_icon(submission.submit_result)
+ details:append(icon, icon_hl)
+ details:append(" 用时=", "leetcode_list")
+
+ local time_spent = submission.time_spent_minutes
+ if time_spent == nil or time_spent == vim.NIL then
+ details:append("未知", "leetcode_alt")
+ else
+ details:append(("%sm"):format(time_spent), "leetcode_normal")
+ end
+
+ details:append(" 测试=", "leetcode_list")
+ details:append(submission.is_test and "是" or "否", submission.is_test and "leetcode_medium" or "leetcode_normal")
+ details:append(" ", "leetcode_list")
+ details:append(submission.submit_result or "Unknown", status_hl(submission.submit_result))
+ layout:insert(details)
+
+ if index < #self.submissions then
+ layout:insert(Padding(1))
+ end
+ end
+ elseif self.active_tab == "memory" and self.memory_state == "loading" then
+ local line = Line()
+ line:append("正在加载历史记忆...", "leetcode_alt")
+ layout:insert(line)
+ elseif self.active_tab == "memory" and self.memory_state == "error" then
+ local line = Line()
+ line:append(self.memory_message or "历史记忆暂不可用", "leetcode_hard")
+ layout:insert(line)
+ elseif self.active_tab == "memory" and self.memory_state == "empty" then
+ local line = Line()
+ line:append("这道题还没有可回忆的历史记录", "leetcode_alt")
+ layout:insert(line)
+ elseif self.active_tab == "memory" then
+ local summary = self.memory_summary or {}
+ local count_line = Line()
+ count_line:append("历史记录数:", "leetcode_list")
+ count_line:append(tostring(summary.record_count or 0), "leetcode_normal")
+ layout:insert(count_line)
+
+ local sessions = summary.sessions or {}
+ for index, session in ipairs(sessions) do
+ layout:insert(Padding(1))
+
+ local header = Line()
+ header:append(("%d. "):format(index), "leetcode_list")
+ header:append(session.endReason or "unknown", "leetcode_medium")
+ if session.latestFailureStatus and session.latestFailureStatus ~= vim.NIL then
+ header:append(" ", "leetcode_alt")
+ header:append(session.latestFailureStatus, memory_status_hl(session.latestFailureStatus))
+ end
+ layout:insert(header)
+
+ if session.distinctMistakeCount and session.distinctMistakeCount ~= vim.NIL then
+ insert_wrapped_value(layout, "mistakes: ", tostring(session.distinctMistakeCount), max_width)
+ end
+
+ local failure_summaries = session_failure_summaries(session)
+ if #failure_summaries > 0 then
+ insert_wrapped_value(layout, "failure: ", table.concat(failure_summaries, " | "), max_width)
+ end
+
+ if type(session.stuckPoints) == "table" and #session.stuckPoints > 0 then
+ insert_wrapped_value(layout, "stuck: ", table.concat(session.stuckPoints, " | "), max_width)
+ end
+
+ if type(session.thoughtProcess) == "table" and #session.thoughtProcess > 0 then
+ insert_wrapped_value(layout, "thought: ", table.concat(session.thoughtProcess, " | "), max_width)
+ end
+ end
+ elseif self.similar_state == "loading" then
+ local line = Line()
+ line:append("正在加载类似题...", "leetcode_alt")
+ layout:insert(line)
+ elseif self.similar_state == "error" then
+ local line = Line()
+ line:append(self.similar_message or "类似题暂不可用", "leetcode_hard")
+ layout:insert(line)
+ elseif self.similar_state == "empty" then
+ local line = Line()
+ line:append("这道题还没有可回忆的类似题", "leetcode_alt")
+ layout:insert(line)
+ else
+ local summary = self.memory_summary or {}
+ local similar_matches = summary.similar_matches or {}
+ local similar_title = Line()
+ similar_title:append("类似题:", "leetcode_list")
+ similar_title:append(tostring(summary.similar_match_count or #similar_matches), "leetcode_normal")
+ layout:insert(similar_title)
+
+ for index, match in ipairs(similar_matches) do
+ layout:insert(Padding(1))
+
+ local header = Line()
+ header:append(("%d. "):format(index), "leetcode_list")
+ header:append(match.titleSlug or "unknown", "leetcode_medium")
+ if match.difficulty and match.difficulty ~= vim.NIL then
+ header:append(" ", "leetcode_alt")
+ header:append(match.difficulty, "leetcode_alt")
+ end
+ if match.score and match.score ~= vim.NIL then
+ header:append(" score=", "leetcode_list")
+ header:append(tostring(match.score), "leetcode_normal")
+ end
+ layout:insert(header)
+
+ if match.profile and match.profile.problemSummary and match.profile.problemSummary ~= "" then
+ insert_wrapped_value(layout, "why: ", match.profile.problemSummary, max_width)
+ end
+
+ local failure_summaries = compact_string_list(match.failureSummaries, 5)
+ if #failure_summaries > 0 then
+ insert_wrapped_value(layout, "failure: ", table.concat(failure_summaries, " | "), max_width)
+ end
+
+ if type(match.stuckPoints) == "table" and #match.stuckPoints > 0 then
+ insert_wrapped_value(layout, "stuck: ", table.concat(match.stuckPoints, " | "), max_width)
+ end
+
+ if type(match.thoughtProcess) == "table" and #match.thoughtProcess > 0 then
+ insert_wrapped_value(layout, "thought: ", table.concat(match.thoughtProcess, " | "), max_width)
+ end
+ end
+ end
+
+ self.renderer:replace({ layout })
+end
+
+function Submissions:draw()
+ self:populate()
+ Submissions.super.draw(self)
+end
+
+function Submissions:set_loading()
+ debug_log("set_loading")
+ self.submissions_state = "loading"
+ self.message = nil
+ self.submissions = {}
+ self:draw()
+end
+
+---@param msg string
+function Submissions:set_error(msg)
+ debug_log("set_error " .. tostring(msg))
+ self.submissions_state = "error"
+ self.message = msg
+ self.submissions = {}
+ self:draw()
+end
+
+---@param submissions table[]
+function Submissions:update_submissions(submissions)
+ debug_log("update_submissions count=" .. tostring(submissions and #submissions or 0))
+ self.submissions = submissions or {}
+ self.message = nil
+ self.submissions_state = vim.tbl_isempty(self.submissions) and "empty" or "ready"
+ self:draw()
+end
+
+function Submissions:set_memory_loading()
+ self.memory_state = "loading"
+ self.similar_state = "loading"
+ self.memory_message = nil
+ self.similar_message = nil
+ self.memory_summary = nil
+ self:draw()
+end
+
+---@param msg string
+function Submissions:set_memory_error(msg)
+ self.memory_state = "error"
+ self.similar_state = "error"
+ self.memory_message = msg
+ self.similar_message = msg
+ self.memory_summary = nil
+ self:draw()
+end
+
+---@param summary table
+function Submissions:update_memory(summary)
+ self.memory_summary = summary or {}
+ self.memory_message = nil
+ self.similar_message = nil
+ local has_memory = summary and summary.has_history
+ local has_similar = summary and (
+ ((summary.similar_match_count or 0) > 0)
+ or (type(summary.similar_matches) == "table" and #summary.similar_matches > 0)
+ )
+ self.memory_state = has_memory and "ready" or "empty"
+ self.similar_state = has_similar and "ready" or "empty"
+ self.question.mem0_recall_summary = self.memory_summary
+ self:draw()
+end
+
+function Submissions:refresh_memory()
+ local ok, saver = pcall(require, "submission_db_saver")
+ if not ok then
+ self:set_memory_error("submission_db_saver.lua not found")
+ return
+ end
+
+ self:set_memory_loading()
+ saver.get_mem0_recall_summary(self.question, function(response)
+ vim.schedule(function()
+ if not self._.mounted then
+ debug_log("refresh_memory callback ignored because panel unmounted")
+ return
+ end
+
+ debug_log("refresh_memory response=" .. vim.inspect(response))
+ if response.error then
+ self:set_memory_error(response.error)
+ return
+ end
+
+ self:update_memory(response)
+ end)
+ end)
+end
+
+---@param tab "submissions"|"memory"|"similar"
+function Submissions:switch_tab(tab)
+ if self.active_tab == tab then
+ return
+ end
+
+ self.active_tab = tab
+ self:draw()
+end
+
+function Submissions:fetch()
+ debug_log("fetch begin for " .. self.question.q.title_slug)
+ self:set_loading()
+ self:set_memory_loading()
+
+ local ok, saver = pcall(require, "submission_db_saver")
+ if not ok then
+ self:set_error("submission_db_saver.lua not found")
+ self:set_memory_error("submission_db_saver.lua not found")
+ return
+ end
+
+ saver.get_past_submissions(self.question, function(response)
+ vim.schedule(function()
+ if not self._.mounted then
+ debug_log("callback ignored because panel unmounted")
+ return
+ end
+
+ debug_log("callback response=" .. vim.inspect(response))
+ if response.error then
+ self:set_error(response.error)
+ return
+ end
+
+ local submissions = response.submissions or {}
+ self.question.past_submissions = submissions
+ self:update_submissions(submissions)
+ end)
+ end, config.user.description.submissions.limit)
+
+ saver.get_mem0_recall_summary(self.question, function(response)
+ vim.schedule(function()
+ if not self._.mounted then
+ debug_log("mem0 callback ignored because panel unmounted")
+ return
+ end
+
+ debug_log("mem0 callback response=" .. vim.inspect(response))
+ if response.error then
+ self:set_memory_error(response.error)
+ return
+ end
+
+ self:update_memory(response)
+ end)
+ end)
+end
+
+function Submissions:mount()
+ Submissions.super.mount(self)
+ active_panels_by_slug[self.question.q.title_slug] = self
+ self:fetch()
+ self:map("n", { "1", "1" }, function()
+ self:switch_tab("submissions")
+ end)
+ self:map("n", { "2", "2" }, function()
+ self:switch_tab("memory")
+ end)
+ self:map("n", { "3", "3" }, function()
+ self:switch_tab("similar")
+ end)
+
+ local ui_utils = require("leetcode-ui.utils")
+ ui_utils.buf_set_opts(self.bufnr, {
+ modifiable = false,
+ buflisted = false,
+ swapfile = false,
+ buftype = "nofile",
+ filetype = config.name,
+ })
+ ui_utils.win_set_opts(self.winid, {
+ winhighlight = "Normal:NormalFloat,FloatBorder:FloatBorder",
+ wrap = false,
+ colorcolumn = "",
+ foldcolumn = "0",
+ cursorcolumn = false,
+ cursorline = false,
+ number = false,
+ relativenumber = false,
+ list = false,
+ spell = false,
+ signcolumn = "no",
+ linebreak = false,
+ })
+ ui_utils.win_set_winfixbuf(self.winid)
+
+ self:draw()
+ return self
+end
+
+function Submissions:unmount()
+ if active_panels_by_slug[self.question.q.title_slug] == self then
+ active_panels_by_slug[self.question.q.title_slug] = nil
+ end
+ return Submissions.super.unmount(self)
+end
+
+---@param parent lc.ui.Question
+function Submissions:init(parent)
+ Submissions.super.init(self, {
+ relative = {
+ type = "win",
+ winid = parent.description.winid,
+ },
+ position = "bottom",
+ size = config.user.description.submissions.height,
+ enter = false,
+ focusable = true,
+ })
+
+ self.question = parent
+ self.active_tab = "submissions"
+ self.submissions_state = "loading"
+ self.memory_state = "loading"
+ self.similar_state = "loading"
+ self.submissions = parent.past_submissions or {}
+ self.memory_summary = parent.mem0_recall_summary
+end
+
+---@type fun(parent: lc.ui.Question): lc.ui.Submissions
+local LeetSubmissions = Submissions
+
+function LeetSubmissions.refresh_memory_for_slug(title_slug)
+ local panel = active_panels_by_slug[title_slug]
+ if not panel or not panel._.mounted then
+ return false
+ end
+
+ panel:refresh_memory()
+ return true
+end
+
+return LeetSubmissions
diff --git a/lua/leetcode/command/init.lua b/lua/leetcode/command/init.lua
index 11824456..ed8ae833 100644
--- a/lua/leetcode/command/init.lua
+++ b/lua/leetcode/command/init.lua
@@ -264,7 +264,7 @@ function cmd.q_run()
utils.auth_guard()
local q = utils.curr_question()
if q then
- q.console:run()
+ q.console:run(false, "on_test_result")
end
end
@@ -273,7 +273,73 @@ function cmd.q_submit()
utils.auth_guard()
local q = utils.curr_question()
if q then
- q.console:run(true)
+ q.console:run(true, "upload_submit_result")
+ end
+end
+
+function cmd.timer_start()
+ local utils = require("leetcode.utils")
+ local q = utils.curr_question()
+ if not q then
+ return
+ end
+ utils.exec_hooks("timer_start", q)
+end
+
+function cmd.timer_stop()
+ local utils = require("leetcode.utils")
+ local q = utils.curr_question()
+ if not q then
+ return
+ end
+ utils.exec_hooks("timer_stop", q)
+end
+
+local function set_ai_assist(enabled)
+ local utils = require("leetcode.utils")
+ local q = utils.curr_question()
+ if not q then
+ return
+ end
+
+ q:set_auto_ai_assist_enabled(enabled)
+ log.info(("Auto AI assist %s"):format(enabled and "on" or "off"))
+end
+
+function cmd.ai_assist()
+ local utils = require("leetcode.utils")
+ local q = utils.curr_question()
+ if not q then
+ return
+ end
+
+ local enabled = q:toggle_auto_ai_assist()
+ log.info(("Auto AI assist %s"):format(enabled and "on" or "off"))
+end
+
+function cmd.ai_assist_on()
+ set_ai_assist(true)
+end
+
+function cmd.ai_assist_off()
+ set_ai_assist(false)
+end
+
+function cmd.q_upload_test_result()
+ local utils = require("leetcode.utils")
+ utils.auth_guard()
+ local q = utils.curr_question()
+ if q then
+ q.console:run(false, "upload_test_result")
+ end
+end
+
+function cmd.q_upload_submit_result()
+ local utils = require("leetcode.utils")
+ utils.auth_guard()
+ local q = utils.curr_question()
+ if q then
+ q.console:run(true, "upload_submit_result")
end
end
@@ -414,6 +480,10 @@ function cmd.fold()
q:editor_fold_imports(true)
end
+function cmd.companion()
+ require("leetcode.integrations.codecompanion").prompt_and_open()
+end
+
function cmd.get_active_session()
local sessions = config.sessions.all
return vim.tbl_filter(function(s)
@@ -610,7 +680,11 @@ cmd.commands = {
lang = { cmd.change_lang },
run = { cmd.q_run },
test = { cmd.q_run },
+ upload_test_result = { cmd.q_upload_test_result },
submit = { cmd.q_submit },
+ upload_submit_result = { cmd.q_upload_submit_result },
+ timer_start = { cmd.timer_start },
+ timer_stop = { cmd.timer_stop },
daily = { cmd.qot },
yank = { cmd.yank },
open = { cmd.open },
@@ -619,6 +693,13 @@ cmd.commands = {
restore = { cmd.restore },
inject = { cmd.inject },
fold = { cmd.fold },
+ companion = { cmd.companion },
+ ai_assist = {
+ cmd.ai_assist,
+ on = { cmd.ai_assist_on },
+ off = { cmd.ai_assist_off },
+ toggle = { cmd.ai_assist },
+ },
-- session = {
-- change = {
-- cmd.change_session,
@@ -648,6 +729,10 @@ cmd.commands = {
update = { cmd.cookie_prompt },
delete = { cmd.sign_out },
},
+ session = {
+ start = { cmd.timer_start },
+ stop = { cmd.timer_stop },
+ },
cache = {
update = { cmd.cache_update },
},
diff --git a/lua/leetcode/config/hooks.lua b/lua/leetcode/config/hooks.lua
index 05b4406e..aa394573 100644
--- a/lua/leetcode/config/hooks.lua
+++ b/lua/leetcode/config/hooks.lua
@@ -1,6 +1,8 @@
---@class lc.Hooks
local hooks = {}
+hooks["problem_description_open"] = {}
+
hooks["question_enter"] = {
function(q)
-- https://github.com/kawre/leetcode.nvim/issues/14
@@ -10,6 +12,35 @@ hooks["question_enter"] = {
end)
end
end,
+ function(q)
+ require("leetcode.utils").exec_hooks("timer_start", q)
+ end,
+}
+
+hooks["upload_submit_result"] = {
+ function(q, _, item)
+ if item and item._ and item._.success then
+ require("leetcode.utils").exec_hooks("timer_stop", q)
+ end
+ end,
+}
+
+hooks["upload_test_result"] = {}
+
+hooks["on_test_result"] = {}
+
+hooks["timer_start"] = {
+ function(q)
+ q:start_timer_display()
+ end,
}
+hooks["timer_stop"] = {
+ function(q)
+ q:stop_timer_display()
+ end,
+}
+
+hooks["question_leave"] = {}
+
return hooks
diff --git a/lua/leetcode/config/template.lua b/lua/leetcode/config/template.lua
index 3d7c5c74..40f4a149 100644
--- a/lua/leetcode/config/template.lua
+++ b/lua/leetcode/config/template.lua
@@ -22,8 +22,15 @@
---@alias lc.hook
---| "enter"
+---| "problem_description_open"
---| "question_enter"
+---| "question_leave"
---| "leave"
+---| "on_test_result"
+---| "upload_submit_result"
+---| "upload_test_result"
+---| "timer_start"
+---| "timer_stop"
---@alias lc.size
---| string
@@ -106,20 +113,61 @@ local M = {
width = "40%", ---@type lc.size
show_stats = true, ---@type boolean
+
+ submissions = {
+ enabled = true, ---@type boolean
+ height = 10, ---@type lc.size
+ limit = 10, ---@type integer
+ },
},
---@type lc.picker
picker = { provider = nil },
+ companion = {
+ adapter = "submission_service", ---@type string
+ model = nil, ---@type string|nil
+ auto_submit = true, ---@type boolean
+ auto_fence_code = true, ---@type boolean
+ prompt_user = true, ---@type boolean
+ default_prompt = "Help me understand this problem and my current code.", ---@type string
+ window = nil, ---@type table|nil
+ },
+
+ ai_assist = {
+ enabled = true, ---@type boolean
+ },
+
hooks = {
---@type fun()[]
["enter"] = {},
+ ---@type fun(question: lc.ui.Question)[]
+ ["problem_description_open"] = {},
+
---@type fun(question: lc.ui.Question)[]
["question_enter"] = {},
+ ---@type fun(question: lc.ui.Question)[]
+ ["question_leave"] = {},
+
---@type fun()[]
["leave"] = {},
+
+ ---@type fun(question: lc.ui.Question, buffer: string, item_json: table)[]
+ ["on_test_result"] = {},
+
+ ---@type fun(question: lc.ui.Question, buffer: string, item_json: table)[]
+ ["upload_submit_result"] = {},
+
+ ---@type fun(question: lc.ui.Question, buffer: string, item_json: table)[]
+ ["upload_test_result"] = {},
+
+ ---@type fun(question: lc.ui.Question)[]
+ ["timer_start"] = {},
+
+ ---@type fun(question: lc.ui.Question)[]
+ ["timer_stop"] = {},
},
keys = {
diff --git a/lua/leetcode/integrations/codecompanion.lua b/lua/leetcode/integrations/codecompanion.lua
new file mode 100644
index 00000000..62ca0900
--- /dev/null
+++ b/lua/leetcode/integrations/codecompanion.lua
@@ -0,0 +1,506 @@
+local config = require("leetcode.config")
+local lc_utils = require("leetcode.utils")
+
+local M = {}
+local active_chats_by_slug = {}
+local FENCE = "````"
+
+local function notify(message, level)
+ vim.notify(message, level or vim.log.levels.INFO, { title = "leetcode.nvim" })
+end
+
+local function current_question()
+ local current_tab = vim.api.nvim_get_current_tabpage()
+ for _, entry in ipairs(lc_utils.question_tabs()) do
+ if entry.tabpage == current_tab then
+ return entry.question
+ end
+ end
+end
+
+local function question_slug(question)
+ return question and question.q and question.q.title_slug or ""
+end
+
+local function is_chat_valid(chat)
+ return chat
+ and type(chat) == "table"
+ and chat.bufnr
+ and vim.api.nvim_buf_is_valid(chat.bufnr)
+ and vim.api.nvim_buf_is_loaded(chat.bufnr)
+end
+
+local function bind_chat(title_slug, chat)
+ if title_slug == "" or not is_chat_valid(chat) then
+ return
+ end
+ active_chats_by_slug[title_slug] = chat
+end
+
+local function has_code_fence(content)
+ return type(content) == "string" and (content:find("```", 1, true) ~= nil)
+end
+
+local function is_blank(line)
+ return vim.trim(line) == ""
+end
+
+local function is_code_like_line(line)
+ local trimmed = vim.trim(line)
+ if trimmed == "" then
+ return false
+ end
+
+ if line:match("^%s%s%s%s+") or line:match("^\t+") then
+ return true
+ end
+
+ if trimmed:match("^#") or trimmed:match('^"""') or trimmed:match("^'''") then
+ return true
+ end
+
+ if trimmed:match("^class%s+[%w_]+") or trimmed:match("^def%s+[%w_]+") then
+ return true
+ end
+
+ if trimmed:match("^[%w_%.]+%s*=%s*.+") then
+ return true
+ end
+
+ if trimmed:match("^[%w_%.]+%b()%s*:?$") then
+ return true
+ end
+
+ if
+ trimmed:match("^(from|import|return|yield|break|continue|pass)%f[%W]")
+ or trimmed:match("^(if|elif|else|for|while|with|try|except|finally)%f[%W]")
+ then
+ return true
+ end
+
+ if trimmed:match("->") or trimmed:match("[%[%]{}]") then
+ return true
+ end
+
+ return false
+end
+
+local function find_trailing_code_block(lines)
+ local block_end
+ for index = #lines, 1, -1 do
+ if not is_blank(lines[index]) then
+ block_end = index
+ break
+ end
+ end
+
+ if not block_end then
+ return nil
+ end
+
+ local code_lines = 0
+ local nonempty_lines = 0
+ local block_start = block_end
+
+ for index = block_end, 1, -1 do
+ local line = lines[index]
+ if is_blank(line) then
+ block_start = index
+ elseif is_code_like_line(line) then
+ block_start = index
+ code_lines = code_lines + 1
+ nonempty_lines = nonempty_lines + 1
+ else
+ break
+ end
+ end
+
+ if nonempty_lines < 4 or code_lines < 4 then
+ return nil
+ end
+
+ if code_lines / nonempty_lines < 0.7 then
+ return nil
+ end
+
+ return block_start, block_end
+end
+
+local function auto_fence_code_blocks(content, lang)
+ if type(content) ~= "string" or content == "" or has_code_fence(content) then
+ return content, false
+ end
+
+ local lines = vim.split(content, "\n", { plain = true, trimempty = false })
+ local block_start, block_end = find_trailing_code_block(lines)
+ if not block_start or not block_end then
+ return content, false
+ end
+
+ local updated = {}
+ for index = 1, block_start - 1 do
+ table.insert(updated, lines[index])
+ end
+ table.insert(updated, ("%s%s"):format(FENCE, lang or "text"))
+ for index = block_start, block_end do
+ table.insert(updated, lines[index])
+ end
+ table.insert(updated, FENCE)
+ for index = block_end + 1, #lines do
+ table.insert(updated, lines[index])
+ end
+
+ return table.concat(updated, "\n"), true
+end
+
+local function rerender_chat(chat)
+ if not (chat and chat.ui) then
+ return
+ end
+
+ chat.ui:render(chat.buffer_context, vim.deepcopy(chat.messages), {
+ stop_context_insertion = true,
+ })
+ if chat.context then
+ chat.context:render()
+ end
+end
+
+local function normalize_latest_llm_message(chat, lang)
+ if not is_chat_valid(chat) then
+ return
+ end
+
+ for index = #chat.messages, 1, -1 do
+ local message = chat.messages[index]
+ if message.role == config.constants.LLM_ROLE and type(message.content) == "string" and message.content ~= "" then
+ message._meta = message._meta or {}
+ if message._meta.leetcode_auto_fenced then
+ return
+ end
+
+ local normalized, changed = auto_fence_code_blocks(message.content, lang)
+ if not changed then
+ return
+ end
+
+ message.content = normalized
+ message._meta.leetcode_auto_fenced = true
+ rerender_chat(chat)
+ return
+ end
+ end
+end
+
+local function unbind_chat(title_slug, chat)
+ if title_slug == "" then
+ return
+ end
+
+ if active_chats_by_slug[title_slug] == chat then
+ active_chats_by_slug[title_slug] = nil
+ end
+end
+
+local function current_chat_for_slug(title_slug)
+ local chat = active_chats_by_slug[title_slug]
+ if not is_chat_valid(chat) then
+ active_chats_by_slug[title_slug] = nil
+ return nil
+ end
+ return chat
+end
+
+local function chat_has_context_id(chat, context_id)
+ local items = chat and chat.context_items or {}
+ for _, item in ipairs(items) do
+ if item.id == context_id then
+ return true
+ end
+ end
+ return false
+end
+
+local function require_question()
+ local question = current_question()
+ if not question then
+ notify("CodeCompanionChat requires an active leetcode question", vim.log.levels.WARN)
+ return nil
+ end
+ return question
+end
+
+local function current_code(question)
+ if not (question and question.bufnr and vim.api.nvim_buf_is_valid(question.bufnr)) then
+ return question and question:snippet(true) or ""
+ end
+
+ local range = question:editor_section_range("code")
+ if range.complete then
+ return table.concat(range.lines, "\n", range.start_i, range.end_i)
+ end
+
+ return question:snippet(true) or ""
+end
+
+local function question_description(question)
+ local q = question.q or {}
+ local content = type(q.content) == "string" and q.content or ""
+ local translated = type(q.translated_content) == "string" and q.translated_content or ""
+ return lc_utils.translate(content, translated)
+end
+
+local function question_tags(question)
+ local tags = {}
+ local topic_tags = type(question.q.topic_tags) == "table" and question.q.topic_tags or {}
+ for _, tag in ipairs(topic_tags) do
+ table.insert(tags, tag.name or tag.slug or "")
+ end
+ return vim.tbl_filter(function(tag)
+ return tag ~= ""
+ end, tags)
+end
+
+local function question_hints(question)
+ local hints = type(question.q.hints) == "table" and question.q.hints or {}
+ return vim.tbl_map(function(hint)
+ return tostring(hint)
+ end, hints)
+end
+
+function M.build_context(question)
+ return {
+ title = question.q.title or "",
+ title_slug = question.q.title_slug or "",
+ difficulty = question.q.difficulty or "",
+ lang = question.lang or config.lang,
+ description = question_description(question),
+ tags = question_tags(question),
+ hints = question_hints(question),
+ code = current_code(question),
+ testcase = question.console and question.console.testcase and question.console.testcase:content() or "",
+ }
+end
+
+function M.render_context(context)
+ local parts = {
+ "# LeetCode Problem Context",
+ "",
+ ("- Title: %s"):format(context.title),
+ ("- Title Slug: %s"):format(context.title_slug),
+ ("- Difficulty: %s"):format(context.difficulty),
+ ("- Language: %s"):format(context.lang),
+ }
+
+ if not vim.tbl_isempty(context.tags) then
+ table.insert(parts, ("- Tags: %s"):format(table.concat(context.tags, ", ")))
+ end
+
+ table.insert(parts, "")
+ table.insert(parts, "## Problem Description")
+ table.insert(parts, context.description ~= "" and context.description or "(empty)")
+
+ if not vim.tbl_isempty(context.hints) then
+ table.insert(parts, "")
+ table.insert(parts, "## Hints")
+ for index, hint in ipairs(context.hints) do
+ table.insert(parts, ("%d. %s"):format(index, hint))
+ end
+ end
+
+ if context.testcase ~= "" then
+ table.insert(parts, "")
+ table.insert(parts, "## Active Testcase")
+ table.insert(parts, "```text")
+ table.insert(parts, context.testcase)
+ table.insert(parts, "```")
+ end
+
+ table.insert(parts, "")
+ table.insert(parts, "## Current Code")
+ table.insert(parts, ("```%s"):format(context.lang))
+ table.insert(parts, context.code ~= "" and context.code or "")
+ table.insert(parts, "```")
+
+ return table.concat(parts, "\n")
+end
+
+function M.render_failure_event_context(event)
+ local parts = {
+ "# LeetCode Failure Event",
+ "",
+ ("- Title Slug: %s"):format(event.title_slug),
+ ("- Event ID: %s"):format(event.event_id),
+ "- Source: submission-service",
+ "- This failure event is already stored in the active submission-service session memory.",
+ }
+
+ if event.summary ~= "" then
+ table.insert(parts, "")
+ table.insert(parts, "## Latest Failure Summary")
+ table.insert(parts, event.summary)
+ end
+
+ if type(event.annotation_count) == "number" and event.annotation_count > 0 then
+ table.insert(parts, "")
+ table.insert(parts, ("- Annotation Count: %d"):format(event.annotation_count))
+ end
+
+ return table.concat(parts, "\n")
+end
+
+function M.render_failure_event(question, event)
+ local title_slug = question_slug(question)
+ local event_id = type(event) == "table" and event.event_id or ""
+ if title_slug == "" or type(event_id) ~= "string" or event_id == "" then
+ return false
+ end
+
+ local chat = current_chat_for_slug(title_slug)
+ if not chat then
+ return false
+ end
+
+ local context_id = (""):format(event_id)
+ if chat_has_context_id(chat, context_id) then
+ return true
+ end
+
+ chat:add_context(
+ {
+ role = "user",
+ content = M.render_failure_event_context({
+ title_slug = title_slug,
+ event_id = event_id,
+ summary = type(event.summary) == "string" and event.summary or "",
+ annotation_count = tonumber(event.count) or 0,
+ }),
+ },
+ "leetcode",
+ context_id,
+ {
+ visible = false,
+ tag = "leetcode_failure_event",
+ }
+ )
+
+ if chat.refresh_context then
+ chat:refresh_context()
+ end
+
+ chat:add_buf_message({
+ role = "user",
+ content = ("Attached failure event `%s` to this session."):format(event_id),
+ })
+
+ return true
+end
+
+function M.open(opts)
+ opts = opts or {}
+
+ local question = require_question()
+ if not question then
+ return
+ end
+
+ local ok, codecompanion = pcall(require, "codecompanion")
+ if not ok then
+ notify("CodeCompanion is not available", vim.log.levels.ERROR)
+ return
+ end
+
+ local cc = config.user.companion or {}
+ local prompt = opts.prompt or cc.default_prompt or ""
+ local auto_submit = cc.auto_submit ~= false
+ local auto_fence = cc.auto_fence_code ~= false
+ local params = {
+ adapter = cc.adapter,
+ }
+
+ if cc.model and cc.model ~= "" then
+ params.model = cc.model
+ end
+
+ local context = M.build_context(question)
+ local title_slug = question_slug(question)
+ return codecompanion.chat({
+ auto_submit = auto_submit,
+ ignore_system_prompt = true,
+ params = params,
+ title = ("LeetCode: %s"):format(context.title ~= "" and context.title or context.title_slug),
+ user_prompt = prompt,
+ window_opts = cc.window,
+ callbacks = {
+ on_created = function(chat)
+ bind_chat(title_slug, chat)
+ chat:add_context(
+ {
+ role = "user",
+ content = M.render_context(context),
+ },
+ "leetcode",
+ (""):format(context.title_slug ~= "" and context.title_slug or chat.id),
+ {
+ visible = false,
+ tag = "leetcode_context",
+ }
+ )
+ end,
+ on_completed = function(chat)
+ if auto_fence and auto_submit then
+ normalize_latest_llm_message(chat, context.lang)
+ end
+ end,
+ on_closed = function(chat)
+ unbind_chat(title_slug, chat)
+ end,
+ },
+ })
+end
+
+function M.toggle_or_open()
+ return M.prompt_and_open()
+end
+
+function M.prompt_and_open()
+ local cc = config.user.companion or {}
+ local default_prompt = cc.default_prompt or ""
+
+ if cc.prompt_user == false then
+ return M.open({ prompt = default_prompt })
+ end
+
+ vim.ui.input({
+ prompt = "What do you want help with? ",
+ default = default_prompt,
+ }, function(input)
+ if input == nil then
+ return
+ end
+
+ local prompt = vim.trim(input)
+ if prompt == "" then
+ prompt = default_prompt
+ end
+
+ M.open({ prompt = prompt })
+ end)
+end
+
+function M.command(opts)
+ opts = opts or {}
+ local fargs = opts.fargs or {}
+
+ if #fargs == 0 then
+ return M.prompt_and_open()
+ end
+
+ return M.open({ prompt = table.concat(fargs, " ") })
+end
+
+function M.clear(question)
+ unbind_chat(question_slug(question), current_chat_for_slug(question_slug(question)))
+end
+
+return M
diff --git a/lua/leetcode/runner/init.lua b/lua/leetcode/runner/init.lua
index 69fd5438..aca2c311 100644
--- a/lua/leetcode/runner/init.lua
+++ b/lua/leetcode/runner/init.lua
@@ -4,6 +4,7 @@ local log = require("leetcode.logger")
local interpreter = require("leetcode.api.interpreter")
local config = require("leetcode.config")
local Judge = require("leetcode.logger.spinner.judge")
+local utils = require("leetcode.utils")
---@type Path
local leetbody = config.storage.cache:joinpath("body")
@@ -18,12 +19,13 @@ Runner.running = false
---@param self lc.Runner
---@param submit boolean
-Runner.run = vim.schedule_wrap(function(self, submit)
+---@param hook_event? lc.hook|boolean
+Runner.run = vim.schedule_wrap(function(self, submit, hook_event)
if Runner.running then
return log.warn("Runner is busy")
end
- local ok, err = pcall(Runner.handle, self, submit)
+ local ok, err = pcall(Runner.handle, self, submit, hook_event)
if not ok then
self:stop()
log.error(err)
@@ -34,7 +36,7 @@ Runner.stop = function()
Runner.running = false
end
-function Runner:handle(submit)
+function Runner:handle(submit, hook_event)
Runner.running = true
local question = self.question
@@ -52,6 +54,15 @@ function Runner:handle(submit)
end
if item then
+ -- print(vim.inspect(item))
+ if hook_event then
+ local event = hook_event
+ if hook_event == true then
+ event = submit and "upload_submit_result" or "upload_test_result"
+ end
+ utils.exec_hooks(event, question, body.typed_code, item)
+ end
+
if item._.success then
judge:success(item.status_msg)
else
diff --git a/lua/leetcode/theme/default.lua b/lua/leetcode/theme/default.lua
index 5eb22761..942d40e9 100644
--- a/lua/leetcode/theme/default.lua
+++ b/lua/leetcode/theme/default.lua
@@ -32,6 +32,7 @@ M.get = function()
easy = { fg = "#46c6c2" },
medium = { fg = "#fac31d" },
hard = { fg = "#f8615c" },
+ timer = { fg = "#f8615c" },
easy_alt = { fg = "#294d35" },
medium_alt = { fg = "#5e4e25" },
diff --git a/lua/leetcode/utils.lua b/lua/leetcode/utils.lua
index e2d9b1b5..ac0cf57c 100644
--- a/lua/leetcode/utils.lua
+++ b/lua/leetcode/utils.lua
@@ -93,6 +93,8 @@ function utils.get_hooks(event)
if type(fns) == "function" then
fns = { fns }
+ else
+ fns = vim.deepcopy(fns)
end
return vim.list_extend(fns, config.hooks[event] or {})
diff --git a/sample.codecompanionconfig.lua b/sample.codecompanionconfig.lua
new file mode 120000
index 00000000..91874b62
--- /dev/null
+++ b/sample.codecompanionconfig.lua
@@ -0,0 +1 @@
+/home/jingyi/.config/nvim/lua/plugins/codecompanion.lua
\ No newline at end of file
diff --git a/sample.leetcode.lua b/sample.leetcode.lua
new file mode 120000
index 00000000..2bf9b741
--- /dev/null
+++ b/sample.leetcode.lua
@@ -0,0 +1 @@
+/home/jingyi/dotfiles/.config/nvim/lua/plugins/leetcode.lua
\ No newline at end of file