mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-14 09:26:52 +02:00
feat: add title command line flag
This commit is contained in:
@@ -41,6 +41,15 @@ Workspaces are virtual desktops, each with its own layout tree.
|
||||
- Parsers can be async (e.g., resolving NIP-05 addresses)
|
||||
- Command pattern: user types `profile alice@example.com` → parser resolves → opens ProfileViewer with props
|
||||
|
||||
**Global Flags** (`src/lib/global-flags.ts`):
|
||||
- Global flags work across ALL commands and are extracted before command-specific parsing
|
||||
- `--title "Custom Title"` - Override the window title (supports quotes, emoji, Unicode)
|
||||
- Example: `profile alice --title "👤 Alice"`
|
||||
- Example: `req -k 1 -a npub... --title "My Feed"`
|
||||
- Position independent: can appear before, after, or in the middle of command args
|
||||
- Tokenization uses `shell-quote` library for proper quote/whitespace handling
|
||||
- Display priority: `customTitle` > `dynamicTitle` (from DynamicWindowTitle) > `appId.toUpperCase()`
|
||||
|
||||
### Reactive Nostr Pattern
|
||||
|
||||
Applesauce uses RxJS observables for reactive data flow:
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -42,6 +42,7 @@
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
@@ -3869,6 +3871,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/shell-quote": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
|
||||
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -8751,6 +8760,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
},
|
||||
@@ -60,6 +61,7 @@
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
|
||||
@@ -37,19 +37,14 @@ export default function Command({
|
||||
? await Promise.resolve(command.argParser(cmdArgs))
|
||||
: command.defaultProps || {};
|
||||
|
||||
const title =
|
||||
cmdArgs.length > 0
|
||||
? `${commandName.toUpperCase()} ${cmdArgs.join(" ")}`
|
||||
: commandName.toUpperCase();
|
||||
|
||||
addWindow(command.appId, cmdProps, title);
|
||||
addWindow(command.appId, cmdProps);
|
||||
}
|
||||
} else if (appId) {
|
||||
// Open the specified app with given props
|
||||
addWindow(appId, props || {}, name.toUpperCase());
|
||||
addWindow(appId, props || {});
|
||||
} else {
|
||||
// Default: open man page
|
||||
addWindow("man", { cmd: name }, `MAN ${name}`);
|
||||
addWindow("man", { cmd: name });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ export default function CommandLauncher({
|
||||
if (editMode) {
|
||||
updateWindow(editMode.windowId, {
|
||||
props: result.props,
|
||||
title: result.title,
|
||||
commandString: input.trim(),
|
||||
appId: recognizedCommand.appId,
|
||||
customTitle: result.globalFlags?.windowProps?.title,
|
||||
});
|
||||
setEditMode(null); // Clear edit mode
|
||||
} else {
|
||||
@@ -69,8 +69,8 @@ export default function CommandLauncher({
|
||||
addWindow(
|
||||
recognizedCommand.appId,
|
||||
result.props,
|
||||
result.title,
|
||||
input.trim(),
|
||||
result.globalFlags?.windowProps?.title,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,20 +96,17 @@ export default function DecodeViewer({ args }: DecodeViewerProps) {
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: { id: decoded.data.data, relays } },
|
||||
`Event ${decoded.data.data.slice(0, 8)}...`,
|
||||
);
|
||||
} else if (decoded.data.type === "nevent") {
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: { id: decoded.data.data.id, relays } },
|
||||
`Event ${decoded.data.data.id.slice(0, 8)}...`,
|
||||
);
|
||||
} else if (decoded.data.type === "naddr") {
|
||||
const { kind, pubkey, identifier } = decoded.data.data;
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: { kind, pubkey, identifier, relays } },
|
||||
`${kind}:${pubkey.slice(0, 8)}:${identifier}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -125,7 +122,7 @@ export default function DecodeViewer({ args }: DecodeViewerProps) {
|
||||
pubkey = decoded.data.data.pubkey;
|
||||
}
|
||||
if (pubkey) {
|
||||
addWindow("profile", { pubkey }, `Profile ${pubkey.slice(0, 8)}...`);
|
||||
addWindow("profile", { pubkey });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ export function useDynamicWindowTitle(window: WindowInstance): WindowTitleData {
|
||||
}
|
||||
|
||||
function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const { appId, props, title: staticTitle } = window;
|
||||
const { appId, props, title: staticTitle, customTitle } = window;
|
||||
|
||||
// Get relay state for conn viewer
|
||||
const { relays } = useRelayState();
|
||||
@@ -512,7 +512,15 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
// Generate raw command for tooltip
|
||||
const rawCommand = generateRawCommand(appId, props);
|
||||
|
||||
// Priority order for title selection
|
||||
// Priority 0: Custom title always wins (user override via --title flag)
|
||||
if (customTitle) {
|
||||
title = customTitle;
|
||||
icon = getCommandIcon(appId);
|
||||
tooltip = rawCommand;
|
||||
return { title, icon, tooltip };
|
||||
}
|
||||
|
||||
// Priority order for title selection (dynamic titles based on data)
|
||||
if (profileTitle) {
|
||||
title = profileTitle;
|
||||
icon = getCommandIcon("profile");
|
||||
@@ -569,7 +577,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
icon = getCommandIcon("conn");
|
||||
tooltip = rawCommand;
|
||||
} else {
|
||||
title = staticTitle;
|
||||
title = staticTitle || appId.toUpperCase();
|
||||
tooltip = rawCommand;
|
||||
}
|
||||
|
||||
@@ -578,6 +586,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
appId,
|
||||
props,
|
||||
event,
|
||||
customTitle,
|
||||
profileTitle,
|
||||
eventTitle,
|
||||
kindTitle,
|
||||
|
||||
@@ -31,7 +31,7 @@ export function EventFooter({ event }: EventFooterProps) {
|
||||
|
||||
const handleKindClick = () => {
|
||||
// Open KIND command to show NIP documentation for this kind
|
||||
addWindow("kind", { number: event.kind }, `KIND ${event.kind}`);
|
||||
addWindow("kind", { number: event.kind });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,7 @@ export function KindBadge({
|
||||
|
||||
const handleClick = () => {
|
||||
if (clickable) {
|
||||
addWindow("kind", { number: String(kind) }, `Kind ${kind}`);
|
||||
addWindow("kind", { number: String(kind) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -59,8 +59,7 @@ export default function NipsViewer() {
|
||||
} else if (e.key === "Enter" && filteredNips.length === 1) {
|
||||
// Open the single result when Enter is pressed
|
||||
const nipId = filteredNips[0];
|
||||
const title = NIP_TITLES[nipId] || `NIP-${nipId}`;
|
||||
addWindow("nip", { number: nipId }, `NIP-${nipId}: ${title}`);
|
||||
addWindow("nip", { number: nipId });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -994,7 +994,7 @@ export default function ReqViewer({
|
||||
className="text-accent underline decoration-dotted cursor-crosshair"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addWindow("nip", { number: "65" }, "NIP-65 - Relay List Metadata");
|
||||
addWindow("nip", { number: "65" });
|
||||
}}
|
||||
>
|
||||
NIP-65
|
||||
|
||||
@@ -192,7 +192,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<WindowErrorBoundary windowTitle={window.title} onClose={onClose}>
|
||||
<WindowErrorBoundary windowTitle={window.title || window.appId.toUpperCase()} onClose={onClose}>
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<div className="h-full w-full overflow-auto">{content}</div>
|
||||
</Suspense>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function WindowTile({
|
||||
// Convert title to string for MosaicWindow (which only accepts strings)
|
||||
// The actual title (with React elements) is rendered in the custom toolbar
|
||||
const titleString =
|
||||
typeof title === "string" ? title : tooltip || window.title;
|
||||
typeof title === "string" ? title : tooltip || window.title || window.appId.toUpperCase();
|
||||
|
||||
// Custom toolbar renderer to include icon
|
||||
const renderToolbar = () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export function RelayLink({
|
||||
const relayInfo = useRelayInfo(url);
|
||||
|
||||
const handleClick = () => {
|
||||
addWindow("relay", { url }, `Relay ${url}`);
|
||||
addWindow("relay", { url });
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
addWindow("profile", { pubkey }, `Profile ${pubkey.slice(0, 8)}...`);
|
||||
addWindow("profile", { pubkey });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Kind30023Renderer({ event }: BaseEventProps) {
|
||||
{title && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title}
|
||||
|
||||
className="text-lg font-bold text-foreground"
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { nip19 } from "nostr-tools";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { EventFooter } from "@/components/EventFooter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getEventDisplayTitle } from "@/lib/event-title";
|
||||
|
||||
// NIP-01 Kind ranges
|
||||
const REPLACEABLE_START = 10000;
|
||||
@@ -77,9 +76,7 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
};
|
||||
}
|
||||
|
||||
// Use automatic title extraction for better window titles
|
||||
const title = getEventDisplayTitle(event);
|
||||
addWindow("open", { pointer }, title);
|
||||
addWindow("open", { pointer });
|
||||
};
|
||||
|
||||
const copyEventId = () => {
|
||||
@@ -168,7 +165,6 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
interface ClickableEventTitleProps {
|
||||
event: NostrEvent;
|
||||
children: React.ReactNode;
|
||||
windowTitle?: string;
|
||||
className?: string;
|
||||
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "span" | "div";
|
||||
}
|
||||
@@ -176,7 +172,6 @@ interface ClickableEventTitleProps {
|
||||
export function ClickableEventTitle({
|
||||
event,
|
||||
children,
|
||||
windowTitle,
|
||||
className,
|
||||
as: Component = "h3",
|
||||
}: ClickableEventTitleProps) {
|
||||
@@ -192,8 +187,6 @@ export function ClickableEventTitle({
|
||||
event.kind < PARAMETERIZED_REPLACEABLE_END);
|
||||
|
||||
let pointer;
|
||||
// Use provided windowTitle, or fall back to automatic title extraction
|
||||
const title = windowTitle || getEventDisplayTitle(event);
|
||||
|
||||
if (isAddressable) {
|
||||
// For replaceable/parameterized replaceable events, use AddressPointer
|
||||
@@ -210,7 +203,7 @@ export function ClickableEventTitle({
|
||||
};
|
||||
}
|
||||
|
||||
addWindow("open", { pointer }, title);
|
||||
addWindow("open", { pointer });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Kind39701Renderer({ event }: BaseEventProps) {
|
||||
{title && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title}
|
||||
|
||||
className="text-lg font-bold text-foreground"
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (repoPointer) {
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export function Kind1337Renderer({ event }: BaseEventProps) {
|
||||
{/* Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={name || "Code Snippet"}
|
||||
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{name || "Code Snippet"}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function CommunityNIPRenderer({ event }: BaseEventProps) {
|
||||
<div dir="auto" className="flex flex-col gap-2">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title}
|
||||
|
||||
className="text-lg font-bold text-foreground flex-1"
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -122,10 +122,9 @@ export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: pointer },
|
||||
`Event ${pointer.slice(0, 8)}...`,
|
||||
);
|
||||
} else {
|
||||
addWindow("open", pointer, `Event`);
|
||||
addWindow("open", pointer);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -55,10 +55,9 @@ export function Kind9802Renderer({ event }: BaseEventProps) {
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: eventPointer },
|
||||
`Event ${eventPointer.id.slice(0, 8)}...`,
|
||||
);
|
||||
} else if (addressPointer) {
|
||||
addWindow("open", { pointer: addressPointer }, `Event`);
|
||||
addWindow("open", { pointer: addressPointer });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer || !repoEvent) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function IssueRenderer({ event }: BaseEventProps) {
|
||||
: repoAddress?.split(":")[2] || "Unknown Repository";
|
||||
|
||||
const handleRepoClick = () => {
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -73,7 +73,7 @@ export function IssueRenderer({ event }: BaseEventProps) {
|
||||
{/* Issue Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title || "Untitled Issue"}
|
||||
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer || !repoEvent) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
};
|
||||
|
||||
// Format created date
|
||||
|
||||
@@ -63,7 +63,7 @@ export function PatchRenderer({ event }: BaseEventProps) {
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
};
|
||||
|
||||
// Shorten commit ID for display
|
||||
@@ -75,7 +75,7 @@ export function PatchRenderer({ event }: BaseEventProps) {
|
||||
{/* Patch Subject */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={subject || "Untitled Patch"}
|
||||
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Patch"}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer || !repoEvent) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,7 +66,7 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
addWindow("open", { pointer: repoPointer });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -75,7 +75,7 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
{/* PR Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={subject || "Untitled Pull Request"}
|
||||
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Pull Request"}
|
||||
|
||||
@@ -35,7 +35,6 @@ export function RepositoryRenderer({ event }: BaseEventProps) {
|
||||
<GitBranch className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={`Repository: ${displayName}`}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
as="span"
|
||||
>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const createWorkspace = (
|
||||
*/
|
||||
export const addWindow = (
|
||||
state: GrimoireState,
|
||||
payload: { appId: string; title: string; props: any; commandString?: string },
|
||||
payload: { appId: string; props: any; commandString?: string; customTitle?: string },
|
||||
): GrimoireState => {
|
||||
const activeId = state.activeWorkspaceId;
|
||||
const ws = state.workspaces[activeId];
|
||||
@@ -64,7 +64,7 @@ export const addWindow = (
|
||||
const newWindow: WindowInstance = {
|
||||
id: newWindowId,
|
||||
appId: payload.appId as any,
|
||||
title: payload.title,
|
||||
customTitle: payload.customTitle,
|
||||
props: payload.props,
|
||||
commandString: payload.commandString,
|
||||
};
|
||||
@@ -323,13 +323,13 @@ export const deleteWorkspace = (
|
||||
|
||||
/**
|
||||
* Updates an existing window with new properties.
|
||||
* Allows updating props, title, commandString, and even appId (which changes the viewer type).
|
||||
* Allows updating props, title, customTitle, commandString, and even appId (which changes the viewer type).
|
||||
*/
|
||||
export const updateWindow = (
|
||||
state: GrimoireState,
|
||||
windowId: string,
|
||||
updates: Partial<
|
||||
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
|
||||
Pick<WindowInstance, "props" | "title" | "customTitle" | "commandString" | "appId">
|
||||
>,
|
||||
): GrimoireState => {
|
||||
const window = state.windows[windowId];
|
||||
|
||||
@@ -132,13 +132,13 @@ export const useGrimoire = () => {
|
||||
}, [setState]);
|
||||
|
||||
const addWindow = useCallback(
|
||||
(appId: AppId, props: any, title?: string, commandString?: string) =>
|
||||
(appId: AppId, props: any, commandString?: string, customTitle?: string) =>
|
||||
setState((prev) =>
|
||||
Logic.addWindow(prev, {
|
||||
appId,
|
||||
props,
|
||||
title: title || appId.toUpperCase(),
|
||||
commandString,
|
||||
customTitle,
|
||||
}),
|
||||
),
|
||||
[setState],
|
||||
@@ -148,7 +148,7 @@ export const useGrimoire = () => {
|
||||
(
|
||||
windowId: string,
|
||||
updates: Partial<
|
||||
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
|
||||
Pick<WindowInstance, "props" | "title" | "customTitle" | "commandString" | "appId">
|
||||
>,
|
||||
) => setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
||||
[setState],
|
||||
|
||||
244
src/lib/command-parser.test.ts
Normal file
244
src/lib/command-parser.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommandInput } from './command-parser';
|
||||
|
||||
/**
|
||||
* Regression tests for parseCommandInput
|
||||
*
|
||||
* These tests document the current behavior to ensure we don't break
|
||||
* existing command parsing when we add global flag support.
|
||||
*/
|
||||
describe('parseCommandInput - regression tests', () => {
|
||||
describe('basic commands', () => {
|
||||
it('should parse simple command with no args', () => {
|
||||
const result = parseCommandInput('help');
|
||||
expect(result.commandName).toBe('help');
|
||||
expect(result.args).toEqual([]);
|
||||
expect(result.command).toBeDefined();
|
||||
});
|
||||
|
||||
it('should parse command with single arg', () => {
|
||||
const result = parseCommandInput('nip 01');
|
||||
expect(result.commandName).toBe('nip');
|
||||
expect(result.args).toEqual(['01']);
|
||||
});
|
||||
|
||||
it('should parse command with multiple args', () => {
|
||||
const result = parseCommandInput('profile alice@domain.com');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['alice@domain.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commands with flags', () => {
|
||||
it('should preserve req command with flags', () => {
|
||||
const result = parseCommandInput('req -k 1 -a alice');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', 'alice']);
|
||||
});
|
||||
|
||||
it('should preserve comma-separated values', () => {
|
||||
const result = parseCommandInput('req -k 1,3,7 -l 50');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1,3,7', '-l', '50']);
|
||||
});
|
||||
|
||||
it('should handle long flag names', () => {
|
||||
const result = parseCommandInput('req --kind 1 --limit 20');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['--kind', '1', '--limit', '20']);
|
||||
});
|
||||
|
||||
it('should handle mixed short and long flags', () => {
|
||||
const result = parseCommandInput('req -k 1 --author alice -l 50');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '--author', 'alice', '-l', '50']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commands with complex identifiers', () => {
|
||||
it('should handle hex pubkey', () => {
|
||||
const hexKey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2';
|
||||
const result = parseCommandInput(`profile ${hexKey}`);
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual([hexKey]);
|
||||
});
|
||||
|
||||
it('should handle npub', () => {
|
||||
const npub = 'npub1abc123def456';
|
||||
const result = parseCommandInput(`profile ${npub}`);
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual([npub]);
|
||||
});
|
||||
|
||||
it('should handle nip05 identifier', () => {
|
||||
const result = parseCommandInput('profile alice@nostr.com');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['alice@nostr.com']);
|
||||
});
|
||||
|
||||
it('should handle relay URL', () => {
|
||||
const result = parseCommandInput('relay wss://relay.damus.io');
|
||||
expect(result.commandName).toBe('relay');
|
||||
expect(result.args).toEqual(['wss://relay.damus.io']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('special arguments', () => {
|
||||
it('should handle $me alias', () => {
|
||||
const result = parseCommandInput('req -k 1 -a $me');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', '$me']);
|
||||
});
|
||||
|
||||
it('should handle $contacts alias', () => {
|
||||
const result = parseCommandInput('req -k 1 -a $contacts');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', '$contacts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('should trim leading whitespace', () => {
|
||||
const result = parseCommandInput(' profile alice');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['alice']);
|
||||
});
|
||||
|
||||
it('should trim trailing whitespace', () => {
|
||||
const result = parseCommandInput('profile alice ');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['alice']);
|
||||
});
|
||||
|
||||
it('should collapse multiple spaces', () => {
|
||||
const result = parseCommandInput('req -k 1 -a alice');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', 'alice']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should handle empty input', () => {
|
||||
const result = parseCommandInput('');
|
||||
expect(result.commandName).toBe('');
|
||||
expect(result.error).toBe('No command provided');
|
||||
});
|
||||
|
||||
it('should handle unknown command', () => {
|
||||
const result = parseCommandInput('unknowncommand');
|
||||
expect(result.commandName).toBe('unknowncommand');
|
||||
expect(result.error).toContain('Unknown command');
|
||||
});
|
||||
});
|
||||
|
||||
describe('case sensitivity', () => {
|
||||
it('should handle lowercase command', () => {
|
||||
const result = parseCommandInput('profile alice');
|
||||
expect(result.commandName).toBe('profile');
|
||||
});
|
||||
|
||||
it('should handle uppercase command (converted to lowercase)', () => {
|
||||
const result = parseCommandInput('PROFILE alice');
|
||||
expect(result.commandName).toBe('profile');
|
||||
});
|
||||
|
||||
it('should handle mixed case command', () => {
|
||||
const result = parseCommandInput('Profile alice');
|
||||
expect(result.commandName).toBe('profile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world command examples', () => {
|
||||
it('req: get recent notes', () => {
|
||||
const result = parseCommandInput('req -k 1 -l 20');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-l', '20']);
|
||||
});
|
||||
|
||||
it('req: get notes from specific author', () => {
|
||||
const result = parseCommandInput('req -k 1 -a npub1abc... -l 50');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', 'npub1abc...', '-l', '50']);
|
||||
});
|
||||
|
||||
it('req: complex filter', () => {
|
||||
const result = parseCommandInput('req -k 1,3,7 -a alice@nostr.com -l 100 --since 24h');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1,3,7', '-a', 'alice@nostr.com', '-l', '100', '--since', '24h']);
|
||||
});
|
||||
|
||||
it('profile: by npub', () => {
|
||||
const result = parseCommandInput('profile npub1abc...');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['npub1abc...']);
|
||||
});
|
||||
|
||||
it('profile: by nip05', () => {
|
||||
const result = parseCommandInput('profile jack@cash.app');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['jack@cash.app']);
|
||||
});
|
||||
|
||||
it('nip: view specification', () => {
|
||||
const result = parseCommandInput('nip 19');
|
||||
expect(result.commandName).toBe('nip');
|
||||
expect(result.args).toEqual(['19']);
|
||||
});
|
||||
|
||||
it('relay: view relay info', () => {
|
||||
const result = parseCommandInput('relay nos.lol');
|
||||
expect(result.commandName).toBe('relay');
|
||||
expect(result.args).toEqual(['nos.lol']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global flags - new functionality', () => {
|
||||
it('should extract --title flag', () => {
|
||||
const result = parseCommandInput('profile alice --title "My Window"');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['alice']);
|
||||
expect(result.globalFlags?.windowProps?.title).toBe('My Window');
|
||||
});
|
||||
|
||||
it('should handle --title at start', () => {
|
||||
const result = parseCommandInput('--title "My Window" profile alice');
|
||||
expect(result.commandName).toBe('profile');
|
||||
expect(result.args).toEqual(['alice']);
|
||||
expect(result.globalFlags?.windowProps?.title).toBe('My Window');
|
||||
});
|
||||
|
||||
it('should handle --title in middle', () => {
|
||||
const result = parseCommandInput('req -k 1 --title "My Feed" -a alice');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', 'alice']);
|
||||
expect(result.globalFlags?.windowProps?.title).toBe('My Feed');
|
||||
});
|
||||
|
||||
it('should handle --title with single quotes', () => {
|
||||
const result = parseCommandInput("profile alice --title 'My Window'");
|
||||
expect(result.globalFlags?.windowProps?.title).toBe('My Window');
|
||||
});
|
||||
|
||||
it('should handle --title without quotes (single word)', () => {
|
||||
const result = parseCommandInput('profile alice --title MyWindow');
|
||||
expect(result.globalFlags?.windowProps?.title).toBe('MyWindow');
|
||||
});
|
||||
|
||||
it('should preserve command behavior when no --title', () => {
|
||||
const result = parseCommandInput('req -k 1 -a alice');
|
||||
expect(result.commandName).toBe('req');
|
||||
expect(result.args).toEqual(['-k', '1', '-a', 'alice']);
|
||||
expect(result.globalFlags).toEqual({});
|
||||
});
|
||||
|
||||
it('should error when --title has no value', () => {
|
||||
const result = parseCommandInput('profile alice --title');
|
||||
expect(result.error).toContain('--title requires a value');
|
||||
});
|
||||
|
||||
it('should handle emoji in --title', () => {
|
||||
const result = parseCommandInput('profile alice --title "👤 Alice"');
|
||||
expect(result.globalFlags?.windowProps?.title).toBe('👤 Alice');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { parse as parseShellTokens } from "shell-quote";
|
||||
import { manPages } from "@/types/man";
|
||||
import { extractGlobalFlagsFromTokens, type GlobalFlags } from "./global-flags";
|
||||
|
||||
export interface ParsedCommand {
|
||||
commandName: string;
|
||||
@@ -6,20 +8,57 @@ export interface ParsedCommand {
|
||||
fullInput: string;
|
||||
command?: (typeof manPages)[string];
|
||||
props?: any;
|
||||
title?: string;
|
||||
error?: string;
|
||||
globalFlags?: GlobalFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a command string into its components.
|
||||
* Returns basic parsing info without executing argParser.
|
||||
*
|
||||
* Now supports:
|
||||
* - Proper quote handling via shell-quote
|
||||
* - Global flag extraction (--title, etc.)
|
||||
*/
|
||||
export function parseCommandInput(input: string): ParsedCommand {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase() || "";
|
||||
const args = parts.slice(1);
|
||||
const fullInput = input.trim();
|
||||
|
||||
// Pre-process: Escape $ to prevent shell-quote from expanding variables
|
||||
// We use $me and $contacts as literal syntax, not shell variables
|
||||
const DOLLAR_PLACEHOLDER = "___DOLLAR___";
|
||||
const escapedInput = fullInput.replace(/\$/g, DOLLAR_PLACEHOLDER);
|
||||
|
||||
// Tokenize with quote support (on escaped input)
|
||||
const rawTokens = parseShellTokens(escapedInput);
|
||||
|
||||
// Convert tokens to strings and restore $ characters
|
||||
const tokens = rawTokens.map((token) => {
|
||||
const str = typeof token === "string" ? token : String(token);
|
||||
return str.replace(new RegExp(DOLLAR_PLACEHOLDER, "g"), "$");
|
||||
});
|
||||
|
||||
// Extract global flags before command parsing
|
||||
let globalFlags: GlobalFlags = {};
|
||||
let remainingTokens = tokens;
|
||||
|
||||
try {
|
||||
const extracted = extractGlobalFlagsFromTokens(tokens);
|
||||
globalFlags = extracted.globalFlags;
|
||||
remainingTokens = extracted.remainingTokens;
|
||||
} catch (error) {
|
||||
// Global flag parsing error
|
||||
return {
|
||||
commandName: "",
|
||||
args: [],
|
||||
fullInput,
|
||||
error: error instanceof Error ? error.message : "Failed to parse global flags",
|
||||
};
|
||||
}
|
||||
|
||||
// Parse command from remaining tokens
|
||||
const commandName = remainingTokens[0]?.toLowerCase() || "";
|
||||
const args = remainingTokens.slice(1);
|
||||
|
||||
const command = commandName && manPages[commandName];
|
||||
|
||||
if (!commandName) {
|
||||
@@ -27,6 +66,7 @@ export function parseCommandInput(input: string): ParsedCommand {
|
||||
commandName: "",
|
||||
args: [],
|
||||
fullInput: "",
|
||||
globalFlags,
|
||||
error: "No command provided",
|
||||
};
|
||||
}
|
||||
@@ -36,6 +76,7 @@ export function parseCommandInput(input: string): ParsedCommand {
|
||||
commandName,
|
||||
args,
|
||||
fullInput,
|
||||
globalFlags,
|
||||
error: `Unknown command: ${commandName}`,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +86,7 @@ export function parseCommandInput(input: string): ParsedCommand {
|
||||
args,
|
||||
fullInput,
|
||||
command,
|
||||
globalFlags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,16 +107,9 @@ export async function executeCommandParser(
|
||||
? await Promise.resolve(parsed.command.argParser(parsed.args))
|
||||
: parsed.command.defaultProps || {};
|
||||
|
||||
// Generate title
|
||||
const title =
|
||||
parsed.args.length > 0
|
||||
? `${parsed.commandName.toUpperCase()} ${parsed.args.join(" ")}`
|
||||
: parsed.commandName.toUpperCase();
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
props,
|
||||
title,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
183
src/lib/global-flags.test.ts
Normal file
183
src/lib/global-flags.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractGlobalFlagsFromTokens, isGlobalFlag } from './global-flags';
|
||||
|
||||
describe('extractGlobalFlagsFromTokens', () => {
|
||||
describe('basic extraction', () => {
|
||||
it('should extract --title flag at end', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['profile', 'alice', '--title', 'My Window']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('My Window');
|
||||
expect(result.remainingTokens).toEqual(['profile', 'alice']);
|
||||
});
|
||||
|
||||
it('should extract --title flag at start', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', 'My Window', 'profile', 'alice']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('My Window');
|
||||
expect(result.remainingTokens).toEqual(['profile', 'alice']);
|
||||
});
|
||||
|
||||
it('should extract --title flag in middle', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['profile', '--title', 'My Window', 'alice']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('My Window');
|
||||
expect(result.remainingTokens).toEqual(['profile', 'alice']);
|
||||
});
|
||||
|
||||
it('should handle command with no global flags', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['profile', 'alice']);
|
||||
expect(result.globalFlags).toEqual({});
|
||||
expect(result.remainingTokens).toEqual(['profile', 'alice']);
|
||||
});
|
||||
|
||||
it('should handle empty token array', () => {
|
||||
const result = extractGlobalFlagsFromTokens([]);
|
||||
expect(result.globalFlags).toEqual({});
|
||||
expect(result.remainingTokens).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate flags', () => {
|
||||
it('should use last value when --title specified multiple times', () => {
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'--title', 'First',
|
||||
'profile', 'alice',
|
||||
'--title', 'Second'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('Second');
|
||||
expect(result.remainingTokens).toEqual(['profile', 'alice']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should error when --title has no value', () => {
|
||||
expect(() => extractGlobalFlagsFromTokens(['profile', '--title']))
|
||||
.toThrow('Flag --title requires a value');
|
||||
});
|
||||
|
||||
it('should error when --title value is another flag', () => {
|
||||
expect(() => extractGlobalFlagsFromTokens(['--title', '--other-flag', 'profile']))
|
||||
.toThrow('Flag --title requires a value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitization', () => {
|
||||
it('should strip control characters', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', 'My\nWindow\tTitle', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('MyWindowTitle');
|
||||
});
|
||||
|
||||
it('should strip null bytes', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', 'My\x00Window', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('MyWindow');
|
||||
});
|
||||
|
||||
it('should preserve Unicode characters', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', 'Profile 👤 Alice', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('Profile 👤 Alice');
|
||||
});
|
||||
|
||||
it('should preserve emoji', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', '🎯 Important', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('🎯 Important');
|
||||
});
|
||||
|
||||
it('should preserve CJK characters', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', '日本語タイトル', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('日本語タイトル');
|
||||
});
|
||||
|
||||
it('should preserve Arabic/RTL characters', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', 'محمد', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('محمد');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', ' My Window ', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('My Window');
|
||||
});
|
||||
|
||||
it('should fallback when title is empty after sanitization', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', ' ', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fallback when title is only control characters', () => {
|
||||
const result = extractGlobalFlagsFromTokens(['--title', '\n\t\r', 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should limit title to 200 characters', () => {
|
||||
const longTitle = 'a'.repeat(300);
|
||||
const result = extractGlobalFlagsFromTokens(['--title', longTitle, 'profile']);
|
||||
expect(result.globalFlags.windowProps?.title).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex command scenarios', () => {
|
||||
it('should preserve command flags', () => {
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'req', '-k', '1', '-a', 'alice@nostr.com', '--title', 'My Feed'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('My Feed');
|
||||
expect(result.remainingTokens).toEqual(['req', '-k', '1', '-a', 'alice@nostr.com']);
|
||||
});
|
||||
|
||||
it('should handle command with many flags', () => {
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'req', '-k', '1,3,7', '-a', 'npub...', '-l', '50', '--title', 'Timeline'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('Timeline');
|
||||
expect(result.remainingTokens).toEqual(['req', '-k', '1,3,7', '-a', 'npub...', '-l', '50']);
|
||||
});
|
||||
|
||||
it('should not interfere with command-specific --title (if any)', () => {
|
||||
// If a future command uses --title for something else, this test would catch it
|
||||
// For now, just verify tokens are preserved
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'somecommand', 'arg1', '--title', 'Global Title', 'arg2'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('Global Title');
|
||||
expect(result.remainingTokens).toEqual(['somecommand', 'arg1', 'arg2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world examples', () => {
|
||||
it('profile with custom title', () => {
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'profile', 'npub1abc...', '--title', 'Alice (Competitor)'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('Alice (Competitor)');
|
||||
expect(result.remainingTokens).toEqual(['profile', 'npub1abc...']);
|
||||
});
|
||||
|
||||
it('req with custom title', () => {
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'req', '-k', '1', '-a', '$me', '--title', 'My Notes'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('My Notes');
|
||||
expect(result.remainingTokens).toEqual(['req', '-k', '1', '-a', '$me']);
|
||||
});
|
||||
|
||||
it('nip with custom title', () => {
|
||||
const result = extractGlobalFlagsFromTokens([
|
||||
'nip', '01', '--title', 'Basic Protocol'
|
||||
]);
|
||||
expect(result.globalFlags.windowProps?.title).toBe('Basic Protocol');
|
||||
expect(result.remainingTokens).toEqual(['nip', '01']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGlobalFlag', () => {
|
||||
it('should recognize --title as global flag', () => {
|
||||
expect(isGlobalFlag('--title')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not recognize command flags', () => {
|
||||
expect(isGlobalFlag('-k')).toBe(false);
|
||||
expect(isGlobalFlag('--kind')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not recognize regular arguments', () => {
|
||||
expect(isGlobalFlag('profile')).toBe(false);
|
||||
expect(isGlobalFlag('alice')).toBe(false);
|
||||
});
|
||||
});
|
||||
90
src/lib/global-flags.ts
Normal file
90
src/lib/global-flags.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Global command flags system
|
||||
*
|
||||
* Extracts global flags (like --title) from tokenized command arguments.
|
||||
* Global flags work across ALL commands and are processed before command-specific parsing.
|
||||
*/
|
||||
|
||||
export interface GlobalFlags {
|
||||
windowProps?: {
|
||||
title?: string;
|
||||
};
|
||||
// Future: layoutHints, dispatchOpts, etc.
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
globalFlags: GlobalFlags;
|
||||
remainingTokens: string[];
|
||||
}
|
||||
|
||||
const RESERVED_GLOBAL_FLAGS = ['--title'] as const;
|
||||
|
||||
/**
|
||||
* Sanitize a title string: strip control characters, limit length
|
||||
*/
|
||||
function sanitizeTitle(title: string): string | undefined {
|
||||
const sanitized = title
|
||||
.replace(/[\x00-\x1F\x7F]/g, '') // Strip control chars (newlines, tabs, null bytes)
|
||||
.trim();
|
||||
|
||||
if (!sanitized) {
|
||||
return undefined; // Empty title → fallback to default
|
||||
}
|
||||
|
||||
return sanitized.slice(0, 200); // Limit to 200 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract global flags from tokenized arguments
|
||||
*
|
||||
* @param tokens - Array of tokenized arguments (from shell-quote or similar)
|
||||
* @returns Global flags and remaining tokens for command-specific parsing
|
||||
*
|
||||
* @example
|
||||
* extractGlobalFlagsFromTokens(['--title', 'My Window', 'profile', 'alice'])
|
||||
* // Returns: {
|
||||
* // globalFlags: { windowProps: { title: 'My Window' } },
|
||||
* // remainingTokens: ['profile', 'alice']
|
||||
* // }
|
||||
*/
|
||||
export function extractGlobalFlagsFromTokens(tokens: string[]): ExtractResult {
|
||||
const globalFlags: GlobalFlags = {};
|
||||
const remainingTokens: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token === '--title') {
|
||||
// Extract title value (next token)
|
||||
const nextToken = tokens[i + 1];
|
||||
|
||||
if (nextToken === undefined || nextToken.startsWith('--')) {
|
||||
throw new Error('Flag --title requires a value. Usage: --title "Window Title"');
|
||||
}
|
||||
|
||||
const sanitized = sanitizeTitle(nextToken);
|
||||
if (sanitized) {
|
||||
if (!globalFlags.windowProps) {
|
||||
globalFlags.windowProps = {};
|
||||
}
|
||||
globalFlags.windowProps.title = sanitized;
|
||||
}
|
||||
|
||||
i += 2; // Skip both --title and its value
|
||||
} else {
|
||||
// Not a global flag, keep it
|
||||
remainingTokens.push(token);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { globalFlags, remainingTokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is a known global flag
|
||||
*/
|
||||
export function isGlobalFlag(token: string): boolean {
|
||||
return RESERVED_GLOBAL_FLAGS.includes(token as any);
|
||||
}
|
||||
@@ -20,7 +20,8 @@ export type AppId =
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
appId: AppId;
|
||||
title: string;
|
||||
title?: string; // Legacy field - rarely used now that DynamicWindowTitle handles all titles
|
||||
customTitle?: string; // User-provided custom title via --title flag (overrides dynamic title)
|
||||
props: any;
|
||||
commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user