feat(ask): slash shortcuts for base reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1dbe719e2c
commit
534f3b6a85
4 changed files with 401 additions and 23 deletions
|
|
@ -6,18 +6,33 @@ import { cn } from "@/lib/utils";
|
|||
import { AnswerMarkdown } from "./answer-markdown";
|
||||
import { UploadCard, type UploadState } from "./upload-card";
|
||||
import type { ChartSpec } from "@/lib/reports/chart-spec";
|
||||
import {
|
||||
SLASH_COMMANDS,
|
||||
argHint,
|
||||
expandSlashCommand,
|
||||
matchCommands,
|
||||
type SlashCommand,
|
||||
} from "@/lib/chat/slash-commands";
|
||||
|
||||
/**
|
||||
* `text` is what the person sees; a slash command also carries the
|
||||
* expanded `prompt` the model gets. `local` turns (/help) never reach
|
||||
* the model.
|
||||
*/
|
||||
type Turn =
|
||||
| { role: "user"; text: string }
|
||||
| { role: "assistant"; text: string; tools?: string[]; charts: ChartSpec[] }
|
||||
| { role: "user"; text: string; prompt?: string; local?: boolean }
|
||||
| { role: "assistant"; text: string; tools?: string[]; charts: ChartSpec[]; local?: boolean }
|
||||
| { role: "upload"; upload: UploadState };
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"Which vehicles need attention?",
|
||||
"What's my projected profit on the Hills?",
|
||||
"How are all my sites doing?",
|
||||
"What's outstanding for verification?",
|
||||
];
|
||||
/** Starter chips: the base reports. */
|
||||
const COMMAND_CHIPS = ["today", "portfolio", "problems", "findings"]
|
||||
.map((name) => SLASH_COMMANDS.find((c) => c.name === name))
|
||||
.filter((c): c is SlashCommand => !!c);
|
||||
|
||||
/** What a chip or menu pick puts in the input: args leave the cursor after a space. */
|
||||
function completion(c: SlashCommand) {
|
||||
return c.args === "none" ? `/${c.name}` : `/${c.name} `;
|
||||
}
|
||||
|
||||
/** get_site_financials -> "site financials" */
|
||||
function toolLabel(name: string) {
|
||||
|
|
@ -35,6 +50,10 @@ export function ChatView() {
|
|||
const [busy, setBusy] = React.useState(false);
|
||||
const [toolRunning, setToolRunning] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
/** Inline, under the input: a bad slash command (e.g. /site with no site). */
|
||||
const [slashError, setSlashError] = React.useState<string | null>(null);
|
||||
const [menuIndex, setMenuIndex] = React.useState(0);
|
||||
const [menuDismissed, setMenuDismissed] = React.useState(false);
|
||||
/** Set when the server reports the agent loop hit its iteration cap.
|
||||
Surfaced so the user knows the answer may be incomplete rather than
|
||||
mistaking a quiet stream end for a complete one. */
|
||||
|
|
@ -78,6 +97,25 @@ export function ChatView() {
|
|||
const q = question.trim();
|
||||
if (!q || busy) return;
|
||||
|
||||
// Slash commands expand client-side. /help never calls the model.
|
||||
const expanded = expandSlashCommand(q);
|
||||
if (expanded?.kind === "error") {
|
||||
setSlashError(expanded.message);
|
||||
return;
|
||||
}
|
||||
setSlashError(null);
|
||||
if (expanded?.kind === "help") {
|
||||
setInput("");
|
||||
setTurns((prev) => [
|
||||
...prev,
|
||||
{ role: "user", text: q, local: true },
|
||||
{ role: "assistant", text: expanded.text, tools: [], charts: [], local: true },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const display = expanded?.kind === "prompt" ? expanded.display : q;
|
||||
const prompt = expanded?.kind === "prompt" ? expanded.prompt : undefined;
|
||||
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
setStoppedOnLimit(false);
|
||||
|
|
@ -90,18 +128,21 @@ export function ChatView() {
|
|||
...turns
|
||||
.filter(
|
||||
(t): t is Extract<Turn, { role: "user" | "assistant" }> =>
|
||||
t.role !== "upload",
|
||||
t.role !== "upload" && !t.local,
|
||||
)
|
||||
.map((t) => ({ role: t.role, text: t.text })),
|
||||
.map((t) => ({
|
||||
role: t.role,
|
||||
text: t.role === "user" ? t.prompt ?? t.text : t.text,
|
||||
})),
|
||||
{
|
||||
role: "user" as const,
|
||||
text: q,
|
||||
text: prompt ?? q,
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
},
|
||||
];
|
||||
setTurns((prev) => [
|
||||
...prev,
|
||||
{ role: "user", text: q },
|
||||
{ role: "user", text: display, ...(prompt ? { prompt } : {}) },
|
||||
{ role: "assistant", text: "", tools: [], charts: [] },
|
||||
]);
|
||||
|
||||
|
|
@ -199,6 +240,24 @@ export function ChatView() {
|
|||
|
||||
const empty = turns.length === 0;
|
||||
|
||||
/** Put text in the input and leave the cursor at its end. */
|
||||
const fillInput = React.useCallback((text: string) => {
|
||||
setInput(text);
|
||||
setSlashError(null);
|
||||
setMenuDismissed(false);
|
||||
setMenuIndex(0);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(text.length, text.length);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const menu = menuDismissed ? [] : matchCommands(input);
|
||||
const menuOpen = menu.length > 0 && input.trimStart().startsWith("/");
|
||||
const activeIndex = Math.min(menuIndex, Math.max(0, menu.length - 1));
|
||||
|
||||
const ACCEPT = "application/pdf,image/jpeg,image/png,image/webp";
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
const fileRef = React.useRef<HTMLInputElement>(null);
|
||||
|
|
@ -344,16 +403,28 @@ export function ChatView() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
{COMMAND_CHIPS.map((c) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => send(s)}
|
||||
key={c.name}
|
||||
onClick={() => fillInput(completion(c))}
|
||||
className="group rounded-lg border border-border bg-card px-3.5 py-3 text-left text-sm transition-colors hover:border-[#F2B165]/50 hover:bg-[#F2B165]/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#F2B165]/60"
|
||||
>
|
||||
<span className="text-foreground">{s}</span>
|
||||
<span className="font-mono text-[13px] text-foreground">/{c.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{c.summary}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Type <span className="font-mono">/</span> for every shortcut, e.g.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillInput("/site ")}
|
||||
className="font-mono text-[#E08A3C] underline-offset-2 hover:underline"
|
||||
>
|
||||
/site The Hills
|
||||
</button>
|
||||
, or just ask a question.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -477,8 +548,39 @@ export function ChatView() {
|
|||
e.preventDefault();
|
||||
send(input);
|
||||
}}
|
||||
className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-xl border border-border bg-card p-2 focus-within:border-[#F2B165]/50"
|
||||
className="relative mx-auto flex w-full max-w-3xl items-end gap-2 rounded-xl border border-border bg-card p-2 focus-within:border-[#F2B165]/50"
|
||||
>
|
||||
{menuOpen && (
|
||||
<div
|
||||
id="slash-menu"
|
||||
role="listbox"
|
||||
aria-label="Shortcuts"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-72 overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
{menu.map((c, i) => (
|
||||
<button
|
||||
key={c.name}
|
||||
id={`slash-${c.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setMenuIndex(i)}
|
||||
onClick={() => fillInput(completion(c))}
|
||||
className={cn(
|
||||
"flex w-full items-baseline gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm",
|
||||
i === activeIndex ? "bg-[#F2B165]/15" : "hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[13px] text-foreground">/{c.name}</span>
|
||||
{argHint(c) && (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{argHint(c)}</span>
|
||||
)}
|
||||
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{c.summary}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
|
|
@ -523,8 +625,41 @@ export function ChatView() {
|
|||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setSlashError(null);
|
||||
setMenuDismissed(false);
|
||||
setMenuIndex(0);
|
||||
}}
|
||||
role="combobox"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="slash-menu"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={menuOpen ? `slash-${menu[activeIndex]?.name}` : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (menuOpen) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setMenuIndex((activeIndex + step + menu.length) % menu.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
const picked = menu[activeIndex];
|
||||
const typed = input.trim();
|
||||
// Tab always completes. Enter completes a partial name; on a
|
||||
// complete command it falls through and sends.
|
||||
const partial = typed !== `/${picked.name}` && !/\s/.test(typed);
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && partial)) {
|
||||
e.preventDefault();
|
||||
fillInput(completion(picked));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Enter sends; Shift+Enter makes a new line. While a
|
||||
// request is in flight the user can still draft the next
|
||||
// question — Enter is just ignored, the button stays
|
||||
|
|
@ -535,7 +670,7 @@ export function ChatView() {
|
|||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Ask about sites, fleet, plant or orders…"
|
||||
placeholder="Ask about sites, fleet, plant or orders — or type / for reports"
|
||||
className="max-h-40 flex-1 resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
|
|
@ -547,6 +682,11 @@ export function ChatView() {
|
|||
<ArrowUp className={cn("h-4 w-4", busy && "animate-pulse motion-reduce:animate-none")} />
|
||||
</button>
|
||||
</form>
|
||||
{slashError && (
|
||||
<p role="alert" className="mx-auto mt-2 max-w-3xl text-xs text-red-700 dark:text-red-400">
|
||||
{slashError}
|
||||
</p>
|
||||
)}
|
||||
<p className="mx-auto mt-2 max-w-3xl text-[11px] text-muted-foreground">
|
||||
Reads live PocketBase data. Figures come from the dashboard's own
|
||||
calculations — the assistant never does its own arithmetic.
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
computeVehicleAttention,
|
||||
computeSiteSpend,
|
||||
computeSiteProjection,
|
||||
resolveSiteStartDate,
|
||||
parseDurationMonths,
|
||||
buildMonthlyIncomeVsExpenses,
|
||||
buildWeeklyIncomeVsExpenses,
|
||||
computeDeliveryNoteValue,
|
||||
|
|
|
|||
75
src/lib/chat/slash-commands.test.ts
Normal file
75
src/lib/chat/slash-commands.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { SLASH_COMMANDS, expandSlashCommand, matchCommands } from "./slash-commands";
|
||||
|
||||
describe("slash commands", () => {
|
||||
it("is not a command without a leading slash", () => {
|
||||
expect(expandSlashCommand("how is the hills")).toBeNull();
|
||||
});
|
||||
it("expands /site with its argument and names the tool", () => {
|
||||
const r = expandSlashCommand("/site Hills");
|
||||
expect(r).toMatchObject({ kind: "prompt", display: "/site Hills" });
|
||||
expect(r && r.kind === "prompt" && r.prompt).toMatch(/site_report/);
|
||||
expect(r && r.kind === "prompt" && r.prompt).toMatch(/Hills/);
|
||||
});
|
||||
it("requires a site for /site", () => {
|
||||
expect(expandSlashCommand("/site")).toEqual({ kind: "error", message: "Which site? e.g. /site The Hills" });
|
||||
});
|
||||
it("/findings asks for all three reports and the My read section", () => {
|
||||
const r = expandSlashCommand("/findings");
|
||||
const p = r && r.kind === "prompt" ? r.prompt : "";
|
||||
for (const t of ["portfolio_report", "problem_report", "improvement_report", "My read"]) expect(p).toContain(t);
|
||||
});
|
||||
it("/help is answered locally", () => {
|
||||
expect(expandSlashCommand("/help")?.kind).toBe("help");
|
||||
});
|
||||
it("unknown commands say so", () => {
|
||||
expect(expandSlashCommand("/nope")).toEqual({ kind: "error", message: "Unknown command /nope — type /help" });
|
||||
});
|
||||
it("menu filters by prefix", () => {
|
||||
expect(matchCommands("/p").map((c) => c.name)).toEqual(["portfolio", "problems", "plant", "purchasing"]);
|
||||
});
|
||||
|
||||
// Beyond the plan's contract tests:
|
||||
it("keeps the contract order", () => {
|
||||
expect(SLASH_COMMANDS.map((c) => c.name)).toEqual([
|
||||
"site", "portfolio", "problems", "improvements", "findings",
|
||||
"fuel", "plant", "workforce", "purchasing", "today", "help",
|
||||
]);
|
||||
});
|
||||
it("scopes optional-site commands only when a site is given", () => {
|
||||
const all = expandSlashCommand("/problems");
|
||||
const one = expandSlashCommand("/problems Kya Sands ");
|
||||
expect(all && all.kind === "prompt" && all.prompt).not.toMatch(/"/);
|
||||
expect(one).toMatchObject({ kind: "prompt", display: "/problems Kya Sands" });
|
||||
expect(one && one.kind === "prompt" && one.prompt).toContain('site "Kya Sands"');
|
||||
});
|
||||
it("ignores case in the command name and rejects arguments where none are taken", () => {
|
||||
expect(expandSlashCommand("/PORTFOLIO")?.kind).toBe("prompt");
|
||||
expect(expandSlashCommand("/today Hills")).toEqual({
|
||||
kind: "error",
|
||||
message: "/today takes no site — it covers every site.",
|
||||
});
|
||||
});
|
||||
it("/help lists every command", () => {
|
||||
const r = expandSlashCommand("/help");
|
||||
const text = r && r.kind === "help" ? r.text : "";
|
||||
for (const c of SLASH_COMMANDS) expect(text).toContain(`/${c.name}`);
|
||||
});
|
||||
it("names the tools each report needs", () => {
|
||||
const prompt = (s: string) => {
|
||||
const r = expandSlashCommand(s);
|
||||
return r && r.kind === "prompt" ? r.prompt : "";
|
||||
};
|
||||
expect(prompt("/today")).toMatch(/attendance_today.*problem_report.*rain_outlook.*delivery_note_queue/s);
|
||||
expect(prompt("/plant")).toContain("plant_equipment_summary");
|
||||
expect(prompt("/workforce")).toContain("attendance_today");
|
||||
expect(prompt("/purchasing")).toContain("supplier_balances");
|
||||
expect(prompt("/fuel")).toContain("vehicles_needing_attention");
|
||||
expect(prompt("/improvements")).toContain("improvement_report");
|
||||
});
|
||||
it("the bare slash lists every command and a partial name narrows it", () => {
|
||||
expect(matchCommands("/")).toHaveLength(SLASH_COMMANDS.length);
|
||||
expect(matchCommands("/site Hills").map((c) => c.name)).toEqual(["site"]);
|
||||
expect(matchCommands("hello")).toEqual([]);
|
||||
});
|
||||
});
|
||||
167
src/lib/chat/slash-commands.ts
Normal file
167
src/lib/chat/slash-commands.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* `/` shortcuts for Ask's base reports. Pure, client-side.
|
||||
*
|
||||
* A command expands into a precise prompt that names the tools to call,
|
||||
* so "/site Hills" always produces the same report rather than whatever
|
||||
* the model makes of a short phrase. /help is answered locally with no
|
||||
* model call. The order of SLASH_COMMANDS is the menu order.
|
||||
*/
|
||||
|
||||
export interface SlashCommand {
|
||||
/** "site" */
|
||||
name: string;
|
||||
args: "none" | "optional-site" | "required-site";
|
||||
/** Shown in the menu. */
|
||||
summary: string;
|
||||
prompt: (arg: string) => string;
|
||||
}
|
||||
|
||||
export type Expanded =
|
||||
| { kind: "prompt"; display: string; prompt: string }
|
||||
| { kind: "help"; text: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
const forSite = (arg: string) => (arg ? ` for "${arg}"` : " across all sites");
|
||||
const withSite = (arg: string) => (arg ? ` with site "${arg}"` : "");
|
||||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
name: "site",
|
||||
args: "required-site",
|
||||
summary: "Site report: KPIs, burn, income vs expenses, fuel, payroll, POs, attention",
|
||||
prompt: (s) =>
|
||||
`Produce a site report for "${s}". Call site_report for it, then problem_report for the same site. Lay it out as a report and place every chart.`,
|
||||
},
|
||||
{
|
||||
name: "portfolio",
|
||||
args: "none",
|
||||
summary: "All sites: contract vs spent vs projected, burn vs elapsed, at-risk sites",
|
||||
prompt: () =>
|
||||
"Produce a portfolio report across all sites. Call portfolio_report. Lead with the sites at risk, show every site's status and projected profit in a table, and place every chart.",
|
||||
},
|
||||
{
|
||||
name: "problems",
|
||||
args: "optional-site",
|
||||
summary: "Everything wrong right now, ranked by severity",
|
||||
prompt: (s) =>
|
||||
`Produce a problem report${forSite(s)}. Call problem_report${withSite(s)}. Group findings by severity, highest first, and link each one.`,
|
||||
},
|
||||
{
|
||||
name: "improvements",
|
||||
args: "optional-site",
|
||||
summary: "Room for improvement: data gaps and setup fixes",
|
||||
prompt: (s) =>
|
||||
`Produce a room-for-improvement report${forSite(s)}. Call improvement_report${withSite(s)}. Group findings by severity, link each one, then write "My read" and "What I'd do next" as the prompt rules describe.`,
|
||||
},
|
||||
{
|
||||
name: "findings",
|
||||
args: "none",
|
||||
summary: "Ask's own findings: portfolio + problems + improvements, with My read",
|
||||
prompt: () =>
|
||||
`Produce your own findings report. Call portfolio_report, problem_report and improvement_report. Then write "My read" and "What I'd do next" as the prompt rules describe.`,
|
||||
},
|
||||
{
|
||||
name: "fuel",
|
||||
args: "optional-site",
|
||||
summary: "Fleet fuel: litres, spend, alerts, wrong-unit limits",
|
||||
prompt: (s) =>
|
||||
s
|
||||
? `Produce a fuel report for "${s}". Call site_report for "${s}" (its fuel KPI and daily fuel chart) and vehicles_needing_attention, and report only that site's vehicles. Keep fuel alerts separate from fuel limits set in the wrong unit, and link each vehicle.`
|
||||
: "Produce a fleet fuel report across all sites. Call get_fuel_summary and vehicles_needing_attention. Keep fuel alerts separate from fuel limits set in the wrong unit, and link each vehicle.",
|
||||
},
|
||||
{
|
||||
name: "plant",
|
||||
args: "optional-site",
|
||||
summary: "Equipment hours and value (guarded meter diffs), rejected readings",
|
||||
prompt: (s) =>
|
||||
`Produce a plant report${forSite(s)}. Call plant_equipment_summary${withSite(s)}. Show the totals, the items with the most value and hours, call out rejected hour-meter readings and items with no cost per hour, and place every chart.`,
|
||||
},
|
||||
{
|
||||
name: "workforce",
|
||||
args: "optional-site",
|
||||
summary: "Hours, cost, attendance today, missed scan-outs",
|
||||
prompt: (s) =>
|
||||
`Produce a workforce report${forSite(s)}. Call top_workers${withSite(s)} and attendance_today${withSite(s)}. Show who is on site, who has not arrived, missed scan-outs, and the top workers by hours with their cost.`,
|
||||
},
|
||||
{
|
||||
name: "purchasing",
|
||||
args: "optional-site",
|
||||
summary: "POs, fulfilment, DN queue, supplier balances",
|
||||
prompt: (s) =>
|
||||
`Produce a purchasing report${forSite(s)}. Call list_purchase_orders${withSite(s)}, problem_report${withSite(s)} (for overdue and unverified orders), delivery_note_queue and supplier_balances. Lead with what is overdue or stuck, then what is owed to whom.`,
|
||||
},
|
||||
{
|
||||
name: "today",
|
||||
args: "none",
|
||||
summary: "Daily brief: attendance, alerts, rain outlook, DN queue, imports",
|
||||
prompt: () =>
|
||||
"Daily brief for today: call attendance_today, problem_report, rain_outlook and delivery_note_queue. Keep it to one screen.",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
args: "none",
|
||||
summary: "List these commands",
|
||||
prompt: () => "",
|
||||
},
|
||||
];
|
||||
|
||||
const ARG_HINT: Record<SlashCommand["args"], string> = {
|
||||
none: "",
|
||||
"optional-site": "[site]",
|
||||
"required-site": "<site>",
|
||||
};
|
||||
|
||||
export function argHint(c: SlashCommand): string {
|
||||
return ARG_HINT[c.args];
|
||||
}
|
||||
|
||||
function helpText(): string {
|
||||
const lines = SLASH_COMMANDS.map(
|
||||
(c) => `- **/${c.name}${argHint(c) ? ` ${argHint(c)}` : ""}** — ${c.summary}`,
|
||||
);
|
||||
return [
|
||||
"**Shortcuts**",
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
"`[site]` is optional; `<site>` is required. A site can be its name or project code, e.g. `/site The Hills` or `/problems C26/09`.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Split "/name rest of it " into ["name", "rest of it"]. */
|
||||
function parse(input: string): { name: string; arg: string } | null {
|
||||
const t = input.trim();
|
||||
if (!t.startsWith("/")) return null;
|
||||
const m = t.slice(1).match(/^(\S*)\s*([\s\S]*)$/);
|
||||
return { name: (m?.[1] ?? "").toLowerCase(), arg: (m?.[2] ?? "").replace(/\s+/g, " ").trim() };
|
||||
}
|
||||
|
||||
/** null = not a command (send as a normal question). */
|
||||
export function expandSlashCommand(input: string): Expanded | null {
|
||||
const p = parse(input);
|
||||
if (!p) return null;
|
||||
const cmd = SLASH_COMMANDS.find((c) => c.name === p.name);
|
||||
if (!cmd) return { kind: "error", message: `Unknown command /${p.name} — type /help` };
|
||||
if (cmd.name === "help") return { kind: "help", text: helpText() };
|
||||
if (cmd.args === "required-site" && !p.arg) {
|
||||
return { kind: "error", message: `Which site? e.g. /${cmd.name} The Hills` };
|
||||
}
|
||||
if (cmd.args === "none" && p.arg) {
|
||||
return { kind: "error", message: `/${cmd.name} takes no site — it covers every site.` };
|
||||
}
|
||||
return {
|
||||
kind: "prompt",
|
||||
display: p.arg ? `/${cmd.name} ${p.arg}` : `/${cmd.name}`,
|
||||
prompt: cmd.prompt(p.arg),
|
||||
};
|
||||
}
|
||||
|
||||
/** Commands for the menu. While an argument is being typed, only that command. */
|
||||
export function matchCommands(partial: string): SlashCommand[] {
|
||||
const p = parse(partial);
|
||||
if (!p) return [];
|
||||
const typingArg = /^\/\S+\s/.test(partial.trimStart());
|
||||
return typingArg
|
||||
? SLASH_COMMANDS.filter((c) => c.name === p.name)
|
||||
: SLASH_COMMANDS.filter((c) => c.name.startsWith(p.name));
|
||||
}
|
||||
Loading…
Reference in a new issue