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:
2026-05-16 21:10:54 +00:00
parent dccd9e90cc
commit bb374c2ad2
+75 -3
View File
@@ -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