diff --git a/repl.lua b/repl.lua index 3160ca2..03a6eb9 100644 --- a/repl.lua +++ b/repl.lua @@ -148,6 +148,9 @@ Meta commands: :bg-list list background jobs (issued via CMD&: or :bg-spawn) :bg-output dump the log of a background job :bg-kill SIGTERM a background job + :tree [] scan cwd file-tree, inject as [project] block in system prompt + :tree refresh re-scan with last opts (or config defaults) + :tree off clear the [project] block :delegate

one-shot sub-broker call to preset

; prints reply :help this message ]] @@ -680,6 +683,58 @@ function M.run(config) return code, out end end + + -- Phase 6 (§6 + N4): project file-tree scanner. Prefers + -- `git -C

ls-files --cached --others --exclude-standard` + -- when is inside a git repo (free .gitignore honor); + -- falls back to `find ... -not -path '*/.'` for non-repo + -- cwds. opts: { depth = N, max_chars = N }; defaults via cfg.project. + -- Returns (body, info) where info = { file_count, truncated }. + local function _scan_project_tree(dir, opts) + opts = opts or {} + local p_cfg = config.project or {} + local depth = opts.depth or p_cfg.tree_depth or 3 + local max_chars = opts.max_chars or p_cfg.tree_max_chars or 4096 + + -- N4: `git -C ` skips the subshell vs `cd && git ...`. + local in_git = os.execute( + ("git -C %s rev-parse --git-dir >/dev/null 2>&1"):format(_shq(dir)) + ) == 0 + local listcmd + if in_git then + listcmd = ("git -C %s ls-files --cached --others --exclude-standard") + :format(_shq(dir)) + else + -- find honors -maxdepth from the start path; we count the + -- depth in terms of nested subdirectories beneath . + listcmd = ("find %s -mindepth 1 -maxdepth %d -type f -not -path '*/.*' 2>/dev/null") + :format(_shq(dir), depth + 1) + end + + local pipe = io.popen(listcmd) + if not pipe then return nil, "scan failed (popen)" end + + local files = {} + for line in pipe:lines() do + -- Depth filter is a no-op for the git case (ls-files emits + -- full repo-relative paths); for find we already capped via + -- -maxdepth. Keep the slash count here as a defensive bound. + local _, slashes = line:gsub("/", "") + if slashes <= depth then files[#files + 1] = line end + end + pipe:close() + + table.sort(files) + + local body = table.concat(files, "\n") + local truncated = false + if #body > max_chars then + body = body:sub(1, max_chars) .. "\n... (truncated)" + truncated = true + end + return body, { file_count = #files, truncated = truncated, in_git = in_git } + end + local function run_shell(cmd) local chd, err = executor.maybe_chdir(cmd) if chd ~= nil then @@ -1702,6 +1757,46 @@ function M.run(config) _every_fire(j) end end + -- Phase 6: :tree meta — scan + inject project file-tree as the + -- [project] block in the system prompt. Variants per §6: + -- :tree scan with config defaults; resets _project_opts + -- :tree scan with depth=N; cached as _project_opts + -- :tree refresh re-scan with cached opts; else config defaults + -- :tree off clear ctx.project AND ctx._project_opts + meta.tree = function(args) + local sub = (args or ""):match("^%s*(%S*)") or "" + if sub == "off" then + ctx.project = nil + ctx._project_opts = nil + renderer.status("project tree cleared") + return + end + local opts + if sub == "refresh" then + opts = ctx._project_opts or {} + elseif sub == "" then + opts = {} + ctx._project_opts = nil + else + local n = tonumber(sub) + if not n or n < 1 then + renderer.status("usage: :tree [|refresh|off]"); return + end + opts = { depth = n } + ctx._project_opts = opts + end + local dir = libc.getcwd() or "." + local body, info = _scan_project_tree(dir, opts) + if not body then + renderer.status("tree scan failed: " .. tostring(info)) + return + end + ctx.project = body + renderer.status(("project tree: %d files%s (%s)"):format( + info.file_count, + info.truncated and " (truncated)" or "", + info.in_git and "git ls-files" or "find fallback")) + end meta.every = function(args) local sub = args:match("^%s*(%S*)") or "" if sub == "list" or sub == "" and args:match("^%s*$") then @@ -1919,6 +2014,25 @@ function M.run(config) renderer.status("no such bg job: #" .. id) end + -- Phase 6: cfg.project.auto_tree startup hook. Runs once before the + -- main loop opens; opts.dir = cwd at startup. Failures are status- + -- logged once and skipped — the rest of the REPL works fine. + -- :tree refresh later picks up cwd changes (cd intercept doesn't + -- auto-refresh per A8 — v2 polish). + if config.project and config.project.auto_tree then + local dir = libc.getcwd() or "." + local body, info = _scan_project_tree(dir, {}) + if body then + ctx.project = body + renderer.status(("project tree auto-injected: %d files%s (%s)") + :format(info.file_count, + info.truncated and " (truncated)" or "", + info.in_git and "git ls-files" or "find fallback")) + else + renderer.status("project tree auto-inject failed: " .. tostring(info)) + end + end + -- Main loop. while true do check_every_due()