Last active
January 29, 2026 06:19
-
-
Save jssee/465d0c960dc73db7cb0d9a6eada776ad to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Lumen Diff Extension | |
| * | |
| * /lumen-diff command lists all files the model has read/written/edited in the active session branch, | |
| * coalesced by path and sorted newest first. Selecting a file opens it in lumen | |
| */ | |
| import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; | |
| import { DynamicBorder } from "@mariozechner/pi-coding-agent"; | |
| import { | |
| Container, | |
| Key, | |
| matchesKey, | |
| type SelectItem, | |
| SelectList, | |
| Text, | |
| } from "@mariozechner/pi-tui"; | |
| interface FileEntry { | |
| path: string; | |
| operations: Set<"read" | "write" | "edit">; | |
| lastTimestamp: number; | |
| } | |
| type FileToolName = "read" | "write" | "edit"; | |
| export default function (pi: ExtensionAPI) { | |
| pi.registerCommand("lumen-diff", { | |
| description: "Open files read/written/edited in this session in lumen", | |
| handler: async (_args, ctx) => { | |
| if (!ctx.hasUI) { | |
| ctx.ui.notify("No UI available", "error"); | |
| return; | |
| } | |
| // Get the current branch (path from leaf to root) | |
| const branch = ctx.sessionManager.getBranch(); | |
| // First pass: collect tool calls (id -> {path, name}) from assistant messages | |
| const toolCalls = new Map< | |
| string, | |
| { path: string; name: FileToolName; timestamp: number } | |
| >(); | |
| for (const entry of branch) { | |
| if (entry.type !== "message") continue; | |
| const msg = entry.message; | |
| if (msg.role === "assistant" && Array.isArray(msg.content)) { | |
| for (const block of msg.content) { | |
| if (block.type === "toolCall") { | |
| const name = block.name; | |
| if (name === "read" || name === "write" || name === "edit") { | |
| const path = block.arguments?.path; | |
| if (path && typeof path === "string") { | |
| toolCalls.set(block.id, { | |
| path, | |
| name, | |
| timestamp: msg.timestamp, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Second pass: match tool results to get the actual execution timestamp | |
| const fileMap = new Map<string, FileEntry>(); | |
| for (const entry of branch) { | |
| if (entry.type !== "message") continue; | |
| const msg = entry.message; | |
| if (msg.role === "toolResult") { | |
| const toolCall = toolCalls.get(msg.toolCallId); | |
| if (!toolCall) continue; | |
| const { path, name } = toolCall; | |
| const timestamp = msg.timestamp; | |
| const existing = fileMap.get(path); | |
| if (existing) { | |
| existing.operations.add(name); | |
| if (timestamp > existing.lastTimestamp) { | |
| existing.lastTimestamp = timestamp; | |
| } | |
| } else { | |
| fileMap.set(path, { | |
| path, | |
| operations: new Set([name]), | |
| lastTimestamp: timestamp, | |
| }); | |
| } | |
| } | |
| } | |
| if (fileMap.size === 0) { | |
| ctx.ui.notify("No files read/written/edited in this session", "info"); | |
| return; | |
| } | |
| // Sort by most recent first | |
| const files = Array.from(fileMap.values()).sort( | |
| (a, b) => b.lastTimestamp - a.lastTimestamp, | |
| ); | |
| const selectedFiles = new Set<FileEntry>(); | |
| const runLumen = async (filesToRun: FileEntry[]): Promise<void> => { | |
| if (filesToRun.length === 0) return; | |
| const args = ["diff"]; | |
| for (const f of filesToRun) { | |
| args.push("--file", f.path); | |
| } | |
| try { | |
| if (process.env.ZELLIJ) { | |
| const zellijArgs = [ | |
| "run", | |
| "--floating", | |
| "--width", | |
| "90%", | |
| "--height", | |
| "90%", | |
| "--x", | |
| "5%", | |
| "--y", | |
| "5%", | |
| "--close-on-exit", | |
| "--name", | |
| "lumen-diff", | |
| "--cwd", | |
| ctx.cwd, | |
| "--", | |
| "lumen", | |
| ...args, | |
| ]; | |
| const result = await pi.exec("zellij", zellijArgs, { | |
| cwd: ctx.cwd, | |
| }); | |
| if (result.code !== 0) { | |
| const errorOutput = result.stderr.trim(); | |
| const detail = | |
| errorOutput.length > 0 | |
| ? errorOutput | |
| : `Exit code ${result.code}`; | |
| ctx.ui.notify( | |
| `Failed to run lumen in zellij: ${detail}`, | |
| "error", | |
| ); | |
| } | |
| } else { | |
| ctx.ui.notify( | |
| "Not running in zellij; lumen may not render in this pane", | |
| "warning", | |
| ); | |
| const result = await pi.exec("lumen", args, { cwd: ctx.cwd }); | |
| if (result.code !== 0) { | |
| const errorOutput = result.stderr.trim(); | |
| const detail = | |
| errorOutput.length > 0 | |
| ? errorOutput | |
| : `Exit code ${result.code}`; | |
| ctx.ui.notify(`Failed to run lumen: ${detail}`, "error"); | |
| } | |
| } | |
| } catch (error) { | |
| const message = | |
| error instanceof Error ? error.message : String(error); | |
| ctx.ui.notify(`Failed to run lumen: ${message}`, "error"); | |
| } | |
| }; | |
| // Show file picker with SelectList | |
| await ctx.ui.custom<void>((tui, theme, _kb, done) => { | |
| const container = new Container(); | |
| // Top border | |
| container.addChild( | |
| new DynamicBorder((s: string) => theme.fg("accent", s)), | |
| ); | |
| // Title | |
| container.addChild( | |
| new Text( | |
| theme.fg("accent", theme.bold(" Select file(s) for lumen diff")), | |
| 0, | |
| 0, | |
| ), | |
| ); | |
| let currentIndex = 0; | |
| const getLabel = (f: FileEntry) => { | |
| const ops: string[] = []; | |
| if (f.operations.has("read")) ops.push(theme.fg("muted", "R")); | |
| if (f.operations.has("write")) ops.push(theme.fg("success", "W")); | |
| if (f.operations.has("edit")) ops.push(theme.fg("warning", "E")); | |
| const opsLabel = ops.join(""); | |
| const hasManualSelection = selectedFiles.size > 0; | |
| const isSelected = hasManualSelection | |
| ? selectedFiles.has(f) | |
| : files[currentIndex] === f; | |
| const checkbox = isSelected ? theme.fg("success", "◉") : "○"; | |
| return `${checkbox} ${opsLabel} ${f.path}`; | |
| }; | |
| // Build select items with colored operations | |
| const items: SelectItem[] = files.map((f) => { | |
| return { | |
| value: f, | |
| label: getLabel(f), | |
| }; | |
| }); | |
| const visibleRows = Math.min(files.length, 15); | |
| const updateIndex = (nextIndex: number) => { | |
| const clampedIndex = Math.max( | |
| 0, | |
| Math.min(nextIndex, items.length - 1), | |
| ); | |
| if (clampedIndex === currentIndex) return; | |
| const previousIndex = currentIndex; | |
| currentIndex = clampedIndex; | |
| if (selectedFiles.size === 0) { | |
| const previousItem = items[previousIndex]; | |
| if (previousItem) { | |
| previousItem.label = getLabel(previousItem.value as FileEntry); | |
| } | |
| const currentItem = items[currentIndex]; | |
| if (currentItem) { | |
| currentItem.label = getLabel(currentItem.value as FileEntry); | |
| } | |
| } | |
| }; | |
| const selectList = new SelectList(items, visibleRows, { | |
| selectedPrefix: (t) => theme.fg("accent", t), | |
| selectedText: (t) => t, // Keep existing colors | |
| description: (t) => theme.fg("muted", t), | |
| scrollInfo: (t) => theme.fg("dim", t), | |
| noMatch: (t) => theme.fg("warning", t), | |
| }); | |
| selectList.onSelect = (item) => { | |
| const selected = Array.from(selectedFiles); | |
| if (selected.length > 0) { | |
| void runLumen(selected); | |
| } else { | |
| void runLumen([item.value as FileEntry]); | |
| } | |
| }; | |
| selectList.onCancel = () => done(); | |
| selectList.onSelectionChange = (item) => { | |
| updateIndex(items.indexOf(item)); | |
| }; | |
| container.addChild(selectList); | |
| // Help text | |
| container.addChild( | |
| new Text( | |
| theme.fg( | |
| "dim", | |
| " ↑↓ navigate • ←→ page • space toggle • enter run • esc close", | |
| ), | |
| 0, | |
| 0, | |
| ), | |
| ); | |
| // Bottom border | |
| container.addChild( | |
| new DynamicBorder((s: string) => theme.fg("accent", s)), | |
| ); | |
| return { | |
| render: (w) => container.render(w), | |
| invalidate: () => container.invalidate(), | |
| handleInput: (data) => { | |
| // Add paging with left/right | |
| if (matchesKey(data, Key.left)) { | |
| // Page up - clamp to 0 | |
| updateIndex(Math.max(0, currentIndex - visibleRows)); | |
| selectList.setSelectedIndex(currentIndex); | |
| } else if (matchesKey(data, Key.right)) { | |
| // Page down - clamp to last | |
| updateIndex( | |
| Math.min(items.length - 1, currentIndex + visibleRows), | |
| ); | |
| selectList.setSelectedIndex(currentIndex); | |
| } else if (matchesKey(data, Key.space)) { | |
| const currentItem = items[currentIndex]; | |
| if (currentItem) { | |
| const f = currentItem.value as FileEntry; | |
| if (selectedFiles.has(f)) { | |
| selectedFiles.delete(f); | |
| } else { | |
| selectedFiles.add(f); | |
| } | |
| // Update label | |
| currentItem.label = getLabel(f); | |
| } | |
| } else { | |
| selectList.handleInput(data); | |
| } | |
| tui.requestRender(); | |
| }, | |
| }; | |
| }); | |
| }, | |
| }); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment