feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
@@ -209,6 +210,7 @@ export function ChatOutline({
|
||||
onOpenList: (list: OutlineList) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const href = useSettingsHref();
|
||||
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||
const close = (
|
||||
<button
|
||||
@@ -329,7 +331,7 @@ export function ChatOutline({
|
||||
icon={Plug}
|
||||
label={t("chat.outline.connectors")}
|
||||
names={o.mcp}
|
||||
to="/settings/agents/claude-code?tab=mcp"
|
||||
to={href("agents/claude-code?tab=mcp")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name.
|
||||
* and `workspaceOf` names each tab's workspace before its own name. Settings
|
||||
* opened on a workspace is one more tab at the bar's end (`settings`, kept
|
||||
* until its X), shown instead of the panes while it is the active one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,8 +45,9 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -152,6 +155,9 @@ interface Tab {
|
||||
|
||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||
|
||||
/** The Settings tab's key among the tabs' (a group's key is its slots). */
|
||||
const SETTINGS_KEY = "settings";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs: runs,
|
||||
@@ -164,6 +170,7 @@ export function ConsoleTabs({
|
||||
sessions,
|
||||
onRenamed,
|
||||
workspaceOf,
|
||||
settings,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
@@ -187,9 +194,12 @@ export function ConsoleTabs({
|
||||
onHome?: () => void;
|
||||
/** Right end of the bar (the single pane's split menus). */
|
||||
actions?: ReactNode;
|
||||
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
|
||||
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const settingsHref = useSettingsHref();
|
||||
const aiTools = useAiTools();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// The tab being renamed (its run id) and the draft name.
|
||||
@@ -397,8 +407,9 @@ export function ConsoleTabs({
|
||||
};
|
||||
|
||||
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
|
||||
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
|
||||
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
||||
const last = tabs.length - 1;
|
||||
const last = keys.length - 1;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? i === last
|
||||
@@ -413,11 +424,14 @@ export function ConsoleTabs({
|
||||
: e.key === "End"
|
||||
? last
|
||||
: null;
|
||||
if (to === null || !tabs[to]) return;
|
||||
const key = to === null ? undefined : keys[to];
|
||||
if (key === undefined) return;
|
||||
e.preventDefault();
|
||||
tabRefs.current.get(tabs[to].key)?.focus();
|
||||
tabRefs.current.get(key)?.focus();
|
||||
};
|
||||
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
|
||||
const tabStop = settings?.active
|
||||
? SETTINGS_KEY
|
||||
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
||||
@@ -456,7 +470,7 @@ export function ConsoleTabs({
|
||||
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const active = tab.index === groups.active;
|
||||
const active = !settings?.active && tab.index === groups.active;
|
||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||
const state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
@@ -569,6 +583,48 @@ export function ConsoleTabs({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{settings && (
|
||||
<div
|
||||
className={`group relative flex shrink-0 items-center rounded-t-md ${
|
||||
settings.active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{settings.active && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={settings.active}
|
||||
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(SETTINGS_KEY, el);
|
||||
else tabRefs.current.delete(SETTINGS_KEY);
|
||||
}}
|
||||
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
|
||||
data-testid="console-tab-settings"
|
||||
onClick={settings.onOpen}
|
||||
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
{t("tabs.settings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="console-tab-settings-close"
|
||||
onClick={settings.onClose}
|
||||
aria-label={t("tabs.closeSettings")}
|
||||
title={t("tabs.closeSettings")}
|
||||
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={menuRef} className="relative flex items-center px-1.5">
|
||||
<button
|
||||
@@ -618,7 +674,10 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${item} !text-fg-muted`}
|
||||
onClick={() => navigate("/settings/agents")}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
navigate(settingsHref("agents"));
|
||||
}}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("tabs.agentSettings")}
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
||||
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* leaves them as typed; switching to the terminal pastes them into its input
|
||||
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
|
||||
@@ -58,6 +58,9 @@ import {
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptWithImages } from "./RunSetup";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -195,7 +198,9 @@ const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
@@ -209,7 +214,22 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
aria-pressed={chat}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setSessionChat(sessionId, !chat)}
|
||||
onClick={() => {
|
||||
const draft = readChatDraft(sessionId);
|
||||
if (chat) {
|
||||
const text = promptWithImages(draft.prompt, draft.images);
|
||||
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
|
||||
} else {
|
||||
const text = takeRunInput(run.id);
|
||||
if (text)
|
||||
writeChatDraft(
|
||||
sessionId,
|
||||
[draft.prompt, text].filter(Boolean).join("\n"),
|
||||
draft.images
|
||||
);
|
||||
}
|
||||
setSessionChat(sessionId, !chat);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{chat ? (
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
@@ -224,6 +227,15 @@ export function PromptEditor({
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// With a `/command` or `@path` in it, the textarea's own text turns
|
||||
// transparent and a mirror over it — same box, font and wrapping, scrolled
|
||||
// with it — draws the text with those tokens coloured.
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const tokens = useMemo(() => promptTokens(value), [value]);
|
||||
const marked = tokens.some((tok) => tok.kind !== "text");
|
||||
const box = bare
|
||||
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
|
||||
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
|
||||
// A composer grows with its text: reset to its rows, then take what the
|
||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||
useLayoutEffect(() => {
|
||||
@@ -232,6 +244,10 @@ export function PromptEditor({
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [bare, value]);
|
||||
// A mirror that just appeared starts at the top, whatever the textarea shows.
|
||||
useLayoutEffect(() => {
|
||||
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
|
||||
}, [marked]);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -545,39 +561,64 @@ export function PromptEditor({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={
|
||||
bare
|
||||
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
: "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"
|
||||
}
|
||||
/>
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
|
||||
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
|
||||
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
|
||||
/>
|
||||
{marked && (
|
||||
<div
|
||||
ref={mirrorRef}
|
||||
aria-hidden
|
||||
data-testid="prompt-mirror"
|
||||
// Its own scrollbar keeps the width the textarea's takes, but is
|
||||
// invisible, so the real one shows through.
|
||||
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
|
||||
>
|
||||
{tokens.map((tok, i) =>
|
||||
tok.kind === "text" ? (
|
||||
tok.text
|
||||
) : (
|
||||
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
|
||||
{tok.text}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{/* A trailing newline is a line in a textarea, not in a div. */}
|
||||
{"\u200b"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
|
||||
@@ -562,6 +562,24 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||
(data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||
},
|
||||
// The rows on screen, dim cells (a TUI's placeholder) left out.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return null;
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
let row = "";
|
||||
for (let x = 0; line && x < line.length; x++) {
|
||||
const cell = line.getCell(x);
|
||||
// Width 0: the right half of a wide character.
|
||||
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||
* chip, a composer
|
||||
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
|
||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
|
||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||
|
||||
describe("PromptEditor — / and @ suggestions", () => {
|
||||
it("colours /command and @path tokens through a mirror, only when there are any", () => {
|
||||
const { unmount } = render(
|
||||
<PromptEditor
|
||||
value={"/review @src/a.ts please"}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
);
|
||||
const mirror = screen.getByTestId("prompt-mirror");
|
||||
expect(mirror).toHaveAttribute("aria-hidden");
|
||||
expect(within(mirror).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
expect(box().className).toContain("text-transparent");
|
||||
unmount();
|
||||
|
||||
renderEditor("plain words");
|
||||
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
|
||||
expect(box().className).toContain("text-fg-primary");
|
||||
});
|
||||
|
||||
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const spies = renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
Reference in New Issue
Block a user