diff --git a/CLAUDE.md b/CLAUDE.md index caf4789..44c2e9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/package-lock.json b/package-lock.json index 0ae15ec..53ba0d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index cdd58d1..99de201 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Command.tsx b/src/components/Command.tsx index 554673f..480f383 100644 --- a/src/components/Command.tsx +++ b/src/components/Command.tsx @@ -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 }); } }; diff --git a/src/components/CommandLauncher.tsx b/src/components/CommandLauncher.tsx index dc9f527..808ac93 100644 --- a/src/components/CommandLauncher.tsx +++ b/src/components/CommandLauncher.tsx @@ -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, ); } diff --git a/src/components/DecodeViewer.tsx b/src/components/DecodeViewer.tsx index 598d5a2..0611547 100644 --- a/src/components/DecodeViewer.tsx +++ b/src/components/DecodeViewer.tsx @@ -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 }); } }; diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 7dba110..5f4b4a5 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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, diff --git a/src/components/EventFooter.tsx b/src/components/EventFooter.tsx index 6dbc4ea..441689a 100644 --- a/src/components/EventFooter.tsx +++ b/src/components/EventFooter.tsx @@ -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 ( diff --git a/src/components/KindBadge.tsx b/src/components/KindBadge.tsx index 1ab0857..e2069b7 100644 --- a/src/components/KindBadge.tsx +++ b/src/components/KindBadge.tsx @@ -32,7 +32,7 @@ export function KindBadge({ const handleClick = () => { if (clickable) { - addWindow("kind", { number: String(kind) }, `Kind ${kind}`); + addWindow("kind", { number: String(kind) }); } }; diff --git a/src/components/NipsViewer.tsx b/src/components/NipsViewer.tsx index d5b341e..b1b54f3 100644 --- a/src/components/NipsViewer.tsx +++ b/src/components/NipsViewer.tsx @@ -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 }); } }; diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 63b66ac..326f442 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -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 diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 9eb2149..5b6e3ae 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -192,7 +192,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { } return ( - + }>
{content}
diff --git a/src/components/WindowTitle.tsx b/src/components/WindowTitle.tsx index 3a34d61..c981387 100644 --- a/src/components/WindowTitle.tsx +++ b/src/components/WindowTitle.tsx @@ -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 = () => { diff --git a/src/components/nostr/RelayLink.tsx b/src/components/nostr/RelayLink.tsx index c9505df..c0669b5 100644 --- a/src/components/nostr/RelayLink.tsx +++ b/src/components/nostr/RelayLink.tsx @@ -54,7 +54,7 @@ export function RelayLink({ const relayInfo = useRelayInfo(url); const handleClick = () => { - addWindow("relay", { url }, `Relay ${url}`); + addWindow("relay", { url }); }; const variantStyles = { diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx index e8476ad..9639d1d 100644 --- a/src/components/nostr/UserName.tsx +++ b/src/components/nostr/UserName.tsx @@ -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 ( diff --git a/src/components/nostr/kinds/ArticleRenderer.tsx b/src/components/nostr/kinds/ArticleRenderer.tsx index 716497d..ecf0d2a 100644 --- a/src/components/nostr/kinds/ArticleRenderer.tsx +++ b/src/components/nostr/kinds/ArticleRenderer.tsx @@ -24,7 +24,7 @@ export function Kind30023Renderer({ event }: BaseEventProps) { {title && ( {title} diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 5f101d0..114b994 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -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 ( diff --git a/src/components/nostr/kinds/BookmarkRenderer.tsx b/src/components/nostr/kinds/BookmarkRenderer.tsx index 7f85f55..2003e36 100644 --- a/src/components/nostr/kinds/BookmarkRenderer.tsx +++ b/src/components/nostr/kinds/BookmarkRenderer.tsx @@ -28,7 +28,7 @@ export function Kind39701Renderer({ event }: BaseEventProps) { {title && ( {title} diff --git a/src/components/nostr/kinds/CodeSnippetDetailRenderer.tsx b/src/components/nostr/kinds/CodeSnippetDetailRenderer.tsx index 26cc827..2883033 100644 --- a/src/components/nostr/kinds/CodeSnippetDetailRenderer.tsx +++ b/src/components/nostr/kinds/CodeSnippetDetailRenderer.tsx @@ -74,7 +74,7 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) { const handleRepoClick = () => { if (repoPointer) { - addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`); + addWindow("open", { pointer: repoPointer }); } }; diff --git a/src/components/nostr/kinds/CodeSnippetRenderer.tsx b/src/components/nostr/kinds/CodeSnippetRenderer.tsx index 0c6da4b..2926ed2 100644 --- a/src/components/nostr/kinds/CodeSnippetRenderer.tsx +++ b/src/components/nostr/kinds/CodeSnippetRenderer.tsx @@ -85,7 +85,7 @@ export function Kind1337Renderer({ event }: BaseEventProps) { {/* Title */} {name || "Code Snippet"} diff --git a/src/components/nostr/kinds/CommunityNIPRenderer.tsx b/src/components/nostr/kinds/CommunityNIPRenderer.tsx index c10c2fd..623e5a4 100644 --- a/src/components/nostr/kinds/CommunityNIPRenderer.tsx +++ b/src/components/nostr/kinds/CommunityNIPRenderer.tsx @@ -17,7 +17,7 @@ export function CommunityNIPRenderer({ event }: BaseEventProps) {
{title} diff --git a/src/components/nostr/kinds/HighlightDetailRenderer.tsx b/src/components/nostr/kinds/HighlightDetailRenderer.tsx index 7a57ff4..9c11aa9 100644 --- a/src/components/nostr/kinds/HighlightDetailRenderer.tsx +++ b/src/components/nostr/kinds/HighlightDetailRenderer.tsx @@ -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); } }} /> diff --git a/src/components/nostr/kinds/HighlightRenderer.tsx b/src/components/nostr/kinds/HighlightRenderer.tsx index 32fabfa..eb7c853 100644 --- a/src/components/nostr/kinds/HighlightRenderer.tsx +++ b/src/components/nostr/kinds/HighlightRenderer.tsx @@ -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 }); } }; diff --git a/src/components/nostr/kinds/IssueDetailRenderer.tsx b/src/components/nostr/kinds/IssueDetailRenderer.tsx index 8a549c4..68983ea 100644 --- a/src/components/nostr/kinds/IssueDetailRenderer.tsx +++ b/src/components/nostr/kinds/IssueDetailRenderer.tsx @@ -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 ( diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index a4a4652..8fd3de4 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -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 */} {title || "Untitled Issue"} diff --git a/src/components/nostr/kinds/PatchDetailRenderer.tsx b/src/components/nostr/kinds/PatchDetailRenderer.tsx index 3e85f0f..ca75e92 100644 --- a/src/components/nostr/kinds/PatchDetailRenderer.tsx +++ b/src/components/nostr/kinds/PatchDetailRenderer.tsx @@ -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 diff --git a/src/components/nostr/kinds/PatchRenderer.tsx b/src/components/nostr/kinds/PatchRenderer.tsx index df47837..8f5a250 100644 --- a/src/components/nostr/kinds/PatchRenderer.tsx +++ b/src/components/nostr/kinds/PatchRenderer.tsx @@ -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 */} {subject || "Untitled Patch"} diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx index bb43274..27092f5 100644 --- a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx @@ -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 ( diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx index 0d37622..f6e92ff 100644 --- a/src/components/nostr/kinds/PullRequestRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestRenderer.tsx @@ -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 */} {subject || "Untitled Pull Request"} diff --git a/src/components/nostr/kinds/RepositoryRenderer.tsx b/src/components/nostr/kinds/RepositoryRenderer.tsx index 5e5350e..3d34e38 100644 --- a/src/components/nostr/kinds/RepositoryRenderer.tsx +++ b/src/components/nostr/kinds/RepositoryRenderer.tsx @@ -35,7 +35,6 @@ export function RepositoryRenderer({ event }: BaseEventProps) { diff --git a/src/core/logic.ts b/src/core/logic.ts index 4a309b2..3076f64 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -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 + Pick >, ): GrimoireState => { const window = state.windows[windowId]; diff --git a/src/core/state.ts b/src/core/state.ts index 32a5d86..6e09175 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -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 + Pick >, ) => setState((prev) => Logic.updateWindow(prev, windowId, updates)), [setState], diff --git a/src/lib/command-parser.test.ts b/src/lib/command-parser.test.ts new file mode 100644 index 0000000..7ec3a66 --- /dev/null +++ b/src/lib/command-parser.test.ts @@ -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'); + }); + }); +}); diff --git a/src/lib/command-parser.ts b/src/lib/command-parser.ts index 50368d6..50f82a7 100644 --- a/src/lib/command-parser.ts +++ b/src/lib/command-parser.ts @@ -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 { diff --git a/src/lib/global-flags.test.ts b/src/lib/global-flags.test.ts new file mode 100644 index 0000000..6d6c755 --- /dev/null +++ b/src/lib/global-flags.test.ts @@ -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); + }); +}); diff --git a/src/lib/global-flags.ts b/src/lib/global-flags.ts new file mode 100644 index 0000000..d2517c7 --- /dev/null +++ b/src/lib/global-flags.ts @@ -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); +} diff --git a/src/types/app.ts b/src/types/app.ts index 402cdd2..525cb7b 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -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") }