andy-stack_vaultkeeper-ai/Components/ChatInput.svelte
Andrew Beal 0fb17e7b3a feat: add planning model selection and rate limit countdown UI
Introduce separate planning model setting to allow using different models for planning vs execution. Add visual countdown display when rate limits are hit, with improved retry delay parsing across providers (Claude, OpenAI, Gemini). Refactor settings tab into Views directory and enhance mobile layout for input controls.
2026-01-05 21:49:51 +00:00

763 lines
22 KiB
Svelte

<script lang="ts">
import { onDestroy, tick } from "svelte";
import { Platform, setIcon, type EventRef } from "obsidian";
import type { UserInputService } from "Services/UserInputService";
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import { isSearchTrigger, fromInput, toNode, triggerToText } from "Enums/SearchTrigger";
import ChatSearchResults from "./ChatSearchResults.svelte";
import type { Writable } from "svelte/store";
import type { InputService } from "Services/InputService";
import UserInstruction from "./UserInstruction.svelte";
import DiffControls from "./DiffControls.svelte";
import type { EventService } from "Services/EventService";
import { Event } from "Enums/Event";
import type { DiffService } from "Services/DiffService";
import type { Attachment } from "Conversations/Attachment";
import ChatAttachments from "./ChatAttachments.svelte";
import InputDisplay from "./InputDisplay.svelte";
import { InputMode } from "Enums/InputMode";
import { Copy, replaceCopy } from "Enums/Copy";
import { HelpModal } from "Modals/HelpModal";
import { sleep } from "Helpers/Helpers";
export let attachments: Attachment[] = [];
export let hasNoApiKey: boolean;
export let isSubmitting: boolean;
export let editModeActive: boolean;
export let planningModeActive: boolean;
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
export let onStop: () => void;
const inputService: InputService = Resolve<InputService>(Services.InputService);
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
const eventService: EventService = Resolve<EventService>(Services.EventService);
const searchState: Writable<ISearchState> = searchStateStore.searchState;
let inputDisplay: InputDisplay;
let textareaElement: HTMLDivElement;
let userInstructionButton: HTMLButtonElement;
let submitButton: HTMLButtonElement;
let editModeButton: HTMLButtonElement;
let planningModeButton: HTMLButtonElement;
let userInstructionActive: boolean = false;
let userRequest: string = "";
let inputMode: InputMode = InputMode.Normal;
let questionResolver: ((answer: string) => void) | null = null;
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
let countdownSecondsRemaining: number = 0;
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { inputMode = InputMode.Diff; focusInput(); });
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
onDestroy(() => {
eventService.offref(diffOpenedRef);
eventService.offref(diffClosedRef);
eventService.offref(rateLimitCountdownRef);
stopCountdown();
});
export function focusInput(onMobile: boolean = false) {
// don't focus on mobile, it's annoying
if (onMobile || !Platform.isMobile) {
tick().then(() => {
textareaElement?.focus();
});
}
}
export function setDisplayItem(element: HTMLElement) {
inputDisplay.setDisplayItem(element);
}
export function clearDisplayItem() {
stopCountdown();
inputDisplay.clearDisplayItem();
inputMode = InputMode.Normal;
}
async function startCountdown(delayMs: number) {
stopCountdown();
countdownSecondsRemaining = Math.ceil(delayMs / 1000);
updateCountdownDisplay();
countdownIntervalId = setInterval(() => {
countdownSecondsRemaining--;
if (countdownSecondsRemaining <= 0) {
clearDisplayItem();
} else {
updateCountdownDisplay();
}
}, 1000);
}
function stopCountdown() {
if (countdownIntervalId !== null) {
clearInterval(countdownIntervalId);
countdownIntervalId = null;
}
}
function openTroubleshootingModal() {
const modal = Resolve<HelpModal>(Services.HelpModal);
modal.open(3); // 3 = Troubleshooting
}
function updateCountdownDisplay() {
const countdownDisplay = createEl("div");
countdownDisplay.addClass("rate-limit-container");
const countdown = createEl("span");
countdown.addClass("rate-limit-countdown");
countdown.textContent = replaceCopy(Copy.RateLimitCountdown, [countdownSecondsRemaining.toString()]);
const info1 = createEl("span");
info1.addClass("rate-limit-info");
info1.appendText(Copy.RateLimitInfo1);
const link = createEl("span");
link.addClass("rate-limit-link");
link.textContent = Copy.RateLimitInfoLink;
link.setAttribute("role", "link");
link.setAttribute("tabindex", "-1");
link.addEventListener("click", openTroubleshootingModal);
info1.append(link);
const info2 = createEl("span");
info2.addClass("rate-limit-info");
info2.appendText(Copy.RateLimitInfo2);
info1.append(info2);
countdownDisplay.append(countdown);
countdownDisplay.append(createEl("br"));
countdownDisplay.append(info1);
inputDisplay.setDisplayItem(countdownDisplay);
}
export function enterQuestionMode(resolver: (answer: string) => void) {
questionResolver = resolver;
inputMode = InputMode.Question;
focusInput();
}
$: if (userInstructionButton) {
setIcon(userInstructionButton, "user-round-pen");
}
$: if (submitButton) {
if (inputMode === InputMode.Question || inputMode === InputMode.Diff) {
setIcon(submitButton, userRequest.trim() === "" ? "square" : "send-horizontal");
} else {
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
}
}
$: if (editModeButton) {
setIcon(editModeButton, editModeActive ? "pencil" : "pencil-off");
}
$: if (planningModeButton) {
setIcon(planningModeButton, planningModeActive ? "list-ordered" : "list-x");
}
$: inputPlaceholder = (() => {
if (inputMode === InputMode.Question) return Copy.InputPlaceholderQuestion;
if (inputMode === InputMode.Diff) return Copy.InputPlaceholderDiff;
return Copy.InputPlaceholderNormal;
})();
$: submitDisabled = (() => {
if (inputMode === InputMode.Diff || inputMode === InputMode.Question) {
return false;
}
return !isSubmitting && userRequest.trim() === "";
})();
$: submitAriaLabel = (() => {
if (inputMode === InputMode.Question) {
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonSubmitAnswer;
}
if (inputMode === InputMode.Diff) {
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion;
}
return isSubmitting ? Copy.ButtonCancel : Copy.ButtonSendMessage;
})();
function handleStop() {
onStop();
}
function handleSubmit() {
if (userRequest.trim() === "" || isSubmitting) {
return;
}
const result = requestFromInput();
onSubmit(result.request, result.formattedRequest);
}
function handleSuggestion() {
if (userRequest.trim() === "" || inputMode !== InputMode.Diff) {
return;
}
const suggestion = requestFromInput();
diffService.onSuggest(suggestion.formattedRequest);
}
function handleAnswer() {
if (userRequest.trim() === "" || inputMode !== InputMode.Question) {
return;
}
const answer = requestFromInput();
if (questionResolver) {
questionResolver(answer.formattedRequest);
questionResolver = null;
}
clearDisplayItem();
}
function requestFromInput() {
const request = textareaElement.innerHTML;
const formattedRequest = triggerToText(request);
textareaElement.textContent = "";
userRequest = "";
if (Platform.isMobile) {
textareaElement.blur();
} else {
focusInput();
}
return { request: request, formattedRequest: formattedRequest };
}
function toggleEditMode() {
if (planningModeActive) {
planningModeActive = false
}
editModeActive = !editModeActive;
focusInput();
}
function togglePlanningMode() {
if (planningModeActive) {
planningModeActive = false;
} else {
if (!editModeActive) {
toggleEditMode(); // Mandatory for planning mode
}
planningModeActive = true;
}
focusInput();
}
async function handleKeydown(e: KeyboardEvent) {
userInstructionActive = false;
if ($searchState.active) {
await continueSearch(e);
return;
}
if (e.key === "Enter") {
if (e.shiftKey || Platform.isMobile) {
return;
}
e.preventDefault();
if (inputMode === InputMode.Question) {
handleAnswer();
} else if (inputMode === InputMode.Diff) {
handleSuggestion();
} else {
handleSubmit();
}
}
}
// Detect search triggers on character insertion
function handleBeforeInput(e: InputEvent) {
// This works reliably on both desktop and mobile (including virtual keyboards)
if (e.inputType === "insertText" && e.data && isSearchTrigger(e.data)) {
const position = inputService.getCursorPosition(textareaElement);
const trigger = fromInput(e.data);
searchStateStore.initializeSearch(trigger, position);
}
// Let the character insert, handleInput() will synchronize the search query
}
async function continueSearch(e: KeyboardEvent) {
if (!$searchState.trigger) {
searchStateStore.resetSearch();
return;
}
if (e.key === "Escape") {
searchStateStore.resetSearch();
e.preventDefault();
return;
}
if (e.key === "Enter") {
e.preventDefault();
handleSearchResultAcceptance();
return;
}
if (e.key === "Backspace" || e.key === "Delete") {
return;
}
if (e.key.startsWith("Arrow")) {
if (e.key === "ArrowUp") {
e.preventDefault();
searchStateStore.setSelectedResultToPrevious();
}
if (e.key === "ArrowDown") {
e.preventDefault();
searchStateStore.setSelectedResultToNext();
}
return;
}
// Only append printable characters to the query
if (inputService.isPrintableKey(e.key, e.ctrlKey, e.metaKey)) {
searchStateStore.appendToQuery(e.key);
userInputService.performSearch();
}
}
function handleSearchResultAcceptance(e?: MouseEvent) {
if ($searchState.selectedResult !== "" && $searchState.position != null && $searchState.trigger != null) {
const node = toNode($searchState.trigger, $searchState.selectedResult);
inputService.deleteTextRange($searchState.position, inputService.getCursorPosition(textareaElement), textareaElement);
inputService.insertElementAtCursor(node, textareaElement);
searchStateStore.resetSearch();
}
e?.preventDefault();
focusInput(true);
}
function handleInput() {
if (textareaElement) {
userRequest = textareaElement.textContent || "";
if (textareaElement.innerHTML !== textareaElement.textContent) {
if (inputService.hasUnauthorizedHTML(textareaElement)) {
inputService.sanitizeToPlainText(textareaElement);
}
}
// If in search mode, synchronize the query with actual text content
if ($searchState.active && $searchState.position !== null) {
const fullText = textareaElement.textContent || "";
const triggerPos = $searchState.position;
// Extract the query portion (from trigger to cursor position)
const currentCursorPos = inputService.getCursorPosition(textareaElement);
const actualQuery = fullText.substring(triggerPos + 1, currentCursorPos);
// Only update if the query has changed
if (actualQuery !== $searchState.query) {
searchStateStore.setQuery(actualQuery);
userInputService.performSearch();
}
}
if (userRequest.trim() === "") {
textareaElement.textContent = "";
}
}
}
function handleCopy(e: ClipboardEvent) {
e.preventDefault();
const selection = window.getSelection();
if (!selection) {
return;
}
const selectedText = selection.toString();
e.clipboardData?.setData("text/plain", selectedText);
}
async function handleDataTransfer(e: ClipboardEvent | DragEvent) {
e.preventDefault();
e.stopPropagation();
const dataTransfer = e instanceof ClipboardEvent ? e.clipboardData : e.dataTransfer;
const files = await inputService.getFilesFromDataTransfer(dataTransfer);
const plainText = inputService.getTextFromDataTransfer(dataTransfer);
const newAttachments = files.filter(file => !attachments.some(attachment => attachment.base64 === file.base64));
attachments = [...attachments, ...newAttachments];
if (!plainText || plainText.includes("obsidian:")) {
return;
}
inputService.insertTextAtCursor(plainText);
handleInput();
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
e.dataTransfer.effectAllowed = "copy";
}
}
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleCursorPositionChange() {
if (!$searchState.active || $searchState.position === null) {
return;
}
const currentPosition = inputService.getCursorPosition(textareaElement);
if (!inputService.isInSearchZone(currentPosition, $searchState.position)) {
searchStateStore.resetSearch();
}
}
function handleFocusOut() {
searchStateStore.resetSearch();
}
</script>
<div id="input-container" class:edit-mode={editModeActive}>
<div id="input-display-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
<InputDisplay bind:this={inputDisplay} {editModeActive}/>
</div>
<div id="input-attachments-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
<ChatAttachments bind:attachments={attachments}/>
</div>
<div id="diff-controls-container" style:padding-top={inputMode === InputMode.Diff ? "var(--size-4-2)" : 0}>
<DiffControls diffOpen={inputMode === InputMode.Diff}/>
</div>
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
<ChatSearchResults searchState={$searchState} onResultAccept={handleSearchResultAcceptance}/>
</div>
<div id="user-instruction-container" style:padding-top={userInstructionActive ? "var(--size-4-2)" : 0}>
<UserInstruction focusInput={focusInput} bind:userInstructionActive={userInstructionActive}/>
</div>
<button
id="user-instruction-button"
class:instruction-active={userInstructionActive}
bind:this={userInstructionButton}
on:click={() => { userInstructionActive = !userInstructionActive; searchStateStore.resetSearch() }}
aria-label="User Instruction">
</button>
<div
id="input-field"
class:error={hasNoApiKey}
class:edit-mode={editModeActive && !hasNoApiKey}
bind:this={textareaElement}
contenteditable="plaintext-only"
on:keydown={handleKeydown}
on:beforeinput={handleBeforeInput}
on:input={handleInput}
on:copy={handleCopy}
on:paste={handleDataTransfer}
on:drop={handleDataTransfer}
on:dragover={handleDragOver}
on:dragenter={handleDragEnter}
on:dragleave={handleDragLeave}
on:click={handleCursorPositionChange}
on:keyup={handleCursorPositionChange}
on:focusout={handleFocusOut}
data-placeholder={inputPlaceholder}
role="textbox"
aria-multiline="true"
tabindex="0">
</div>
<button
id="edit-mode-button"
class:edit-mode={editModeActive}
bind:this={editModeButton}
on:click={() => { toggleEditMode() }}
disabled={isSubmitting}
aria-label={editModeActive ? Copy.ButtonTurnOffAgentMode : Copy.ButtonTurnOnAgentMode}>
</button>
<button
id="planning-mode-button"
class:planning-mode={planningModeActive}
bind:this={planningModeButton}
on:click={() => { togglePlanningMode() }}
disabled={isSubmitting}
aria-label={planningModeActive ? Copy.ButtonTurnOffPlanningMode : Copy.ButtonTurnOnPlanningMode}>
</button>
<button
id="submit-button"
class:edit-mode={editModeActive}
bind:this={submitButton}
on:click={() => {
if (inputMode === InputMode.Question) {
userRequest.trim() === "" ? handleStop() : handleAnswer();
} else if (inputMode === InputMode.Diff) {
userRequest.trim() === "" ? handleStop() : handleSuggestion();
} else {
isSubmitting ? handleStop() : handleSubmit();
}
}}
disabled={submitDisabled}
aria-label={submitAriaLabel}>
</button>
</div>
<style>
#input-container {
grid-row: 3;
grid-column: 1;
display: grid;
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-3);
grid-template-columns: var(--size-4-3) auto var(--size-4-2) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
border-radius: var(--modal-radius);
background-color: var(--background-primary);
}
#input-container.edit-mode {
border-color: var(--alt-interactive-accent);
transition: border-color 0.5s ease-out;
}
#input-display-container {
grid-row: 1;
grid-column: 2 / 11;
}
#input-attachments-container {
grid-row: 2;
grid-column: 2 / 11;
}
#diff-controls-container {
grid-row: 3;
grid-column: 2 / 11;
}
#input-search-results-container {
grid-row: 4;
grid-column: 2 / 11;
}
#user-instruction-container {
grid-row: 4;
grid-column: 2 / 11;
}
#user-instruction-button {
grid-row: 6;
grid-column: 2;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
}
:global(.is-mobile) #user-instruction-button {
max-height: 2rem;
}
#user-instruction-button.instruction-active {
box-shadow: 0px 0px 4px 1px var(--color-accent);
}
#input-field {
grid-row: 6;
grid-column: 4;
height: 100%;
max-height: 30vh;
border-radius: var(--input-radius);
font-weight: var(--input-font-weight);
border-width: var(--input-border-width);
border-style: solid;
border-color: var(--background-modifier-border);
padding: var(--size-2-2) var(--size-2-3);
background-color: var(--background-primary);
font-family: var(--font-interface-theme);
resize: none;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
color: var(--font-interface-theme);
transition: border-color 0.5s ease-out;
word-wrap: break-word;
white-space: pre-wrap;
}
:global(.is-mobile) #input-field {
align-content: end;
}
#input-field:focus {
border-color: var(--color-accent);
box-shadow: 0px 0px 4px 1px var(--color-accent);
transition: border-color 0.5s ease-out;
}
#input-field.edit-mode:focus {
border-color: var(--alt-interactive-accent);
box-shadow: 0px 0px 3px 1px var(--alt-interactive-accent);
transition: border-color 0.5s ease-out;
}
#input-field.error,
#input-field.error:focus {
border-color: var(--color-red);
box-shadow: 0px 0px 4px 1px var(--color-red);
transition: border-color 0.5s ease-out;
}
#input-field::-webkit-scrollbar {
display: none;
}
#input-field:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
opacity: 0.75;
pointer-events: none;
}
#input-field[contenteditable]:focus {
outline: none;
}
#edit-mode-button {
grid-row: 6;
grid-column: 6;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
}
#edit-mode-button.edit-mode {
box-shadow: inset 0px 0px 1px 1px var(--alt-interactive-accent);
}
:global(.is-mobile) #edit-mode-button {
max-height: 2rem;
}
#planning-mode-button {
grid-row: 6;
grid-column: 8;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
}
#planning-mode-button.planning-mode {
box-shadow: inset 0px 0px 1px 1px var(--alt-interactive-accent);
}
:global(.is-mobile) #planning-mode-button {
max-height: 2rem;
}
#submit-button {
grid-row: 6;
grid-column: 10;
border-radius: var(--button-radius);
padding-left: var(--size-4-5);
padding-right: var(--size-4-5);
align-self: end;
transition-duration: 0.5s;
background-color: var(--interactive-accent);
}
:global(.is-mobile) #submit-button {
max-height: 2rem;
}
#submit-button:not(:disabled):hover {
cursor: pointer;
background-color: var(--interactive-accent-hover);
}
#submit-button.edit-mode {
background-color: var(--alt-interactive-accent);
}
#submit-button.edit-mode:not(:disabled):hover {
cursor: pointer;
background-color: var(--alt-interactive-accent-hover);
}
/* Narrow/mobile layout: input above, buttons below */
:global(.is-mobile) #input-container {
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-3);
grid-template-columns: var(--size-4-3) auto 1fr auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
}
:global(.is-mobile) #input-display-container,
:global(.is-mobile) #input-attachments-container,
:global(.is-mobile) #diff-controls-container,
:global(.is-mobile) #input-search-results-container,
:global(.is-mobile) #user-instruction-container {
grid-column: 2 / 9;
}
:global(.is-mobile) #input-field {
grid-row: 6;
grid-column: 2 / 9;
}
:global(.is-mobile) #user-instruction-button {
grid-row: 8;
grid-column: 2;
}
:global(.is-mobile) #edit-mode-button {
grid-row: 8;
grid-column: 4;
}
:global(.is-mobile) #planning-mode-button {
grid-row: 8;
grid-column: 6;
}
:global(.is-mobile) #submit-button {
grid-row: 8;
grid-column: 8;
}
</style>