{
if (e.isTrusted) {
setFocused(true);
+
+ // If shift tabbing into the prompt field, select the last placeholder if any.
+ if (
+ e.relatedTarget &&
+ getInteractionModality() === 'keyboard' &&
+ e.currentTarget.compareDocumentPosition(e.relatedTarget) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ) {
+ let lastPlaceholder = prompt.segments.findLastIndex(s => s.type === 'token');
+ if (lastPlaceholder >= 0) {
+ setPrompt(value =>
+ value.withSelectedRange(
+ new TokenFieldValue.SelectedRange(
+ {index: lastPlaceholder, offset: 0},
+ {index: lastPlaceholder, offset: 1}
+ )
+ )
+ );
+ }
+ }
}
}}
onBlur={e => {
@@ -451,7 +591,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
setFocused(false);
}
}}
- onKeyDown={onKeyDown}
onPaste={
acceptedAttachmentTypes
? e => {
@@ -477,12 +616,12 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
+ className={
css('&:empty::before { content: attr(data-placeholder); }') +
style({
font: 'body',
color: {
- default: baseColor('neutral'),
+ default: 'neutral',
':empty': {
default: 'gray-600',
forcedColors: 'GrayText'
@@ -491,9 +630,22 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
width: 'full',
outlineStyle: 'none',
cursor: 'text'
- })(renderProps)
+ })
}>
- {children || (segment => {segment.text})}
+ {useCallback(
+ (token: TokenSegment) => {
+ if (token.value?.type === 'anchor') {
+ return {token.text};
+ } else {
+ return children ? (
+ children(token)
+ ) : (
+ {token.text}
+ );
+ }
+ },
+ [children]
+ )}
= 0 && i < prompt.segments.length; i += dir) {
+ let segment = prompt.segments[i];
+ if (segment.type === 'token' && (!placeholder || segment.value?.type === 'placeholder')) {
+ return prompt.withSelectedRange(
+ new TokenFieldValue.SelectedRange(
+ {index: i, offset: 0},
+ {index: i, offset: segment.text.length}
+ )
+ );
+ }
+ }
+
+ return null;
+}
+
export interface PromptTokenFieldPopoverProps extends Omit {
filterAnchor?: Position | null;
items?: React.ReactNode[] | null | Promise;
@@ -517,7 +690,7 @@ export interface PromptTokenFieldPopoverProps extends Omit 0 && filterAnchor.offset === 0) {
+ filterAnchor = {
+ index: filterAnchor.index - 1,
+ offset: prompt.segments[filterAnchor.index - 1].text.length
+ };
+ }
+ // Reposition the popover when the anchor changes.
+ key = `${filterAnchor.index}:${filterAnchor.offset}`;
+ }
+
return (
{
return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect();
}}>
-
+
@@ -548,6 +735,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) {
}
export interface PromptTokenProps extends Omit {
+ token: TokenSegment;
children: React.ReactNode;
}
@@ -555,40 +743,50 @@ export function PromptToken(props: PromptTokenProps) {
return (
+ className={renderProps =>
+ style({
+ font: 'ui',
+ backgroundColor: {
+ default: 'transparent-overlay-1000/10',
+ isSelected: 'blue-800',
+ // Firefox ignores completely transparent selection colors, so we need to use a nearly transparent color instead
+ '::selection': '[#ffffff01]'
+ },
+ color: {
+ default: 'body',
+ isSelected: 'white'
+ },
+ outlineStyle: {
+ default: 'solid',
+ isPlaceholder: 'dashed'
+ },
+ outlineWidth: 1,
+ outlineColor: {
+ default: 'transparent-overlay-1000/10',
+ isPlaceholder: 'transparent-overlay-1000/40'
+ },
+ outlineOffset: -1,
+ borderRadius: 'pill',
+ boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`,
+ paddingX: 8,
+ // not using inline-flex here due to a text selection bug in WebKit.
+ paddingY: space(3),
+ lineHeight: '[1em]',
+ cursor: 'default',
+ '--iconPrimary': {
+ type: 'fill',
+ value: 'currentColor'
+ }
+ })({...renderProps, isPlaceholder: props.token.value?.type === 'placeholder'})
+ }>
{icon}
+ styles: style({
+ size: 14,
+ display: 'inline-block',
+ verticalAlign: '[-0.18em]',
+ marginEnd: 4
+ })
}}>
{props.children}
@@ -674,7 +872,7 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) {
// to be inaccurate
let finalPrompt = buildVoicePrompt(basePromptRef.current, transcript);
inputRef.current.focus();
- setTokenFieldSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition);
+ setTokenFieldSelection(inputRef.current, finalPrompt.selectedRange);
setPrompt(finalPrompt);
});
@@ -799,37 +997,50 @@ export function AttachFileMenuItem() {
}
// either replace the filter text (aka token replace) or insert value at current caret position (aka plain text inject)
-function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[]) {
+function useInsertPromptSegment(segments: TokenFieldSegment[]) {
let {setPrompt, inputRef} = useContext(PromptFieldContext);
let anchor = useContext(PromptCompletionAnchorContext);
- let pendingCaret = useRef(null);
- return (item: any) => {
+ let pendingSelection = useRef(null);
+ return () => {
setPrompt(value => {
+ // Add a space only if not already followed by one, but move the cursor past the space in any case.
+ let insert: TokenFieldSegment[] = [...segments];
+ let endPosition = value.selectedRange.end;
+ if (insert.length) {
+ let space = value.findText(endPosition, TokenFieldValue.Direction.Forward, ' ');
+ let hasFollowingSpace = space && value.slice(endPosition, space).segments.length === 0;
+ insert.push({type: 'text', text: ' '});
+ if (hasFollowingSpace && space) {
+ space.offset++;
+ endPosition = space;
+ }
+ }
let newValue = value.replaceRangeWithSegments(
- anchor ?? value.caretPosition,
- value.caretPosition,
- buildSegments(item),
+ anchor ?? value.selectedRange.start,
+ endPosition,
+ insert,
false // Don't coalesce in undo/redo history.
);
- pendingCaret.current = newValue.caretPosition;
+ newValue = selectNextToken(newValue, 1, true) || newValue;
+ pendingSelection.current = newValue.selectedRange;
return newValue;
});
if (anchor == null) {
// Wait for popover animation, then restore cursor to after the inserted content.
setTimeout(() => {
- if (inputRef.current && pendingCaret.current) {
- let position = pendingCaret.current;
- pendingCaret.current = null;
+ if (inputRef.current && pendingSelection.current) {
+ let range = pendingSelection.current;
+ pendingSelection.current = null;
inputRef.current.focus();
// we need to update the position manually since TokenField's update caret logic only happens if the field is focused
// but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late
- setTokenFieldSelection(inputRef.current, position, position);
+ setTokenFieldSelection(inputRef.current, range);
// the above focus and setCursor call can cause the internally tracked caret position to be reset incorrectly
// seemingly due to TokenField's isProgrammaticSelectionChange being flipped to false by setCursor and thus reset to 0 by the .focus
// fix this by resetting to proper position below
// happens when injecting multiple tokens one after another via + menu
- setPrompt(value => value.withCaretPosition(position));
+ setPrompt(value => value.withSelectedRange(range));
}
}, 400);
}
@@ -848,19 +1059,19 @@ export interface InsertTokenMenuItemProps extends Omit<
| 'rel'
| 'routerOptions'
| 'target'
-> {}
+ | 'value'
+> {
+ token: TokenSegment;
+}
export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) {
- let insert = useInsertPromptSegment(item => [
- {type: 'token', text: 'command' in item ? item.command : item.title, value: item},
- {type: 'text', text: ' '}
- ]);
+ let insert = useInsertPromptSegment([props.token]);
return (