My nvim configuration
feat(claude): telescope directory sessions
3 files changed, +192 -0
+1-0claude-nvim/lua/claude/init.lua
| @@ -34,6 +34,7 @@ function M.setup(opts) | ||
| 34 | 34 | vim.keymap.set("n", "<leader>ct", ui.toggle, { desc = "Claude: Toggle panel" }) |
| 35 | 35 | vim.keymap.set("n", "<leader>cn", ui.start_new, { desc = "Claude: New session" }) |
| 36 | 36 | vim.keymap.set("n", "<leader>cr", ui.resume_last, { desc = "Claude: Resume last session" }) |
| 37 | + vim.keymap.set("n", "<leader>cs", pickers.sessions_picker, { desc = "Claude: Sessions picker" }) | |
| 37 | 38 | vim.keymap.set("n", "<leader>cq", pickers.prompts_picker, { desc = "Claude: Prompts → quickfix" }) |
| 38 | 39 | vim.keymap.set("n", "<leader>cl", pickers.qf_last, { desc = "Claude: Last prompt → quickfix" }) |
| 39 | 40 | vim.keymap.set("n", "<leader>cp", ui.open_prompt_keep_focus, { desc = "Claude: Prompt (keep focus)" }) |
+122-0claude-nvim/lua/claude/pickers.lua
| @@ -112,4 +112,126 @@ function M.prompts_picker() | ||
| 112 | 112 | }):find() |
| 113 | 113 | end |
| 114 | 114 | |
| 115 | +local function projects_dir() | |
| 116 | + local cwd = vim.fn.getcwd() | |
| 117 | + local enc = cwd:gsub("[/%.]", "-") | |
| 118 | + local home = vim.uv.os_homedir() or os.getenv("HOME") or "" | |
| 119 | + return home .. "/.claude/projects/" .. enc | |
| 120 | +end | |
| 121 | + | |
| 122 | +local function first_user_text(path, max_chars) | |
| 123 | + local f = io.open(path, "r") | |
| 124 | + if not f then return nil end | |
| 125 | + local result | |
| 126 | + for line in f:lines() do | |
| 127 | + local ok, evt = pcall(vim.json.decode, line) | |
| 128 | + if ok and type(evt) == "table" and evt.message and evt.message.role == "user" then | |
| 129 | + local c = evt.message.content | |
| 130 | + local text | |
| 131 | + if type(c) == "string" then | |
| 132 | + text = c | |
| 133 | + elseif type(c) == "table" then | |
| 134 | + local parts = {} | |
| 135 | + for _, b in ipairs(c) do | |
| 136 | + if type(b) == "table" and b.type == "text" and b.text then | |
| 137 | + table.insert(parts, b.text) | |
| 138 | + end | |
| 139 | + end | |
| 140 | + text = table.concat(parts, "\n") | |
| 141 | + end | |
| 142 | + if text and text ~= "" and not text:match("^<") then | |
| 143 | + result = text:sub(1, max_chars or 4000) | |
| 144 | + break | |
| 145 | + end | |
| 146 | + end | |
| 147 | + end | |
| 148 | + f:close() | |
| 149 | + return result | |
| 150 | +end | |
| 151 | + | |
| 152 | +local function list_sessions() | |
| 153 | + local dir = projects_dir() | |
| 154 | + local out = {} | |
| 155 | + local handle = vim.uv.fs_scandir(dir) | |
| 156 | + if not handle then return out end | |
| 157 | + while true do | |
| 158 | + local name, t = vim.uv.fs_scandir_next(handle) | |
| 159 | + if not name then break end | |
| 160 | + if t == "file" and name:sub(-6) == ".jsonl" then | |
| 161 | + local full = dir .. "/" .. name | |
| 162 | + local stat = vim.uv.fs_stat(full) | |
| 163 | + table.insert(out, { | |
| 164 | + claude_id = name:sub(1, -7), | |
| 165 | + path = full, | |
| 166 | + mtime = stat and stat.mtime.sec or 0, | |
| 167 | + }) | |
| 168 | + end | |
| 169 | + end | |
| 170 | + table.sort(out, function(a, b) return a.mtime > b.mtime end) | |
| 171 | + return out | |
| 172 | +end | |
| 173 | + | |
| 174 | +function M.sessions_picker() | |
| 175 | + local ok_t, _ = pcall(require, "telescope") | |
| 176 | + if not ok_t then | |
| 177 | + vim.notify("claude: telescope.nvim not available", vim.log.levels.ERROR) | |
| 178 | + return | |
| 179 | + end | |
| 180 | + local pickers = require("telescope.pickers") | |
| 181 | + local finders = require("telescope.finders") | |
| 182 | + local actions = require("telescope.actions") | |
| 183 | + local action_state = require("telescope.actions.state") | |
| 184 | + local previewers = require("telescope.previewers") | |
| 185 | + local conf = require("telescope.config").values | |
| 186 | + | |
| 187 | + local sessions = list_sessions() | |
| 188 | + if #sessions == 0 then | |
| 189 | + vim.notify("claude: no sessions found for " .. vim.fn.getcwd(), vim.log.levels.WARN) | |
| 190 | + return | |
| 191 | + end | |
| 192 | + | |
| 193 | + pickers.new({}, { | |
| 194 | + prompt_title = "Claude sessions (" .. #sessions .. ")", | |
| 195 | + finder = finders.new_table({ | |
| 196 | + results = sessions, | |
| 197 | + entry_maker = function(s) | |
| 198 | + local first = first_user_text(s.path, 200) or "(no prompt)" | |
| 199 | + local display = string.format( | |
| 200 | + "%s %s %s", | |
| 201 | + os.date("%Y-%m-%d %H:%M", s.mtime), | |
| 202 | + s.claude_id:sub(1, 8), | |
| 203 | + snippet(first, 80) | |
| 204 | + ) | |
| 205 | + return { | |
| 206 | + value = s, | |
| 207 | + display = display, | |
| 208 | + ordinal = display, | |
| 209 | + } | |
| 210 | + end, | |
| 211 | + }), | |
| 212 | + sorter = conf.generic_sorter({}), | |
| 213 | + previewer = previewers.new_buffer_previewer({ | |
| 214 | + define_preview = function(self, entry) | |
| 215 | + local text = first_user_text(entry.value.path, 4000) or "(no user message)" | |
| 216 | + local lines = { "# " .. entry.value.claude_id, "" } | |
| 217 | + for _, l in ipairs(vim.split(text, "\n", { plain = true })) do | |
| 218 | + table.insert(lines, l) | |
| 219 | + end | |
| 220 | + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) | |
| 221 | + vim.bo[self.state.bufnr].filetype = "markdown" | |
| 222 | + end, | |
| 223 | + }), | |
| 224 | + attach_mappings = function(prompt_bufnr) | |
| 225 | + actions.select_default:replace(function() | |
| 226 | + local entry = action_state.get_selected_entry() | |
| 227 | + actions.close(prompt_bufnr) | |
| 228 | + if entry and entry.value then | |
| 229 | + require("claude.ui").resume_session(entry.value.claude_id) | |
| 230 | + end | |
| 231 | + end) | |
| 232 | + return true | |
| 233 | + end, | |
| 234 | + }):find() | |
| 235 | +end | |
| 236 | + | |
| 115 | 237 | return M |
+69-0claude-nvim/lua/claude/ui.lua
| @@ -250,6 +250,45 @@ local function replay_history(claude_id) | ||
| 250 | 250 | return turns > 0 |
| 251 | 251 | end |
| 252 | 252 | |
| 253 | +function M.resume_session(claude_id) | |
| 254 | + if not claude_id or claude_id == "" then | |
| 255 | + vim.notify("claude: missing claude_id", vim.log.levels.WARN) | |
| 256 | + return | |
| 257 | + end | |
| 258 | + local runner = require("claude.runner") | |
| 259 | + local store = require("claude.store") | |
| 260 | + -- find existing session.json with this claude_id, else build thin session | |
| 261 | + local session | |
| 262 | + for _, entry in ipairs(store.list_sessions()) do | |
| 263 | + local s = store.load_path(entry.path) | |
| 264 | + if s and s.claude_id == claude_id then session = s break end | |
| 265 | + end | |
| 266 | + if not session then | |
| 267 | + session = { | |
| 268 | + id = store.uuid(), | |
| 269 | + started_at = os.time(), | |
| 270 | + cwd = vim.fn.getcwd(), | |
| 271 | + claude_id = claude_id, | |
| 272 | + prompts = {}, | |
| 273 | + } | |
| 274 | + end | |
| 275 | + local sid = runner.attach_existing(session) | |
| 276 | + panel.sid = sid | |
| 277 | + store.write_pointer({ session_id = session.id, claude_id = claude_id, ts = os.time(), cwd = session.cwd }) | |
| 278 | + clear_buf() | |
| 279 | + M.open_panel() | |
| 280 | + append_lines({ | |
| 281 | + "# Claude — resumed", | |
| 282 | + "_id: " .. sid:sub(1, 8) .. " ← " .. claude_id:sub(1, 8) .. "_", | |
| 283 | + "", | |
| 284 | + }) | |
| 285 | + local replayed = replay_history(claude_id) | |
| 286 | + if not replayed then | |
| 287 | + append_lines({ "_(no prior transcript found on disk)_", "" }) | |
| 288 | + end | |
| 289 | + append_lines({ "Press `i` to send a prompt.", "" }) | |
| 290 | +end | |
| 291 | + | |
| 253 | 292 | function M.resume_last() |
| 254 | 293 | local runner = require("claude.runner") |
| 255 | 294 | local sid = runner.resume_from_pointer() |
| @@ -274,6 +313,36 @@ function M.resume_last() | ||
| 274 | 313 | append_lines({ "Press `i` to send a prompt.", "" }) |
| 275 | 314 | end |
| 276 | 315 | |
| 316 | +function M.resume_session(claude_id) | |
| 317 | + if not claude_id or claude_id == "" then | |
| 318 | + vim.notify("claude: missing session id", vim.log.levels.WARN) | |
| 319 | + return | |
| 320 | + end | |
| 321 | + local runner = require("claude.runner") | |
| 322 | + local store = require("claude.store") | |
| 323 | + local session = { | |
| 324 | + id = store.uuid(), | |
| 325 | + started_at = os.time(), | |
| 326 | + cwd = vim.fn.getcwd(), | |
| 327 | + claude_id = claude_id, | |
| 328 | + prompts = {}, | |
| 329 | + } | |
| 330 | + local sid = runner.attach_existing(session) | |
| 331 | + panel.sid = sid | |
| 332 | + clear_buf() | |
| 333 | + M.open_panel() | |
| 334 | + append_lines({ | |
| 335 | + "# Claude — resumed", | |
| 336 | + "_id: " .. sid:sub(1, 8) .. " ← " .. claude_id:sub(1, 8) .. "_", | |
| 337 | + "", | |
| 338 | + }) | |
| 339 | + local replayed = replay_history(claude_id) | |
| 340 | + if not replayed then | |
| 341 | + append_lines({ "_(no prior transcript found on disk)_", "" }) | |
| 342 | + end | |
| 343 | + append_lines({ "Press `i` to send a prompt.", "" }) | |
| 344 | +end | |
| 345 | + | |
| 277 | 346 | function M.open_input(opts) |
| 278 | 347 | opts = opts or {} |
| 279 | 348 | if not panel.sid then |