repl: @path mention expansion in input lines (closes #7)
Saves the user from manual copy/paste: typing "show me @repl.lua" or
"compare @config.lua and @config.example.lua" auto-expands each mention
to a fenced code block carrying the file contents, language-tagged by
extension, and feeds the composed text to the broker.
Wired on the "ai" branch of the input loop and inside :ask. Meta and
shell branches pass through unchanged — "@foo" in shell context is a
literal program argument; meta commands store text verbatim.
Trigger rule: "@" must follow start-of-string or whitespace — avoids
false positives on email addresses ("user@example.com") and shell
short-options. Path extends to next whitespace.
Other behavior:
- Language tag derived from extension via a small lookup; unknown
extensions yield an untagged fence.
- Files over 32 KB are truncated head/tail (16K + 8K) with a marker.
- Missing files leave the literal "@path" token in place and emit
a "[aish] @path: not found" status — non-fatal, lets the user
correct the path and re-type.
- Each successful expansion emits "[aish] @path expanded (N bytes
[, truncated])" so the user sees what was inlined.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,77 @@ local mcp = require("mcp")
|
||||
local safety = require("safety")
|
||||
local json = require("dkjson")
|
||||
|
||||
-- ---------------------------------------------------------------- @-mentions (issue #7)
|
||||
-- Triggered when "@" follows start-of-string or whitespace (avoids
|
||||
-- false positives on email addresses like user@example.com). Path
|
||||
-- runs until next whitespace. The mention is replaced by a fenced
|
||||
-- code block carrying the file contents, language-tagged by extension.
|
||||
-- Files over MENTION_MAX_BYTES are truncated head+tail with a marker.
|
||||
local MENTION_MAX_BYTES = 32 * 1024
|
||||
local MENTION_HEAD = 16 * 1024
|
||||
local MENTION_TAIL = 8 * 1024
|
||||
local LANG_BY_EXT = {
|
||||
lua = "lua", py = "python", js = "javascript", ts = "typescript",
|
||||
sh = "bash", c = "c", h = "c", cc = "cpp", cpp = "cpp", hpp = "cpp",
|
||||
rs = "rust", go = "go", java = "java", rb = "ruby", md = "markdown",
|
||||
json = "json", yaml = "yaml", yml = "yaml", toml = "toml",
|
||||
html = "html", css = "css", sql = "sql", xml = "xml",
|
||||
}
|
||||
local function _lang_of(path)
|
||||
local ext = path:match("%.([%w]+)$")
|
||||
return ext and LANG_BY_EXT[ext:lower()] or ""
|
||||
end
|
||||
local function _read_truncated(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local content = f:read("*a") or ""
|
||||
f:close()
|
||||
if #content <= MENTION_MAX_BYTES then return content, false end
|
||||
local head = content:sub(1, MENTION_HEAD)
|
||||
local tail = content:sub(#content - MENTION_TAIL + 1)
|
||||
return head
|
||||
.. ("\n... [%d bytes elided] ...\n"):format(#content - MENTION_HEAD - MENTION_TAIL)
|
||||
.. tail, true
|
||||
end
|
||||
local function expand_mentions(line, on_status)
|
||||
-- Walk the line; for each "@<path>" preceded by SOL or whitespace,
|
||||
-- attempt to read and substitute. Missing files leave the literal
|
||||
-- token in place + emit a status warning.
|
||||
local out, i = {}, 1
|
||||
while i <= #line do
|
||||
local at_start = (i == 1) or line:sub(i - 1, i - 1):match("%s") ~= nil
|
||||
if at_start and line:sub(i, i) == "@" then
|
||||
local path_end = line:find("%s", i + 1) or (#line + 1)
|
||||
local path = line:sub(i + 1, path_end - 1)
|
||||
if path ~= "" then
|
||||
local content, truncated = _read_truncated(path)
|
||||
if content then
|
||||
if on_status then
|
||||
on_status(("@%s expanded (%d bytes%s)"):format(
|
||||
path, #content, truncated and ", truncated" or ""))
|
||||
end
|
||||
out[#out + 1] = ("```%s path=%s\n%s\n```"):format(
|
||||
_lang_of(path), path, content)
|
||||
i = path_end
|
||||
else
|
||||
if on_status then
|
||||
on_status(("@%s: not found"):format(path))
|
||||
end
|
||||
out[#out + 1] = line:sub(i, path_end - 1)
|
||||
i = path_end
|
||||
end
|
||||
else
|
||||
out[#out + 1] = "@"
|
||||
i = i + 1
|
||||
end
|
||||
else
|
||||
out[#out + 1] = line:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local M = {}
|
||||
|
||||
local HELP = [[
|
||||
@@ -24,7 +95,7 @@ Meta commands:
|
||||
:models list configured models (* = active)
|
||||
:history show conversation turns
|
||||
:exec <cmd> force shell execution
|
||||
:ask <text> force AI query
|
||||
:ask <text> force AI query (supports @path expansion)
|
||||
:sessions list session log files
|
||||
:save <name> rename current session log to <name>.jsonl
|
||||
:resume <name> load <name>.jsonl turns into the in-memory context
|
||||
@@ -783,7 +854,7 @@ function M.run(config)
|
||||
ask = function(args)
|
||||
args = (args or ""):match("^%s*(.-)%s*$")
|
||||
if args == "" then renderer.status("usage: :ask <text>"); return end
|
||||
ask_ai(args)
|
||||
ask_ai(expand_mentions(args, renderer.status))
|
||||
end,
|
||||
sessions = function()
|
||||
if not sessions_dir then renderer.status("(no history.dir configured)"); return end
|
||||
@@ -1210,7 +1281,8 @@ function M.run(config)
|
||||
elseif kind == "shell" then
|
||||
run_shell(payload)
|
||||
else -- "ai"
|
||||
ask_ai(payload)
|
||||
local expanded = expand_mentions(payload, renderer.status)
|
||||
ask_ai(expanded)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user