Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
229 lines
7.2 KiB
TypeScript
229 lines
7.2 KiB
TypeScript
/**
|
|
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
|
|
*
|
|
* Simplified: no file attachments, screenshots, drag-drop, command palette,
|
|
* hover cards, referenced sources, or tabs. Core input + footer + submit.
|
|
*/
|
|
|
|
import { ArrowUp, Square, X } from 'lucide-react';
|
|
import type {
|
|
ComponentProps,
|
|
FormEvent,
|
|
HTMLAttributes,
|
|
KeyboardEvent,
|
|
} from 'react';
|
|
import { forwardRef, useCallback, useRef } from 'react';
|
|
import { cn } from '../../lib/utils';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from '../ui/input-group';
|
|
import { Spinner } from '../ui/spinner';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PromptInput (form wrapper)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
|
|
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
|
/** Allow attachments that carry their own prompt context to submit without textarea text. */
|
|
allowEmptySubmit?: boolean;
|
|
/** Optional styling for the bordered input group inside the form. */
|
|
inputGroupClassName?: string;
|
|
}
|
|
|
|
export const shouldSubmitPromptInput = (text: string, allowEmptySubmit = false): boolean =>
|
|
Boolean(text.trim()) || allowEmptySubmit;
|
|
|
|
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
|
({ className, onSubmit, allowEmptySubmit = false, inputGroupClassName, children, ...props }, ref) => {
|
|
const handleSubmit = useCallback(
|
|
(e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const form = e.currentTarget;
|
|
const textarea = form.querySelector('textarea');
|
|
const text = textarea?.value?.trim() ?? '';
|
|
if (!shouldSubmitPromptInput(text, allowEmptySubmit)) return;
|
|
onSubmit(text, e);
|
|
},
|
|
[allowEmptySubmit, onSubmit],
|
|
);
|
|
|
|
return (
|
|
<form
|
|
ref={ref}
|
|
onSubmit={handleSubmit}
|
|
className={className}
|
|
data-allow-empty-submit={allowEmptySubmit ? 'true' : undefined}
|
|
{...props}
|
|
>
|
|
<InputGroup className={inputGroupClassName}>{children}</InputGroup>
|
|
</form>
|
|
);
|
|
},
|
|
);
|
|
PromptInput.displayName = 'PromptInput';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PromptInputTextarea
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
|
|
/** Called when Enter is pressed (without Shift) to trigger form submit */
|
|
onSubmitRequest?: () => void;
|
|
}
|
|
|
|
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
|
|
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
|
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
|
|
|
const setRef = useCallback(
|
|
(node: HTMLTextAreaElement | null) => {
|
|
internalRef.current = node;
|
|
if (typeof ref === 'function') ref(node);
|
|
else if (ref) ref.current = node;
|
|
},
|
|
[ref],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
onKeyDown?.(e);
|
|
if (e.defaultPrevented) return;
|
|
|
|
// CJK composition guard
|
|
if (e.nativeEvent.isComposing) return;
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
onSubmitRequest?.();
|
|
// Trigger form submit
|
|
const form = internalRef.current?.closest('form');
|
|
if (form) {
|
|
form.requestSubmit();
|
|
}
|
|
}
|
|
},
|
|
[onKeyDown, onSubmitRequest],
|
|
);
|
|
|
|
return (
|
|
<InputGroupTextarea
|
|
ref={setRef}
|
|
className={className}
|
|
onKeyDown={handleKeyDown}
|
|
{...props}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
PromptInputTextarea.displayName = 'PromptInputTextarea';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PromptInputFooter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
|
|
({ className, ...props }, ref) => (
|
|
<InputGroupAddon
|
|
ref={ref}
|
|
align="block-end"
|
|
className={cn('gap-1', className)}
|
|
{...props}
|
|
/>
|
|
),
|
|
);
|
|
PromptInputFooter.displayName = 'PromptInputFooter';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PromptInputTools (left side of footer)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn('flex items-center gap-0.5', className)}
|
|
{...props}
|
|
/>
|
|
),
|
|
);
|
|
PromptInputTools.displayName = 'PromptInputTools';
|
|
|
|
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
|
|
|
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
|
status?: PromptInputStatus;
|
|
onStop?: () => void;
|
|
}
|
|
|
|
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
|
|
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
|
|
const isRunning = status === 'submitted' || status === 'streaming';
|
|
|
|
const handleClick = useCallback(() => {
|
|
if (isRunning && onStop) {
|
|
onStop();
|
|
}
|
|
}, [isRunning, onStop]);
|
|
|
|
const icon =
|
|
status === 'submitted' ? (
|
|
<Spinner size={14} />
|
|
) : status === 'streaming' ? (
|
|
<Square size={14} />
|
|
) : status === 'error' ? (
|
|
<X size={14} />
|
|
) : (
|
|
<ArrowUp size={14} />
|
|
);
|
|
|
|
const tooltipLabel =
|
|
status === 'submitted'
|
|
? 'Waiting...'
|
|
: status === 'streaming'
|
|
? 'Stop'
|
|
: status === 'error'
|
|
? 'Error'
|
|
: 'Send';
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<InputGroupButton
|
|
ref={ref}
|
|
type={isRunning ? 'button' : 'submit'}
|
|
onClick={isRunning ? handleClick : undefined}
|
|
aria-label={tooltipLabel}
|
|
variant="ghost"
|
|
disabled={disabled && !isRunning}
|
|
className={cn(
|
|
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
|
|
isRunning
|
|
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
|
|
: disabled
|
|
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
|
|
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
{icon}
|
|
</InputGroupButton>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
},
|
|
);
|
|
PromptInputSubmit.displayName = 'PromptInputSubmit';
|