Files
paseo/packages/app/src/composer/input/state.ts
T
Luke LaBonte 7f6a805e0e feat(app): choose the composer send shortcut (#5503)
Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests.

Co-authored-by: Luke LaBonte <contact@lukelabonte.com>
2026-10-27 10:05:16 +07:00

271 lines
8.1 KiB
TypeScript

import type { SendShortcut } from "@/hooks/use-settings";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type SendBehavior = ActiveTurnBehavior | "queue";
export function resolveActiveSendBehavior(
sendBehavior: SendBehavior,
hasPendingPermission: boolean,
): SendBehavior {
return sendBehavior === "queue" && hasPendingPermission ? "interrupt" : sendBehavior;
}
interface ComposerSurfaceState {
opacity: 0 | 1;
pointerEvents: "auto" | "none";
}
export interface ComposerSurfacePresentation {
input: ComposerSurfaceState;
overlay: ComposerSurfaceState;
}
const INPUT_PRESENTATION: ComposerSurfacePresentation = {
input: { opacity: 1, pointerEvents: "auto" },
overlay: { opacity: 0, pointerEvents: "none" },
};
const OVERLAY_PRESENTATION: ComposerSurfacePresentation = {
input: { opacity: 0, pointerEvents: "none" },
overlay: { opacity: 1, pointerEvents: "auto" },
};
export function resolveComposerSurfacePresentation(
showOverlay: boolean,
): ComposerSurfacePresentation {
return showOverlay ? OVERLAY_PRESENTATION : INPUT_PRESENTATION;
}
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;
isAgentRunning: boolean;
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
voiceAgentId: string | undefined;
}
interface SendActionContext {
defaultSendBehavior: SendBehavior;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
handleSendMessage: () => void;
handleQueueMessage: () => void;
}
interface DictationTranscriptContext {
value: string;
defaultSendBehavior: SendBehavior;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmit: (payload: MessagePayload) => void;
replaceText: (text: string) => void;
attachments: MessagePayload["attachments"];
cwd: string;
autoSend: boolean;
}
export function applyDictationTranscript(text: string, ctx: DictationTranscriptContext): void {
if (!text) return;
const shouldPad = ctx.value.length > 0 && !/\s$/.test(ctx.value);
const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`;
if (!ctx.autoSend) {
ctx.replaceText(nextValue);
return;
}
ctx.replaceText(nextValue);
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.replaceText("");
return;
}
ctx.onSubmit({
text: nextValue,
attachments: ctx.attachments,
cwd: ctx.cwd,
forceSend: ctx.isAgentRunning || undefined,
});
}
interface MessageInputKeyboardActions {
focusInput: () => void;
isDictationRecording: () => boolean;
markTranscriptForSend: () => void;
confirmDictation: () => void | Promise<void>;
cancelDictation: () => void | Promise<void>;
startDictation: () => void | Promise<void>;
toggleRealtimeVoice: () => void;
isRealtimeVoiceActive: boolean;
toggleRealtimeVoiceMute: () => void;
}
export function computeCanStartDictation(input: {
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
disabled: boolean;
dictationUnavailableMessage: string | null | undefined;
}): boolean {
const socketConnected = input.client?.isConnected ?? false;
const readyForDictation = input.isReadyForDictation ?? socketConnected;
return (
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
);
}
export function runDefaultSendAction(ctx: SendActionContext): void {
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
ctx.handleQueueMessage();
return;
}
ctx.handleSendMessage();
}
export type AlternateSendAction = "queue" | "send";
/**
* What Mod+Enter and a long-press on Send do instead of the default send. Null while
* no turn is running, where both fall back to the default send.
*/
export function resolveAlternateSendAction(
ctx: Pick<SendActionContext, "defaultSendBehavior" | "isAgentRunning" | "onQueue">,
): AlternateSendAction | null {
if (!ctx.isAgentRunning || !ctx.onQueue) return null;
return ctx.defaultSendBehavior === "queue" ? "send" : "queue";
}
export function runAlternateSendAction(ctx: SendActionContext): void {
const action = resolveAlternateSendAction(ctx);
if (action === "queue") {
ctx.handleQueueMessage();
return;
}
if (action === "send") {
ctx.handleSendMessage();
return;
}
runDefaultSendAction(ctx);
}
export type MessageInputEnterAction = "alternate-send" | "default-send";
export interface MessageInputEnterContext {
submitOnEnter: boolean;
sendShortcut: SendShortcut;
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
}
export function resolveMessageInputEnterAction(
ctx: MessageInputEnterContext,
): MessageInputEnterAction | null {
if (!ctx.submitOnEnter) return null;
if (ctx.shiftKey) return null;
const hasModifier = ctx.metaKey || ctx.ctrlKey;
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
return "default-send";
}
export function runMessageInputKeyboardAction(
action: MessageInputKeyboardActionKind,
actions: MessageInputKeyboardActions,
): boolean {
if (action === "focus") {
actions.focusInput();
return true;
}
if (action === "send" || action === "dictation-confirm") {
if (actions.isDictationRecording()) {
actions.markTranscriptForSend();
void actions.confirmDictation();
return true;
}
return false;
}
if (action === "voice-toggle") {
actions.toggleRealtimeVoice();
return true;
}
if (action === "voice-mute-toggle") {
if (actions.isRealtimeVoiceActive) {
actions.toggleRealtimeVoiceMute();
}
return true;
}
if (action === "dictation-cancel") {
if (actions.isDictationRecording()) {
void actions.cancelDictation();
return true;
}
return false;
}
if (action === "dictation-toggle") {
if (actions.isDictationRecording()) {
actions.markTranscriptForSend();
void actions.confirmDictation();
} else {
void actions.startDictation();
}
return true;
}
return false;
}
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
if (ctx.isAgentRunning) {
if (!ctx.client || !ctx.voiceAgentId) {
throw new Error("Cannot stop the running voice agent while the host is unavailable");
}
await ctx.client.cancelAgent(ctx.voiceAgentId);
}
await ctx.voice.stopVoice();
}
/**
* Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in
* equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at
* x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen
* glyphs visibly past the arrow.
*/
export function resolveGlyphRailOffset(input: {
controlSize: number;
sendIconSize: number;
glyphIconSize: number;
}): number {
const arrowInkInset =
(input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24;
const glyphInkInset =
(input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24;
return arrowInkInset - glyphInkInset;
}
export function resolveComposerSurfaceText(
liveText: string,
replacement: TextReplacement,
appliedReplacementKey: string,
): string {
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
}
/** A retained caret must fit the text the remounted editor shows. */
export function resolveRetainedSelection(
retained: { start: number; end: number },
textLength: number,
): { start: number; end: number } {
const end = Math.min(Math.max(retained.end, 0), textLength);
const start = Math.min(Math.max(retained.start, 0), end);
return { start, end };
}