diff --git a/.changeset/prompt-editor-stable-during-streaming.md b/.changeset/prompt-editor-stable-during-streaming.md new file mode 100644 index 0000000..a4bc709 --- /dev/null +++ b/.changeset/prompt-editor-stable-during-streaming.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the chat prompt input stable during streaming so mobile touch gestures (such as the iOS long-press paste/edit callout) are no longer interrupted. Session status and activity updates are now coalesced into a single render per animation frame instead of one per token, the prompt editor ignores status changes that do not affect what it displays, and per-keystroke draft state no longer triggers surrounding re-renders. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index ba6ae20..30cc18d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1830,6 +1830,25 @@ export class PiWebApp extends LitElement { void this.sessions.send(text, streamingBehavior, attachments, delivery); } + // Stable handler identities for . Inlined arrow closures would + // be a fresh reference on every render, forcing Lit to re-commit the bindings + // each time the app re-renders; bound class fields keep them constant. + private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => { + this.sendPrompt(text, streamingBehavior, attachments, delivery); + }; + + private readonly handleStopActiveWork = (): void => { + void this.sessions.stopActiveWork(); + }; + + private readonly handleSelectModel = (): void => { + void this.openModelDialog(); + }; + + private readonly handleSelectThinking = (): void => { + void this.openThinkingDialog(); + }; + private renderContextBar() { if (!this.appShell.isMobileNavigationLayout) return null; return html` @@ -1889,7 +1908,7 @@ export class PiWebApp extends LitElement {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 0fcffaf..7c79bdc 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; -import { inputModeForDraft } from "../inputModes"; +import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; @@ -42,7 +42,14 @@ export class PromptEditor extends LitElement { @property({ attribute: false }) availableThinkingLevels: readonly string[] = []; @query(".markdown-editor") private editorHost?: HTMLDivElement; @query(".attachment-input") private attachmentInput?: HTMLInputElement; - @state() private draft = ""; + // `draft` is the live document text but is intentionally NOT reactive: it + // changes on every keystroke and the visible text is owned by CodeMirror, not + // by Lit's render. Re-rendering the surrounding template on each keystroke is + // wasted work and, on iOS, can interrupt an in-progress touch gesture (the + // long-press edit/paste callout). Only `currentInputMode` (shell vs. normal) + // is reactive, since that is the only draft-derived value the template shows. + private draft = ""; + @state() private currentInputMode: InputMode = { kind: "normal" }; @state() private completions: CompletionItem[] = []; @state() private selectedIndex = 0; @state() private attachments: PendingAttachment[] = []; @@ -64,17 +71,29 @@ export class PromptEditor extends LitElement { if (previousKey !== undefined) saveDraft(previousKey, this.draft); const currentKey = draftStorageKey(this.machineId, this.sessionId); this.draft = currentKey !== undefined ? loadDraft(currentKey) : ""; + this.currentInputMode = inputModeForDraft(this.draft); this.completions = []; this.selectedIndex = 0; } + protected override shouldUpdate(changed: PropertyValues): boolean { + // Status updates churn once per token during streaming and hand us a fresh + // object reference each time. When nothing else changed, only re-render if a + // status field the template actually displays differs, so streaming does not + // disturb the editor DOM (and any in-progress touch gesture survives). + if (changed.has("status") && changed.size === 1) { + return !sessionStatusRenderEqual(changed.get("status"), this.status); + } + return true; + } + override firstUpdated(): void { this.createEditor(); } protected override updated(changed: PropertyValues) { if (changed.has("disabled")) this.updateEditorDisabledState(); - if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); + if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); } override disconnectedCallback(): void { @@ -84,8 +103,8 @@ export class PromptEditor extends LitElement { } override render() { - const inputMode = inputModeForDraft(this.draft); - const shellMode = inputMode.kind === "shell"; + const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined; + const shellMode = shellInputMode !== undefined; const queuesInput = this.canSteer || this.isCompacting; const busy = this.disabled || this.sending; return html` @@ -94,7 +113,7 @@ export class PromptEditor extends LitElement {
{ void this.handleFileInput(event); }} /> - ${shellMode ? html`
Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} + ${shellMode ? html`
Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} ${this.isCompacting && !shellMode ? html`
Compacting history · message will be queued
` : null} ${this.renderAttachments()} { this.pick(item); }}> @@ -288,6 +307,8 @@ export class PromptEditor extends LitElement { this.draft = value; const key = draftStorageKey(this.machineId, this.sessionId); if (key !== undefined) saveDraft(key, this.draft); + const nextInputMode = inputModeForDraft(this.draft); + if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode; void this.refreshCompletions(); } @@ -432,16 +453,33 @@ export class PromptEditor extends LitElement { private resetComposer() { this.draft = ""; + this.currentInputMode = { kind: "normal" }; const key = draftStorageKey(this.machineId, this.sessionId); if (key !== undefined) clearDraft(key); this.completions = []; this.attachments = []; this.attachmentError = undefined; + // `draft` is not reactive, so the cleared text will not flow to CodeMirror + // via `updated()`; push it to the editor document explicitly. + this.syncEditorDoc(); } static override styles = promptEditorStyles; } +// The only `status` fields the template reads directly are the model identity +// and thinking level (shown in renderCompactStatus). Everything else the editor +// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate +// property that Lit already diffs by value. Comparing just these fields lets us +// ignore the per-token status churn that does not change anything on screen. +function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.model?.id === b.model?.id + && a.model?.provider === b.model?.provider + && a.thinkingLevel === b.thinkingLevel; +} + function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined { if (typeof machineId !== "string" || machineId === "") return undefined; if (typeof sessionId !== "string" || sessionId === "") return undefined; diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 261c36e..a58f6c5 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api"; +import type { SessionUiEvent } from "../sessionSocket"; import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; import { machineSessionKey } from "../machineKeys"; @@ -52,6 +53,28 @@ class FakeSocket implements SessionEventSocket { } } +class EmitSocket implements SessionEventSocket { + readonly connectedSessionIds: string[] = []; + private handler: ((event: SessionUiEvent) => void) | undefined; + + connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void { + this.connectedSessionIds.push(session.id); + this.handler = onEvent; + } + + setHandler(onEvent: (event: SessionUiEvent) => void): void { + this.handler = onEvent; + } + + emit(event: SessionUiEvent): void { + this.handler?.(event); + } + + close(): void { + this.handler = undefined; + } +} + const workspace: Workspace = { id: "workspace-1", projectId: "project-1", @@ -93,11 +116,116 @@ function status(sessionId: string): SessionStatus { }; } +const framesById = new Map void>(); +let nextFrameId = 1; + +// The controller coalesces status/activity/transcript updates behind +// requestAnimationFrame. The node test environment has no rAF, so install a +// controllable one: callbacks are queued and only run when a test drives a +// frame, mirroring how the browser defers them until paint. +beforeEach(() => { + framesById.clear(); + nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { + const id = nextFrameId++; + framesById.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function runPendingAnimationFrames(): void { + const frames = Array.from(framesById.values()); + framesById.clear(); + for (const frame of frames) frame(); +} + describe("SessionController", () => { afterEach(() => { Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); }); + it("coalesces rapid status updates into a single state write per frame", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } }); + + // Nothing applies until the frame is flushed; last-write-wins per session. + expect(setStateCalls).toHaveLength(0); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + + runPendingAnimationFrames(); + + expect(setStateCalls).toHaveLength(1); + expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 }); + expect(state.status?.messageCount).toBe(3); + }); + + it("applies the latest activity per session on flush", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } }); + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } }); + + expect(setStateCalls).toHaveLength(0); + + controller.flushPendingUpdates(); + + expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" }); + expect(state.activity?.phase).toBe("idle"); + }); + + it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => { + const socket = new EmitSocket(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + status: () => Promise.resolve(status(oldSession.id)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket }, + ); + await controller.selectSession(oldSession, { updateUrl: false }); + + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } }); + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } }); + + // Buffered, not applied synchronously. + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined(); + + controller.flushPendingUpdates(); + + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8); + expect(state.status?.messageCount).toBe(8); + }); + it("clears stale active activity when an idle status arrives", () => { const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" }; let state: AppState = { @@ -116,6 +244,7 @@ describe("SessionController", () => { ); controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) }); + controller.flushPendingUpdates(); expect(state.activity).toBeUndefined(); expect(state.sessionActivities[oldSession.id]).toBeUndefined(); @@ -137,6 +266,7 @@ describe("SessionController", () => { ); controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } }); + controller.flushPendingUpdates(); expect(state.sessions[0]?.messageCount).toBe(3); expect(state.selectedSession?.messageCount).toBe(3); @@ -356,6 +486,7 @@ describe("SessionController", () => { const send = controller.send("hello"); controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } }); + controller.flushPendingUpdates(); resolvePrompt?.(); await send; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index b3576a3..a8b5976 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -34,7 +34,9 @@ export class SessionController { private selectionSeq = 0; private catchupStreamSessionId: string | undefined; private pendingTranscriptEvents: SessionUiEvent[] = []; - private pendingTranscriptFrame: number | undefined; + private pendingStatusBySession = new Map(); + private pendingActivityBySession = new Map(); + private pendingFrame: number | undefined; constructor( private readonly getState: GetState, @@ -49,22 +51,22 @@ export class SessionController { } applyGlobalEvent(event: GlobalSessionEvent): void { - if (event.type === "status.update") this.applyStatus(event.status); - else if (event.type === "activity.update") this.applyActivity(event.activity); + if (event.type === "status.update") this.queueStatusUpdate(event.status); + else if (event.type === "activity.update") this.queueActivityUpdate(event.activity); else if (event.type === "session.created") this.applyCreatedSession(event.session); else this.applySessionName(event.sessionId, event.name); } dispose() { this.socket.close(); - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); } clearActiveSession() { this.selectionSeq += 1; this.socket.close(); this.catchupStreamSessionId = undefined; - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); // Note: sendingPrompts is intentionally NOT cleared here. Deselecting a // session must not cancel the in-flight upload indicator of the session // that is still sending; the per-session entry is cleared by send()'s @@ -113,7 +115,7 @@ export class SessionController { const seq = ++this.selectionSeq; this.socket.close(); this.catchupStreamSessionId = undefined; - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); const transcriptKey = this.sessionCacheKey(session.id); const cached = this.transcripts.cachedView(transcriptKey); this.setState({ @@ -563,7 +565,7 @@ export class SessionController { const session = this.getState().selectedSession; if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return; try { - this.flushPendingTranscriptEvents(); + this.flushPendingUpdates(); const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]); if (this.getState().selectedSession?.id !== sessionId) return; const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page); @@ -694,19 +696,29 @@ export class SessionController { if (isTranscriptEvent(event)) return; } + // Status and activity arrive once per token (the server republishes them on + // every transcript event). Buffer them alongside high-frequency transcript + // deltas so the host component renders at most once per animation frame + // instead of once per token. Coalescing these here is what keeps the prompt + // editor's DOM stable during streaming, so in-progress touch gestures (e.g. + // the iOS long-press edit/paste callout) are not interrupted by a re-render. + if (event.type === "status.update") { + this.queueStatusUpdate(event.status); + return; + } + if (event.type === "activity.update") { + this.queueActivityUpdate(event.activity); + return; + } if (isHighFrequencyTranscriptEvent(event)) { this.queueTranscriptEvent(event); return; } - this.flushPendingTranscriptEvents(); + this.flushPendingUpdates(); const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event); if (transcript) { this.setState({ messages: transcript }); - } else if (event.type === "status.update") { - this.applyStatus(event.status); - } else if (event.type === "activity.update") { - this.applyActivity(event.activity); } else if (event.type === "session.name") { this.applySessionName(event.sessionId, event.name); } @@ -714,27 +726,60 @@ export class SessionController { private queueTranscriptEvent(event: SessionUiEvent): void { this.pendingTranscriptEvents.push(event); - if (this.pendingTranscriptFrame !== undefined) return; - this.pendingTranscriptFrame = requestAnimationFrame(() => { - this.pendingTranscriptFrame = undefined; - this.flushPendingTranscriptEvents(); + this.schedulePendingFlush(); + } + + private queueStatusUpdate(status: SessionStatus): void { + this.pendingStatusBySession.set(status.sessionId, status); + this.schedulePendingFlush(); + } + + private queueActivityUpdate(activity: SessionActivity): void { + this.pendingActivityBySession.set(activity.sessionId, activity); + this.schedulePendingFlush(); + } + + private schedulePendingFlush(): void { + if (this.pendingFrame !== undefined) return; + this.pendingFrame = requestAnimationFrame(() => { + this.pendingFrame = undefined; + this.flushPendingUpdates(); }); } - private flushPendingTranscriptEvents(): void { - if (this.pendingTranscriptEvents.length === 0) return; - const events = this.pendingTranscriptEvents; - this.pendingTranscriptEvents = []; - let messages = this.getState().messages; - for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; - if (messages !== this.getState().messages) this.setState({ messages }); + // Apply buffered transcript deltas, activity, and status in one task. Activity + // is applied before status to mirror the server's publish order, so an idle + // status can clear the now-stale active activity it supersedes. Status and + // activity are last-write-wins per session, so iterating the maps applies only + // the latest buffered value per session. These writes run in a single task, so + // Lit batches them into one render. + flushPendingUpdates(): void { + if (this.pendingTranscriptEvents.length > 0) { + const events = this.pendingTranscriptEvents; + this.pendingTranscriptEvents = []; + let messages = this.getState().messages; + for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; + if (messages !== this.getState().messages) this.setState({ messages }); + } + if (this.pendingActivityBySession.size > 0) { + const activities = Array.from(this.pendingActivityBySession.values()); + this.pendingActivityBySession.clear(); + for (const activity of activities) this.applyActivity(activity); + } + if (this.pendingStatusBySession.size > 0) { + const statuses = Array.from(this.pendingStatusBySession.values()); + this.pendingStatusBySession.clear(); + for (const status of statuses) this.applyStatus(status); + } } - private clearPendingTranscriptEvents(): void { + private clearPendingUpdates(): void { this.pendingTranscriptEvents = []; - if (this.pendingTranscriptFrame === undefined) return; - cancelAnimationFrame(this.pendingTranscriptFrame); - this.pendingTranscriptFrame = undefined; + this.pendingStatusBySession.clear(); + this.pendingActivityBySession.clear(); + if (this.pendingFrame === undefined) return; + cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = undefined; } // Stream catch-up is a single mode with two coupled facets that must never diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 5f14055..f4e8ea3 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { inputModeForDraft, isShellInput } from "./inputModes"; +import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes"; describe("inputModeForDraft", () => { it("detects shell input and context-excluded shell input after leading whitespace", () => { @@ -14,6 +14,13 @@ describe("inputModeForDraft", () => { expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" }); }); + it("treats modes as equal only when kind and shell context-exclusion match", () => { + expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true); + expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false); + expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true); + expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false); + }); + it("detects file completion contexts", () => { expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index ed59118..bfeb755 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -19,6 +19,12 @@ export function isShellInput(text: string): boolean { return inputModeForDraft(text).kind === "shell"; } +export function inputModesEqual(a: InputMode, b: InputMode): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "shell" && b.kind === "shell") return a.excludeFromContext === b.excludeFromContext; + return true; +} + function currentToken(draft: string): string { const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1; return draft.slice(tokenStart);