fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header
- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The 2f39f4e rewrite had left a
bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
(sessions matched to the lane by longest path-boundary cwd), instead of
every lane's.
- The Changes panel's git line and its actions are one compact row in one
card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
leaving an empty frame where the git row used to be.
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -47,6 +47,20 @@ import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
/** The claude lane a session's cwd belongs to: the longest path-boundary
|
||||
* prefix, the same rule the server binds hook sessions with. External
|
||||
* sessions are all claude — Codex emits no hooks. */
|
||||
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
|
||||
let best: Lane | null = null;
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== "claude") continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
laneId: number | null;
|
||||
@@ -97,6 +111,22 @@ export function LaneConsolePane({
|
||||
|
||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||
const laneTool = currentLane?.tool ?? "claude";
|
||||
// The page polls runs and sessions for EVERY lane; this pane's Active runs
|
||||
// list is this lane's only. No lane selected keeps the full lists.
|
||||
const laneRuns = useMemo(
|
||||
() =>
|
||||
laneId === null || !activeRuns
|
||||
? activeRuns
|
||||
: { ...activeRuns, items: activeRuns.items.filter((r) => r.laneId === laneId) },
|
||||
[activeRuns, laneId]
|
||||
);
|
||||
const laneSessions = useMemo(
|
||||
() =>
|
||||
laneId === null
|
||||
? externalSessions
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -487,11 +517,11 @@ export function LaneConsolePane({
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
activeRuns={laneRuns}
|
||||
currentHandleId={handle?.id || null}
|
||||
onAttach={attachToRun}
|
||||
runHistory={runHistory}
|
||||
externalSessions={externalSessions}
|
||||
externalSessions={laneSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
|
||||
@@ -53,30 +53,214 @@ import type {
|
||||
RunStartArgs,
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
|
||||
// Minimal SlashCommand type (from deleted RunConsole)
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
source: "project" | "user" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Minimal PromptEditor component (from deleted RunConsole)
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
"compact",
|
||||
"config",
|
||||
"model",
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
"status",
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"output-style",
|
||||
"exit",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
||||
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
||||
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
||||
if (!q) return 1;
|
||||
const n = name.toLowerCase();
|
||||
if (n === q) return 1000;
|
||||
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
||||
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
||||
const idx = n.indexOf(q);
|
||||
if (idx >= 0) return 400 - Math.min(idx, 100);
|
||||
let i = 0;
|
||||
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
||||
if (i === q.length) return 200;
|
||||
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
kind: "slash" | "file";
|
||||
query: string;
|
||||
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
||||
start: number;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
||||
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
||||
function detectTrigger(value: string, cursor: number): Trigger | null {
|
||||
let start = cursor;
|
||||
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
||||
const tok = value.slice(start, cursor);
|
||||
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
||||
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PromptEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
slashCommands: SlashCommand[];
|
||||
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
}
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
slash,
|
||||
fileCwd,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
||||
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
||||
if (commandsCwd !== fileCwd) {
|
||||
setCommandsCwd(fileCwd);
|
||||
setCommands(null);
|
||||
}
|
||||
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
||||
|
||||
// Commands and skills are read the first time `/` is typed for this cwd —
|
||||
// two directory walks nobody should pay for on every render of the form.
|
||||
useEffect(() => {
|
||||
if (kind !== "slash" || commands) return;
|
||||
let live = true;
|
||||
const toCmd = (c: CcMdItem): SlashCommand => ({
|
||||
name: c.name,
|
||||
source: c.scope,
|
||||
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
||||
});
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [kind, commands, fileCwd]);
|
||||
|
||||
// Files: debounced, and only the newest query may land.
|
||||
const query = trigger?.query ?? "";
|
||||
useEffect(() => {
|
||||
if (kind !== "file") return;
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(fileCwd, query)
|
||||
.then((r) => live && setFiles(r.items))
|
||||
.catch(() => live && setFiles([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [kind, query, fileCwd]);
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (kind !== "slash" || !commands) return [];
|
||||
const q = query.toLowerCase();
|
||||
return commands
|
||||
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
||||
a.cmd.name.localeCompare(b.cmd.name)
|
||||
)
|
||||
.map((x) => x.cmd);
|
||||
}, [kind, commands, query]);
|
||||
const count = kind === "file" ? files.length : slashItems.length;
|
||||
const current = Math.min(active, Math.max(0, count - 1));
|
||||
|
||||
const pick = (token: string) => {
|
||||
if (!trigger) return;
|
||||
const before = value.slice(0, trigger.start);
|
||||
const after = value.slice(trigger.cursor);
|
||||
const sep = after.startsWith(" ") ? "" : " ";
|
||||
onChange(before + token + sep + after);
|
||||
setTrigger(null);
|
||||
setActive(0);
|
||||
const pos = before.length + token.length + 1;
|
||||
requestAnimationFrame(() => {
|
||||
taRef.current?.focus();
|
||||
taRef.current?.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
const pickAt = (i: number) => {
|
||||
if (kind === "file") {
|
||||
const f = files[i];
|
||||
if (f) pick(`@${f}`);
|
||||
} else {
|
||||
const c = slashItems[i];
|
||||
if (c) pick(`/${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
pickAt(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
@@ -85,15 +269,89 @@ function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: Prom
|
||||
}
|
||||
};
|
||||
|
||||
const retrack = (ta: HTMLTextAreaElement) => {
|
||||
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
||||
};
|
||||
|
||||
const rowClass = (i: number) =>
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
{kind && (
|
||||
<div
|
||||
role="listbox"
|
||||
data-testid="prompt-autocomplete"
|
||||
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
||||
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
||||
</div>
|
||||
) : kind === "slash" ? (
|
||||
slashItems.map((c, i) => (
|
||||
<button
|
||||
key={`${c.source}:${c.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
// Keep focus in the textarea, or its blur closes the list first.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={rowClass(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
||||
>
|
||||
{c.source}
|
||||
</span>
|
||||
</div>
|
||||
{c.description && (
|
||||
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
files.map((f, i) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +380,6 @@ interface RunSetupProps {
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands?: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** The selected lane's CLI. Decides which advanced fields render. */
|
||||
@@ -287,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands ?? []}
|
||||
slash={props.tool !== "codex"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
|
||||
@@ -331,4 +331,56 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
|
||||
const run = (id: string, laneId: number, promptPreview: string) => ({
|
||||
id,
|
||||
laneId,
|
||||
status: "running",
|
||||
cwd: "/workspace/x",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
});
|
||||
const session = (id: string, cwd: string, name: string) =>
|
||||
({
|
||||
id,
|
||||
cwd,
|
||||
name,
|
||||
model: null,
|
||||
source: "local",
|
||||
started_at: "2026-09-24T01:00:00Z",
|
||||
}) as never;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
lanes={[
|
||||
LANE,
|
||||
{ ...LANE, id: 2, cwd: "/workspace/b" },
|
||||
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
|
||||
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
|
||||
]}
|
||||
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
|
||||
externalSessions={[
|
||||
session("s1", "/workspace/a/src", "my session"),
|
||||
session("s2", "/workspace/b", "b session"),
|
||||
session("s3", "/workspace/a/nested/x", "nested session"),
|
||||
session("s4", "/workspace/ab", "sibling prefix"),
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
|
||||
expect(screen.getByText("mine")).toBeTruthy();
|
||||
expect(screen.getByText("my session")).toBeTruthy();
|
||||
expect(screen.queryByText("other lane's")).toBeNull();
|
||||
expect(screen.queryByText("b session")).toBeNull();
|
||||
expect(screen.queryByText("nested session")).toBeNull();
|
||||
expect(screen.queryByText("sibling prefix")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
items: [
|
||||
{
|
||||
scope: "project",
|
||||
name: "ship-it",
|
||||
frontmatter: { description: "Ship the change" },
|
||||
preview: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
@@ -300,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
tool="claude"
|
||||
@@ -364,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
|
||||
expect(onResumeLast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the run will start in, not the server's own.
|
||||
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
// Enter picked the suggestion; it did not send the prompt.
|
||||
expect(spies.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests files under the cwd on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
});
|
||||
|
||||
it("offers no slash commands on a codex lane", async () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user