feat: add title command line flag

This commit is contained in:
Alejandro Gómez
2025-12-16 20:50:03 +01:00
parent 6126f43e34
commit 63121f6233
37 changed files with 648 additions and 73 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 });
}
};

View File

@@ -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,
);
}

View File

@@ -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 });
}
};

View File

@@ -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,

View File

@@ -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 (

View File

@@ -32,7 +32,7 @@ export function KindBadge({
const handleClick = () => {
if (clickable) {
addWindow("kind", { number: String(kind) }, `Kind ${kind}`);
addWindow("kind", { number: String(kind) });
}
};

View File

@@ -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 });
}
};

View File

@@ -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

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -54,7 +54,7 @@ export function RelayLink({
const relayInfo = useRelayInfo(url);
const handleClick = () => {
addWindow("relay", { url }, `Relay ${url}`);
addWindow("relay", { url });
};
const variantStyles = {

View File

@@ -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 (

View File

@@ -24,7 +24,7 @@ export function Kind30023Renderer({ event }: BaseEventProps) {
{title && (
<ClickableEventTitle
event={event}
windowTitle={title}
className="text-lg font-bold text-foreground"
>
{title}

View File

@@ -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 (

View File

@@ -28,7 +28,7 @@ export function Kind39701Renderer({ event }: BaseEventProps) {
{title && (
<ClickableEventTitle
event={event}
windowTitle={title}
className="text-lg font-bold text-foreground"
>
{title}

View File

@@ -74,7 +74,7 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
const handleRepoClick = () => {
if (repoPointer) {
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
addWindow("open", { pointer: repoPointer });
}
};

View File

@@ -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"}

View File

@@ -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}

View File

@@ -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);
}
}}
/>

View File

@@ -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 });
}
};

View File

@@ -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 (

View File

@@ -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"}

View File

@@ -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

View File

@@ -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"}

View File

@@ -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 (

View File

@@ -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"}

View File

@@ -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"
>

View File

@@ -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];

View File

@@ -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],

View 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');
});
});
});

View File

@@ -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 {

View 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
View 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);
}

View File

@@ -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")
}