nandi/oripublic Fork 0
35061753be581c0ba47a3189520d64e7788c183d
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

PromptInput.tsx · 183 lines · 4.6 KBTypeScript Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
 * PromptInput is the message composer: Enter sends (Shift+Enter for a new
 * line); while a turn runs the send button becomes a stop button, matching
 * the behaviour of Zed's agent panel. Typing "@" opens the workspace file
 * selector (the pick is sent as an ACP resource_link attachment) and a
 * leading "/" opens the skill / command selector.
 *
 * @example
 * <PromptInput turnActive={false} commands={[]} onSend={(t, files) => …} onCancel={() => {}} />
 */

import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import {
	type CompletionItem,
	activeAttachments,
	applyCompletion,
} from "../mentions";
import type { AcpAvailableCommand, PromptAttachment } from "../protocol";
import { useCompletion } from "../useCompletion";
import { CompletionPopup } from "./CompletionPopup";

interface PromptInputProps {
	turnActive: boolean;
	/** Slash commands announced by the agent, merged into the "/" popup. */
	commands: AcpAvailableCommand[];
	onSend: (text: string, attachments: PromptAttachment[]) => void;
	onCancel: () => void;
}

export function PromptInput({
	turnActive,
	commands,
	onSend,
	onCancel,
}: PromptInputProps) {
	const [text, setText] = useState("");
	const [caret, setCaret] = useState(0);
	const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
	const textareaRef = useRef<HTMLTextAreaElement>(null);
	const pendingCaret = useRef<number | null>(null);
	const completion = useCompletion({ text, caret, commands });

	// After a completion the caret must land right after the insertion; the
	// pending value is consumed on the render that follows the text update.
	useEffect(() => {
		if (pendingCaret.current !== null && textareaRef.current) {
			textareaRef.current.setSelectionRange(
				pendingCaret.current,
				pendingCaret.current,
			);
			pendingCaret.current = null;
		}
	});

	const syncCaret = () => {
		setCaret(textareaRef.current?.selectionStart ?? text.length);
	};

	const pick = (item: CompletionItem) => {
		if (!completion.trigger) {
			return;
		}
		const next = applyCompletion(text, completion.trigger, item.insert);
		setText(next.text);
		setCaret(next.caret);
		pendingCaret.current = next.caret;
		if (item.attachment) {
			setAttachments((current) => [
				...current,
				item.attachment as PromptAttachment,
			]);
		}
		textareaRef.current?.focus();
	};

	const submit = () => {
		const trimmed = text.trim();
		if (trimmed === "" || turnActive) {
			return;
		}
		onSend(trimmed, activeAttachments(trimmed, attachments));
		setText("");
		setCaret(0);
		setAttachments([]);
	};

	const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
		if (completion.open && handleCompletionKey(event, completion, pick)) {
			event.preventDefault();
			return;
		}
		if (event.key === "Enter" && !event.shiftKey) {
			event.preventDefault();
			submit();
		}
	};

	return (
		<form
			className="composer"
			onSubmit={(event) => {
				event.preventDefault();
				submit();
			}}
		>
			<div className="composer-field">
				{completion.open && (
					<CompletionPopup
						items={completion.items}
						active={completion.active}
						onHover={completion.setActive}
						onPick={pick}
					/>
				)}
				<textarea
					ref={textareaRef}
					className="composer-input"
					placeholder="Message the agent… (@ file, / skill)"
					aria-label="prompt"
					aria-autocomplete="list"
					aria-expanded={completion.open}
					aria-activedescendant={
						completion.open ? `completion-${completion.active}` : undefined
					}
					value={text}
					rows={3}
					onChange={(event) => {
						setText(event.target.value);
						setCaret(event.target.selectionStart);
					}}
					onKeyDown={onKeyDown}
					onKeyUp={syncCaret}
					onClick={syncCaret}
				/>
			</div>
			{turnActive ? (
				<button type="button" className="composer-stop" onClick={onCancel}>
					Stop
				</button>
			) : (
				<button
					type="submit"
					className="composer-send"
					disabled={text.trim() === ""}
				>
					Send
				</button>
			)}
		</form>
	);
}

/**
 * handleCompletionKey applies the popup's keyboard contract and tells
 * whether the key was consumed: arrows move, Enter/Tab pick, Escape closes.
 */
function handleCompletionKey(
	event: KeyboardEvent<HTMLTextAreaElement>,
	completion: ReturnType<typeof useCompletion>,
	pick: (item: CompletionItem) => void,
): boolean {
	switch (event.key) {
		case "ArrowDown":
			completion.moveActive(1);
			return true;
		case "ArrowUp":
			completion.moveActive(-1);
			return true;
		case "Enter":
		case "Tab": {
			const item = completion.items[completion.active];
			if (item) {
				pick(item);
			}
			return true;
		}
		case "Escape":
			completion.dismiss();
			return true;
		default:
			return false;
	}
}