feat: editable commands

This commit is contained in:
Alejandro Gómez
2025-12-13 22:53:27 +01:00
parent 92cb290c4d
commit d877e51317
13 changed files with 1076 additions and 57 deletions

View File

@@ -1,7 +1,10 @@
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useAtom } from "jotai";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import { parseCommandInput, executeCommandParser } from "@/lib/command-parser";
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import "./command-launcher.css";
@@ -16,24 +19,22 @@ export default function CommandLauncher({
onOpenChange,
}: CommandLauncherProps) {
const [input, setInput] = useState("");
const { addWindow } = useGrimoire();
const [editMode, setEditMode] = useAtom(commandLauncherEditModeAtom);
const { addWindow, updateWindow } = useGrimoire();
// Prefill input when entering edit mode
useEffect(() => {
if (!open) {
if (open && editMode) {
setInput(editMode.initialCommand);
} else if (!open) {
setInput("");
}
}, [open]);
}, [open, editMode]);
// Parse input into command and arguments
const parseInput = (value: string) => {
const parts = value.trim().split(/\s+/);
const commandName = parts[0]?.toLowerCase() || "";
const args = parts.slice(1);
return { commandName, args, fullInput: value };
};
const { commandName, args } = parseInput(input);
const recognizedCommand = commandName && manPages[commandName];
const parsed = parseCommandInput(input);
const { commandName } = parsed;
const recognizedCommand = parsed.command;
// Filter commands by partial match on command name only
const filteredCommands = Object.entries(manPages).filter(([name]) =>
@@ -44,22 +45,33 @@ export default function CommandLauncher({
const executeCommand = async () => {
if (!recognizedCommand) return;
const command = recognizedCommand;
// Execute argParser and get props/title
const result = await executeCommandParser(parsed);
// Use argParser if available, otherwise use defaultProps
// argParser can now be async
const props = command.argParser
? await Promise.resolve(command.argParser(args))
: command.defaultProps || {};
if (result.error || !result.props) {
console.error("Failed to parse command:", result.error);
return;
}
// Generate title
const title =
args.length > 0
? `${commandName.toUpperCase()} ${args.join(" ")}`
: commandName.toUpperCase();
// Edit mode: update existing window
if (editMode) {
updateWindow(editMode.windowId, {
props: result.props,
title: result.title,
commandString: input.trim(),
appId: recognizedCommand.appId,
});
setEditMode(null); // Clear edit mode
} else {
// Normal mode: create new window
addWindow(
recognizedCommand.appId,
result.props,
result.title,
input.trim(),
);
}
// Execute command
addWindow(command.appId, props, title);
onOpenChange(false);
};
@@ -111,11 +123,11 @@ export default function CommandLauncher({
className="command-input"
/>
{recognizedCommand && args.length > 0 && (
{recognizedCommand && parsed.args.length > 0 && (
<div className="command-hint">
<span className="command-hint-label">Parsed:</span>
<span className="command-hint-command">{commandName}</span>
<span className="command-hint-args">{args.join(" ")}</span>
<span className="command-hint-args">{parsed.args.join(" ")}</span>
</div>
)}

View File

@@ -71,6 +71,7 @@ export default function Home() {
window={window}
path={path}
onClose={handleRemoveWindow}
onEditCommand={() => setCommandLauncherOpen(true)}
/>
);
};

View File

@@ -12,29 +12,31 @@ export function TabBar() {
};
return (
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1">
{Object.values(workspaces).map((ws) => (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
className={cn(
"px-3 py-1 text-xs font-mono rounded transition-colors",
ws.id === activeWorkspaceId
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
<div className="flex items-center gap-1 flex-nowrap">
{Object.values(workspaces).map((ws) => (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
className={cn(
"px-3 py-1 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0",
ws.id === activeWorkspaceId
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{ws.label}
</button>
))}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1 flex-shrink-0"
onClick={handleNewTab}
>
{ws.label}
</button>
))}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1"
onClick={handleNewTab}
>
<Plus className="h-3 w-3" />
</Button>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
);
}

View File

@@ -9,9 +9,16 @@ interface WindowTileProps {
window: WindowInstance;
path: MosaicBranch[];
onClose: (id: string) => void;
onEditCommand: () => void; // Callback to open CommandLauncher
}
export function WindowTile({ id, window, path, onClose }: WindowTileProps) {
export function WindowTile({
id,
window,
path,
onClose,
onEditCommand,
}: WindowTileProps) {
const { title, icon, tooltip } = useDynamicWindowTitle(window);
const Icon = icon;
@@ -29,7 +36,11 @@ export function WindowTile({ id, window, path, onClose }: WindowTileProps) {
{title}
</span>
</div>
<WindowToolbar onClose={() => onClose(id)} />
<WindowToolbar
window={window}
onClose={() => onClose(id)}
onEditCommand={onEditCommand}
/>
</div>
);
};

View File

@@ -1,12 +1,52 @@
import { X } from "lucide-react";
import { X, Pencil } from "lucide-react";
import { useSetAtom } from "jotai";
import { WindowInstance } from "@/types/app";
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
import { reconstructCommand } from "@/lib/command-reconstructor";
interface WindowToolbarProps {
window?: WindowInstance;
onClose?: () => void;
onEditCommand?: () => void; // Callback to open CommandLauncher
}
export function WindowToolbar({ onClose }: WindowToolbarProps) {
export function WindowToolbar({
window,
onClose,
onEditCommand,
}: WindowToolbarProps) {
const setEditMode = useSetAtom(commandLauncherEditModeAtom);
const handleEdit = () => {
if (!window) return;
// Get command string (existing or reconstructed)
const commandString =
window.commandString || reconstructCommand(window);
// Set edit mode state
setEditMode({
windowId: window.id,
initialCommand: commandString,
});
// Open CommandLauncher
if (onEditCommand) {
onEditCommand();
}
};
return (
<>
{window && (
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={handleEdit}
title="Edit command"
>
<Pencil className="size-4" />
</button>
)}
{onClose && (
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"

View File

@@ -0,0 +1,16 @@
import { atom } from "jotai";
/**
* Edit mode state for CommandLauncher.
* When set, CommandLauncher opens in edit mode for the specified window.
*/
export interface EditModeState {
windowId: string;
initialCommand: string;
}
/**
* Atom to control edit mode in CommandLauncher.
* Set this to trigger edit mode, null for normal create mode.
*/
export const commandLauncherEditModeAtom = atom<EditModeState | null>(null);

View File

@@ -30,7 +30,7 @@ export const createWorkspace = (
*/
export const addWindow = (
state: GrimoireState,
payload: { appId: string; title: string; props: any },
payload: { appId: string; title: string; props: any; commandString?: string },
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
@@ -40,6 +40,7 @@ export const addWindow = (
appId: payload.appId as any,
title: payload.title,
props: payload.props,
commandString: payload.commandString,
};
// Simple Binary Split Logic
@@ -263,3 +264,56 @@ export const setActiveAccountRelays = (
},
};
};
/**
* Deletes a workspace by ID.
* Cannot delete the last remaining workspace.
* Does NOT change activeWorkspaceId - caller is responsible for workspace navigation.
*/
export const deleteWorkspace = (
state: GrimoireState,
workspaceId: string,
): GrimoireState => {
const workspaceIds = Object.keys(state.workspaces);
// Don't delete if it's the only workspace
if (workspaceIds.length <= 1) {
return state;
}
// Don't delete if workspace doesn't exist
if (!state.workspaces[workspaceId]) {
return state;
}
// Remove the workspace (don't touch activeWorkspaceId - that's the caller's job)
const { [workspaceId]: _removed, ...remainingWorkspaces } = state.workspaces;
return {
...state,
workspaces: remainingWorkspaces,
};
};
/**
* Updates an existing window with new properties.
* Allows updating props, title, commandString, and even appId (which changes the viewer type).
*/
export const updateWindow = (
state: GrimoireState,
windowId: string,
updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>,
): GrimoireState => {
const window = state.windows[windowId];
if (!window) {
return state; // Window doesn't exist, return unchanged
}
return {
...state,
windows: {
...state.windows,
[windowId]: { ...window, ...updates },
},
};
};

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useAtom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { GrimoireState, AppId } from "@/types/app";
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
@@ -82,14 +82,17 @@ export const useGrimoire = () => {
const count = Object.keys(state.workspaces).length + 1;
setState((prev) => Logic.createWorkspace(prev, count.toString()));
},
addWindow: (appId: AppId, props: any, title?: string) =>
addWindow: (appId: AppId, props: any, title?: string, commandString?: string) =>
setState((prev) =>
Logic.addWindow(prev, {
appId,
props,
title: title || appId.toUpperCase(),
commandString,
}),
),
updateWindow: (windowId: string, updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>) =>
setState((prev) => Logic.updateWindow(prev, windowId, updates)),
removeWindow: (id: string) =>
setState((prev) => Logic.removeWindow(prev, id)),
moveWindowToWorkspace: (windowId: string, targetWorkspaceId: string) =>
@@ -99,7 +102,37 @@ export const useGrimoire = () => {
updateLayout: (layout: any) =>
setState((prev) => Logic.updateLayout(prev, layout)),
setActiveWorkspace: (id: string) =>
setState((prev) => ({ ...prev, activeWorkspaceId: id })),
setState((prev) => {
// Validate target workspace exists
if (!prev.workspaces[id]) {
console.warn(`Cannot switch to non-existent workspace: ${id}`);
return prev;
}
// If not actually switching, return unchanged
if (prev.activeWorkspaceId === id) {
return prev;
}
// Check if we're leaving an empty workspace and should auto-remove it
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
const shouldDeleteCurrent =
currentWorkspace &&
currentWorkspace.windowIds.length === 0 &&
Object.keys(prev.workspaces).length > 1;
if (shouldDeleteCurrent) {
// Delete the empty workspace, then switch to target
const afterDelete = Logic.deleteWorkspace(
prev,
prev.activeWorkspaceId,
);
return { ...afterDelete, activeWorkspaceId: id };
}
// Normal workspace switch
return { ...prev, activeWorkspaceId: id };
}),
setActiveAccount: (pubkey: string | undefined) =>
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
setActiveAccountRelays: (relays: any) =>

102
src/lib/command-parser.ts Normal file
View File

@@ -0,0 +1,102 @@
import { manPages } from "@/types/man";
export interface ParsedCommand {
commandName: string;
args: string[];
fullInput: string;
command?: typeof manPages[string];
props?: any;
title?: string;
error?: string;
}
/**
* Parses a command string into its components.
* Returns basic parsing info without executing argParser.
*/
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();
const command = commandName && manPages[commandName];
if (!commandName) {
return {
commandName: "",
args: [],
fullInput: "",
error: "No command provided",
};
}
if (!command) {
return {
commandName,
args,
fullInput,
error: `Unknown command: ${commandName}`,
};
}
return {
commandName,
args,
fullInput,
command,
};
}
/**
* Executes the argParser for a command and returns complete parsed command data.
* This is async to support commands like profile that use NIP-05 resolution.
*/
export async function executeCommandParser(
parsed: ParsedCommand,
): Promise<ParsedCommand> {
if (!parsed.command) {
return parsed; // Already has error, return as-is
}
try {
// Use argParser if available, otherwise use defaultProps
const props = parsed.command.argParser
? 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 {
...parsed,
error:
error instanceof Error
? error.message
: "Failed to parse command arguments",
};
}
}
/**
* Complete command parsing pipeline: parse input → execute argParser.
* Returns fully parsed command ready for window creation.
*/
export async function parseAndExecuteCommand(
input: string,
): Promise<ParsedCommand> {
const parsed = parseCommandInput(input);
if (parsed.error || !parsed.command) {
return parsed;
}
return executeCommandParser(parsed);
}

View File

@@ -0,0 +1,196 @@
import { WindowInstance } from "@/types/app";
import { nip19 } from "nostr-tools";
/**
* Reconstructs the command string that would have created this window.
* Used for windows created before commandString tracking was added.
*/
export function reconstructCommand(window: WindowInstance): string {
const { appId, props } = window;
try {
switch (appId) {
case "nip":
return `nip ${props.number || "01"}`;
case "kind":
return `kind ${props.number || "1"}`;
case "kinds":
return "kinds";
case "man":
return props.cmd && props.cmd !== "help"
? `man ${props.cmd}`
: "help";
case "profile": {
// Try to encode pubkey as npub for readability
if (props.pubkey) {
try {
const npub = nip19.npubEncode(props.pubkey);
return `profile ${npub}`;
} catch {
// If encoding fails, use hex
return `profile ${props.pubkey}`;
}
}
return "profile";
}
case "open": {
// Try to encode event ID as note or use hex
if (props.id) {
try {
const note = nip19.noteEncode(props.id);
return `open ${note}`;
} catch {
return `open ${props.id}`;
}
}
// Address pointer format: kind:pubkey:d-tag
if (props.address) {
return `open ${props.address}`;
}
return "open";
}
case "relay":
return props.url ? `relay ${props.url}` : "relay";
case "conn":
return "conn";
case "encode":
// Best effort reconstruction
return props.args ? `encode ${props.args.join(" ")}` : "encode";
case "decode":
return props.args ? `decode ${props.args[0] || ""}` : "decode";
case "req": {
// Reconstruct req command from filter object
return reconstructReqCommand(props);
}
case "feed":
return reconstructFeedCommand(props);
case "debug":
return "debug";
case "win":
return "win";
default:
return appId; // Fallback to just the command name
}
} catch (error) {
console.error("Failed to reconstruct command:", error);
return appId; // Fallback to just the command name
}
}
/**
* Reconstructs a req command from its filter props.
* This is complex as req has many flags.
*/
function reconstructReqCommand(props: any): string {
const parts = ["req"];
const filter = props.filter || {};
// Kinds
if (filter.kinds && filter.kinds.length > 0) {
parts.push("-k", filter.kinds.join(","));
}
// Authors (convert hex to npub if possible)
if (filter.authors && filter.authors.length > 0) {
const authors = filter.authors.map((hex: string) => {
try {
return nip19.npubEncode(hex);
} catch {
return hex;
}
});
parts.push("-a", authors.join(","));
}
// Limit
if (filter.limit) {
parts.push("-l", filter.limit.toString());
}
// Event IDs (#e tag)
if (filter["#e"] && filter["#e"].length > 0) {
parts.push("-e", filter["#e"].join(","));
}
// Mentioned pubkeys (#p tag)
if (filter["#p"] && filter["#p"].length > 0) {
const pubkeys = filter["#p"].map((hex: string) => {
try {
return nip19.npubEncode(hex);
} catch {
return hex;
}
});
parts.push("-p", pubkeys.join(","));
}
// Hashtags (#t tag)
if (filter["#t"] && filter["#t"].length > 0) {
parts.push("-t", filter["#t"].join(","));
}
// D-tags (#d tag)
if (filter["#d"] && filter["#d"].length > 0) {
parts.push("-d", filter["#d"].join(","));
}
// Generic tags
for (const [key, value] of Object.entries(filter)) {
if (key.startsWith("#") && key.length === 2 && !["#e", "#p", "#t", "#d"].includes(key)) {
const letter = key[1];
const values = value as string[];
if (values.length > 0) {
parts.push("--tag", letter, values.join(","));
}
}
}
// Time ranges
if (filter.since) {
parts.push("--since", filter.since.toString());
}
if (filter.until) {
parts.push("--until", filter.until.toString());
}
// Search
if (filter.search) {
parts.push("--search", filter.search);
}
// Close on EOSE
if (props.closeOnEose) {
parts.push("--close-on-eose");
}
// Relays
if (props.relays && props.relays.length > 0) {
parts.push(...props.relays);
}
return parts.join(" ");
}
/**
* Reconstructs a feed command from its props.
*/
function reconstructFeedCommand(props: any): string {
// Feed command structure depends on implementation
// This is a best-effort reconstruction
return "feed";
}

View File

@@ -24,6 +24,7 @@ export interface WindowInstance {
appId: AppId;
title: string;
props: any;
commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com")
}
export interface Workspace {