mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
import { isComposerSendKey, type SendShortcut } from "../../../domain/input/send-shortcut";
|
|
import {
|
|
type ComposerAttachment,
|
|
type ComposerAttachmentHandler,
|
|
codexInputWithComposerAttachments,
|
|
} from "../application/composer/attachments";
|
|
import type { ComposerBoundaryScrollAction } from "../application/composer/boundary-scroll";
|
|
import {
|
|
type ActiveNoteContextReference,
|
|
activeNoteContextReferenceMarker,
|
|
type ComposerContextReferenceProvider,
|
|
type SelectionContextReference,
|
|
selectionContextReferenceMarker,
|
|
} from "../application/composer/context-references";
|
|
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
|
import type { NoteCandidateProvider } from "../application/composer/note-context";
|
|
import {
|
|
activeComposerSuggestions,
|
|
applyComposerSuggestionInsertion,
|
|
type ComposerSuggestion,
|
|
composerSuggestionNavigationDirection,
|
|
type NoteCandidate,
|
|
nextComposerSuggestionIndex,
|
|
} from "../application/composer/suggestions";
|
|
import {
|
|
type PreparedComposerInput,
|
|
preparedUserInputWithWikiLinkMentionsSkillsAndContext,
|
|
} from "../application/composer/wikilink-context";
|
|
import type { ChatAction, ChatState } from "../application/state/root-reducer";
|
|
import type { ChatStateStore } from "../application/state/store";
|
|
import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer";
|
|
import { syncComposerHeight } from "../ui/composer.dom";
|
|
import {
|
|
applyComposerInsertionToElement,
|
|
composerBoundaryScrollActionFromElement,
|
|
composerFilesFromTransfer,
|
|
composerHasFocus,
|
|
composerInsertionSource,
|
|
composerRangeInsertionSource,
|
|
composerSuggestionSignatureFromElement,
|
|
composerTextBeforeCursor,
|
|
composerTransferHasFiles,
|
|
focusComposer,
|
|
} from "./composer-element.dom";
|
|
import type { ChatPanelComposerReadModel } from "./shell-read-model";
|
|
import type { ChatPanelComposerProjection } from "./surface/composer-projection";
|
|
|
|
export interface ChatComposerControllerOptions {
|
|
noteCandidateProvider: NoteCandidateProvider;
|
|
contextReferenceProvider: ComposerContextReferenceProvider;
|
|
attachmentHandler?: ComposerAttachmentHandler;
|
|
sourcePath: () => string;
|
|
stateStore: ChatStateStore;
|
|
viewId: string;
|
|
referenceActiveNoteOnSend: () => boolean;
|
|
sendShortcut: () => SendShortcut;
|
|
scrollThreadFromComposerEdges: () => boolean;
|
|
canInterrupt: (model: ChatPanelComposerReadModel) => boolean;
|
|
composerProjection: (model: ChatPanelComposerReadModel) => ChatPanelComposerProjection;
|
|
currentModelForSuggestions: () => string | null;
|
|
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
|
|
togglePlan: () => void;
|
|
toggleAutoReview: () => void;
|
|
toggleFast: () => void;
|
|
onDraftChange: () => void;
|
|
onHeightChange: () => void;
|
|
onAttachmentError?: (message: string) => void;
|
|
}
|
|
|
|
export interface ChatComposerRenderActions {
|
|
submit: () => void;
|
|
}
|
|
|
|
export class ChatComposerController {
|
|
private composer: HTMLTextAreaElement | null = null;
|
|
private attachments: ComposerAttachment[] = [];
|
|
private pendingAttachmentTransfers = new Set<Promise<void>>();
|
|
private submitAfterAttachmentTransfersActive = false;
|
|
private activeNoteContextSnapshots: ActiveNoteContextReference[] = [];
|
|
private selectionContextSnapshots: SelectionContextReference[] = [];
|
|
private pendingSelection: ComposerPendingSelection | null = null;
|
|
|
|
constructor(private readonly options: ChatComposerControllerOptions) {}
|
|
|
|
private get state(): ChatState {
|
|
return this.options.stateStore.getState();
|
|
}
|
|
|
|
private dispatch(action: ChatAction): void {
|
|
this.options.stateStore.dispatch(action);
|
|
}
|
|
|
|
get trimmedDraft(): string {
|
|
return this.composer?.value.trim() ?? this.state.composer.draft.trim();
|
|
}
|
|
|
|
renderState(model: ChatPanelComposerReadModel, actions: ChatComposerRenderActions): ComposerShellProps {
|
|
const projection = this.options.composerProjection(model);
|
|
return {
|
|
viewId: this.options.viewId,
|
|
draft: model.draft.value,
|
|
busy: model.turnBusy.value,
|
|
canInterrupt: this.options.canInterrupt(model),
|
|
normalPlaceholder: projection.placeholder,
|
|
suggestions: model.suggestions.value,
|
|
selectedSuggestionIndex: model.selectedSuggestionIndex.value,
|
|
pendingSelection: this.pendingSelection,
|
|
onPendingSelectionApplied: this.clearPendingSelection,
|
|
callbacks: this.composerCallbacks(actions),
|
|
meta: projection.meta,
|
|
onComposer: this.setComposerElement,
|
|
};
|
|
}
|
|
|
|
private readonly setComposerElement = (composer: HTMLTextAreaElement | null): void => {
|
|
if (!composer) {
|
|
this.composer = null;
|
|
return;
|
|
}
|
|
this.composer = composer;
|
|
syncComposerHeight(composer);
|
|
};
|
|
|
|
setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; preserveContext?: boolean } = {}): void {
|
|
if (!options.preserveContext) {
|
|
this.pruneActiveNoteContextSnapshots(text);
|
|
this.pruneSelectionContextSnapshots(text);
|
|
this.pruneAttachments(text);
|
|
}
|
|
this.dispatch({
|
|
type: "composer/draft-set",
|
|
draft: text,
|
|
...(options.clearSuggestions === undefined ? {} : { clearSuggestions: options.clearSuggestions }),
|
|
});
|
|
this.options.onDraftChange();
|
|
if (options.focus) focusComposer(this.composer);
|
|
}
|
|
|
|
focusComposer(): void {
|
|
focusComposer(this.composer, { preventScroll: true });
|
|
}
|
|
|
|
hasFocus(): boolean {
|
|
return composerHasFocus(this.composer);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.composer = null;
|
|
this.options.noteCandidateProvider.dispose();
|
|
this.options.contextReferenceProvider.dispose();
|
|
}
|
|
|
|
refreshSuggestions(): void {
|
|
this.updateSuggestions();
|
|
}
|
|
|
|
captureInputSnapshot(): ComposerInputSnapshot {
|
|
const sourcePath = this.options.sourcePath();
|
|
return {
|
|
sourcePath,
|
|
availableSkills: this.state.connection.availableSkills,
|
|
referenceActiveNoteOnSend: this.options.referenceActiveNoteOnSend(),
|
|
contextReferences: this.options.contextReferenceProvider.contextReferences(sourcePath),
|
|
activeNoteSnapshots: [...this.activeNoteContextSnapshots],
|
|
selectionSnapshots: [...this.selectionContextSnapshots],
|
|
attachments: [...this.attachments],
|
|
};
|
|
}
|
|
|
|
preparedInput(text: string, snapshot: ComposerInputSnapshot = this.captureInputSnapshot()): PreparedComposerInput {
|
|
const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext(
|
|
text,
|
|
(target) => this.options.noteCandidateProvider.resolveMention(target, snapshot.sourcePath),
|
|
snapshot.availableSkills,
|
|
this.contextReferencesFromSnapshot(snapshot, text),
|
|
{ referenceActiveNoteOnSend: snapshot.referenceActiveNoteOnSend },
|
|
);
|
|
return {
|
|
text: prepared.text,
|
|
input: codexInputWithComposerAttachments(prepared.text, prepared.input, snapshot.attachments),
|
|
};
|
|
}
|
|
|
|
private handleSuggestionKeydown(event: KeyboardEvent): boolean {
|
|
if (event.isComposing) return false;
|
|
const state = this.state;
|
|
if (state.composer.suggestions.length === 0) return false;
|
|
|
|
const direction = composerSuggestionNavigationDirection(event);
|
|
if (direction) {
|
|
event.preventDefault();
|
|
this.dispatchSuggestions({
|
|
type: "composer/suggestions-set",
|
|
suggestions: state.composer.suggestions,
|
|
selected: nextComposerSuggestionIndex(state.composer.suggestSelected, state.composer.suggestions.length, direction),
|
|
});
|
|
return true;
|
|
}
|
|
if (event.metaKey || event.ctrlKey) return false;
|
|
|
|
if (event.key === "Enter" || event.key === "Tab") {
|
|
event.preventDefault();
|
|
this.insertSuggestion(state.composer.suggestions[state.composer.suggestSelected], event.key === "Tab" ? "tab" : "enter");
|
|
return true;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
this.dismissSuggestions();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private handleBoundaryScrollKeydown(event: KeyboardEvent): boolean {
|
|
const action = composerBoundaryScrollActionFromElement(event, this.composer);
|
|
if (!action) return false;
|
|
if (action.kind === "scroll-by" && action.amount === "text-lines" && !this.options.scrollThreadFromComposerEdges()) return false;
|
|
|
|
event.preventDefault();
|
|
this.options.threadScrollFromComposer(action);
|
|
return true;
|
|
}
|
|
|
|
private updateSuggestions(): void {
|
|
const beforeCursor = composerTextBeforeCursor(this.composer);
|
|
if (beforeCursor === null) {
|
|
this.clearSuggestions();
|
|
return;
|
|
}
|
|
|
|
const signature = this.suggestionSignature();
|
|
const state = this.state;
|
|
if (state.composer.suggestionsDismissedSignature === signature) {
|
|
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [] });
|
|
return;
|
|
}
|
|
const suggestions = activeComposerSuggestions(
|
|
beforeCursor,
|
|
this.noteCandidates(),
|
|
state.connection.availableSkills,
|
|
state.threadList.listedThreads,
|
|
state.connection.availableModels,
|
|
this.options.currentModelForSuggestions(),
|
|
{
|
|
activeThreadId: state.activeThread.id,
|
|
contextReferences: this.contextReferences(),
|
|
permissionProfiles: state.connection.availablePermissionProfiles,
|
|
tagCandidates: () => this.options.noteCandidateProvider.tags(),
|
|
},
|
|
);
|
|
|
|
this.dispatchSuggestions({
|
|
type: "composer/suggestions-set",
|
|
suggestions,
|
|
selected: state.composer.suggestSelected >= suggestions.length ? 0 : state.composer.suggestSelected,
|
|
});
|
|
}
|
|
|
|
private handleInput(value: string): void {
|
|
this.pruneActiveNoteContextSnapshots(value);
|
|
this.pruneSelectionContextSnapshots(value);
|
|
this.pruneAttachments(value);
|
|
const suggestionState = this.inputSuggestionState();
|
|
this.dispatch({
|
|
type: "composer/input-set",
|
|
draft: value,
|
|
suggestions: suggestionState.suggestions,
|
|
selected: suggestionState.selected,
|
|
dismissedSignature: suggestionState.dismissedSignature,
|
|
});
|
|
this.options.onDraftChange();
|
|
}
|
|
|
|
private inputSuggestionState(): {
|
|
suggestions: readonly ComposerSuggestion[];
|
|
selected: number;
|
|
dismissedSignature: string | null;
|
|
} {
|
|
if (!this.composer) return { suggestions: [], selected: 0, dismissedSignature: null };
|
|
const signature = this.suggestionSignature();
|
|
const state = this.state;
|
|
if (state.composer.suggestionsDismissedSignature === signature) {
|
|
return { suggestions: [], selected: 0, dismissedSignature: signature };
|
|
}
|
|
const beforeCursor = composerTextBeforeCursor(this.composer);
|
|
if (beforeCursor === null) return { suggestions: [], selected: 0, dismissedSignature: null };
|
|
const suggestions = activeComposerSuggestions(
|
|
beforeCursor,
|
|
this.noteCandidates(),
|
|
state.connection.availableSkills,
|
|
state.threadList.listedThreads,
|
|
state.connection.availableModels,
|
|
this.options.currentModelForSuggestions(),
|
|
{
|
|
activeThreadId: state.activeThread.id,
|
|
contextReferences: this.contextReferences(),
|
|
permissionProfiles: state.connection.availablePermissionProfiles,
|
|
tagCandidates: () => this.options.noteCandidateProvider.tags(),
|
|
},
|
|
);
|
|
return {
|
|
suggestions,
|
|
selected: state.composer.suggestSelected >= suggestions.length ? 0 : state.composer.suggestSelected,
|
|
dismissedSignature: null,
|
|
};
|
|
}
|
|
|
|
private selectSuggestion(index: number): void {
|
|
if (this.state.composer.suggestSelected === index) return;
|
|
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: this.state.composer.suggestions, selected: index });
|
|
}
|
|
|
|
private insertSuggestion(suggestion: ComposerSuggestion | undefined, activation: "enter" | "tab" = "enter"): void {
|
|
if (!suggestion) return;
|
|
const source = composerInsertionSource(this.composer);
|
|
if (!source) return;
|
|
|
|
const insertion = applyComposerSuggestionInsertion(source.value, source.cursor, suggestion, { activation });
|
|
if (suggestion.activeNoteContext) this.rememberActiveNoteContextSnapshot(suggestion.activeNoteContext);
|
|
if (suggestion.selectionContext) this.rememberSelectionContextSnapshot(suggestion.selectionContext);
|
|
this.pruneActiveNoteContextSnapshots(insertion.value);
|
|
this.pruneSelectionContextSnapshots(insertion.value);
|
|
|
|
this.pendingSelection = { value: insertion.value, cursor: insertion.cursor };
|
|
this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true });
|
|
this.options.onDraftChange();
|
|
applyComposerInsertionToElement(this.composer, insertion.cursor);
|
|
}
|
|
|
|
private readonly clearPendingSelection = (): void => {
|
|
this.pendingSelection = null;
|
|
};
|
|
|
|
private clearSuggestions(): void {
|
|
this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: [], selected: 0 });
|
|
}
|
|
|
|
private handleTransferredFiles(files: readonly File[]): void {
|
|
if (files.length === 0) return;
|
|
const handler = this.options.attachmentHandler;
|
|
if (!handler) return;
|
|
|
|
const transfer = this.saveTransferredFiles(handler, files);
|
|
this.pendingAttachmentTransfers.add(transfer);
|
|
void transfer.finally(() => {
|
|
this.pendingAttachmentTransfers.delete(transfer);
|
|
});
|
|
}
|
|
|
|
private async saveTransferredFiles(handler: ComposerAttachmentHandler, files: readonly File[]): Promise<void> {
|
|
try {
|
|
const attachments = await handler.saveFiles(files);
|
|
if (attachments.length === 0) return;
|
|
this.attachments = [...this.attachments, ...attachments];
|
|
this.insertAttachmentMarkers(attachments);
|
|
} catch (error) {
|
|
this.options.onAttachmentError?.(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
private insertAttachmentMarkers(attachments: readonly ComposerAttachment[]): void {
|
|
const markers = attachments.map((attachment) => attachment.marker);
|
|
if (markers.length === 0) return;
|
|
|
|
const fallbackSource = {
|
|
value: this.state.composer.draft,
|
|
start: this.state.composer.draft.length,
|
|
end: this.state.composer.draft.length,
|
|
};
|
|
const source = composerRangeInsertionSource(this.composer) ?? fallbackSource;
|
|
const insertion = applyAttachmentMarkerInsertion(source.value, source.start, source.end, markers);
|
|
this.pruneAttachments(insertion.value);
|
|
this.pruneActiveNoteContextSnapshots(insertion.value);
|
|
this.pruneSelectionContextSnapshots(insertion.value);
|
|
this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true });
|
|
this.options.onDraftChange();
|
|
applyComposerInsertionToElement(this.composer, insertion.cursor);
|
|
}
|
|
|
|
private dismissSuggestions(): void {
|
|
this.dispatchSuggestions({
|
|
type: "composer/suggestions-set",
|
|
suggestions: [],
|
|
selected: 0,
|
|
dismissedSignature: this.suggestionSignature(),
|
|
});
|
|
}
|
|
|
|
private dispatchSuggestions(action: ChatAction): void {
|
|
this.options.stateStore.dispatch(action);
|
|
}
|
|
|
|
private suggestionSignature(): string | null {
|
|
return composerSuggestionSignatureFromElement(this.composer);
|
|
}
|
|
|
|
private noteCandidates(): NoteCandidate[] {
|
|
return [...this.options.noteCandidateProvider.candidates(this.options.sourcePath())];
|
|
}
|
|
|
|
private contextReferences(text: string | null = null) {
|
|
return this.contextReferencesFromSnapshot(this.captureInputSnapshot(), text);
|
|
}
|
|
|
|
private contextReferencesFromSnapshot(snapshot: ComposerInputSnapshot, text: string | null = null) {
|
|
const availableActiveNoteSnapshots = snapshot.activeNoteSnapshots;
|
|
const availableSelectionSnapshots = snapshot.selectionSnapshots;
|
|
const activeNoteSnapshots =
|
|
text === null
|
|
? availableActiveNoteSnapshots
|
|
: availableActiveNoteSnapshots.filter((activeNote) => text.includes(activeNoteContextReferenceMarker(activeNote)));
|
|
const selectionSnapshots =
|
|
text === null
|
|
? availableSelectionSnapshots
|
|
: availableSelectionSnapshots.filter((selection) => text.includes(selectionContextReferenceMarker(selection)));
|
|
return { ...snapshot.contextReferences, activeNoteSnapshots, selectionSnapshots };
|
|
}
|
|
|
|
private rememberActiveNoteContextSnapshot(activeNote: ActiveNoteContextReference): void {
|
|
const marker = activeNoteContextReferenceMarker(activeNote);
|
|
this.activeNoteContextSnapshots = [
|
|
...this.activeNoteContextSnapshots.filter((snapshot) => activeNoteContextReferenceMarker(snapshot) !== marker),
|
|
activeNote,
|
|
];
|
|
}
|
|
|
|
private rememberSelectionContextSnapshot(selection: SelectionContextReference): void {
|
|
const marker = selectionContextReferenceMarker(selection);
|
|
this.selectionContextSnapshots = [
|
|
...this.selectionContextSnapshots.filter((snapshot) => selectionContextReferenceMarker(snapshot) !== marker),
|
|
selection,
|
|
];
|
|
}
|
|
|
|
private pruneSelectionContextSnapshots(text: string): void {
|
|
this.selectionContextSnapshots = this.selectionContextSnapshots.filter((selection) =>
|
|
text.includes(selectionContextReferenceMarker(selection)),
|
|
);
|
|
}
|
|
|
|
private pruneActiveNoteContextSnapshots(text: string): void {
|
|
this.activeNoteContextSnapshots = this.activeNoteContextSnapshots.filter((activeNote) =>
|
|
text.includes(activeNoteContextReferenceMarker(activeNote)),
|
|
);
|
|
}
|
|
|
|
private activeAttachments(text: string): readonly ComposerAttachment[] {
|
|
return this.attachments.filter((attachment) => text.includes(attachment.marker));
|
|
}
|
|
|
|
private pruneAttachments(text: string): void {
|
|
this.attachments = [...this.activeAttachments(text)];
|
|
}
|
|
|
|
private async submitAfterAttachmentTransfers(actions: ChatComposerRenderActions): Promise<void> {
|
|
if (this.submitAfterAttachmentTransfersActive) return;
|
|
this.submitAfterAttachmentTransfersActive = true;
|
|
try {
|
|
while (this.pendingAttachmentTransfers.size > 0) {
|
|
await Promise.allSettled([...this.pendingAttachmentTransfers]);
|
|
}
|
|
actions.submit();
|
|
} finally {
|
|
this.submitAfterAttachmentTransfersActive = false;
|
|
}
|
|
}
|
|
|
|
private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks {
|
|
return {
|
|
onInput: (value) => {
|
|
this.handleInput(value);
|
|
},
|
|
onUpdateSuggestions: () => {
|
|
this.updateSuggestions();
|
|
},
|
|
onKeydown: (event) => {
|
|
if (this.handleSuggestionKeydown(event)) {
|
|
return;
|
|
}
|
|
if (this.handleBoundaryScrollKeydown(event)) {
|
|
return;
|
|
}
|
|
if (isComposerSendKey(event, this.options.sendShortcut())) {
|
|
event.preventDefault();
|
|
void this.submitAfterAttachmentTransfers(actions);
|
|
}
|
|
},
|
|
onPaste: (event) => {
|
|
const files = composerFilesFromTransfer(event.clipboardData);
|
|
if (files.length === 0) return;
|
|
event.preventDefault();
|
|
this.handleTransferredFiles(files);
|
|
},
|
|
onDrop: (event) => {
|
|
const files = composerFilesFromTransfer(event.dataTransfer);
|
|
if (files.length === 0) return;
|
|
event.preventDefault();
|
|
this.handleTransferredFiles(files);
|
|
},
|
|
onDragOver: (event) => {
|
|
if (!composerTransferHasFiles(event.dataTransfer)) return;
|
|
event.preventDefault();
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
|
},
|
|
onSendOrInterrupt: () => {
|
|
void this.submitAfterAttachmentTransfers(actions);
|
|
},
|
|
onHeightChange: () => {
|
|
this.options.onHeightChange();
|
|
},
|
|
onTogglePlan: () => {
|
|
this.options.togglePlan();
|
|
},
|
|
onToggleAutoReview: () => {
|
|
this.options.toggleAutoReview();
|
|
},
|
|
onToggleFast: () => {
|
|
this.options.toggleFast();
|
|
},
|
|
onSuggestionHover: (index) => {
|
|
this.selectSuggestion(index);
|
|
},
|
|
onSuggestionInsert: (suggestion) => {
|
|
this.insertSuggestion(suggestion);
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function applyAttachmentMarkerInsertion(
|
|
value: string,
|
|
start: number,
|
|
end: number,
|
|
markers: readonly string[],
|
|
): { value: string; cursor: number } {
|
|
const prefix = value.slice(0, start);
|
|
const suffix = value.slice(end);
|
|
const before = prefix && !prefix.endsWith("\n") ? "\n" : "";
|
|
const after = suffix && !suffix.startsWith("\n") ? "\n" : "";
|
|
const inserted = markers.join("\n");
|
|
const nextValue = `${prefix}${before}${inserted}${after}${suffix}`;
|
|
return {
|
|
value: nextValue,
|
|
cursor: prefix.length + before.length + inserted.length,
|
|
};
|
|
}
|