Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/input-suggestion-extension.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { ExtensionAPI, InlineExtension, ToolDefinition } from "@earendil-works/pi-coding-agent";
import type { InputSuggestionService } from "./input-suggestion.js";
import type { Resettable } from "./input-suggestion.js";

export interface InputSuggestionExtensionOptions {
readonly service: InputSuggestionService;
readonly typeTextTool?: ToolDefinition<any, any, any> | undefined;
readonly lifecycle?: Resettable | undefined;
readonly service?: (Resettable & { getTypeTextTool?: () => ToolDefinition<any, any, any> }) | undefined;
}

/**
* Pi Agent InlineExtension for proactive input suggestions.
* Pi Agent InlineExtension for desktop text typing & proactive input suggestions.
*
* Adheres strictly to the `@earendil-works/pi-coding-agent` ExtensionAPI specification.
* Integrates the suggestion service lifecycle with the Pi Agent session and registers
* the official `type_text` tool dynamically.
* Dynamically registers the official `type_text` tool and ties lifecycle cleanup to
* `session_shutdown`.
*/
export function inputSuggestionExtension(
options: InputSuggestionExtensionOptions,
Expand All @@ -20,14 +21,17 @@ export function inputSuggestionExtension(
name: "squid-input-suggestion",
hidden: true,
factory: (pi: ExtensionAPI) => {
const toolToRegister = options.typeTextTool ?? options.service.getTypeTextTool?.();
const toolToRegister = options.typeTextTool ?? options.service?.getTypeTextTool?.();
if (toolToRegister !== undefined) {
pi.registerTool(toolToRegister);
}

pi.on("session_shutdown", () => {
options.service.reset?.();
});
const targetLifecycle = options.lifecycle ?? options.service;
if (targetLifecycle?.reset) {
pi.on("session_shutdown", () => {
targetLifecycle.reset();
});
}
},
};
}
99 changes: 72 additions & 27 deletions src/input-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* editor, or terminal). Infers/predicts what the user likely intends to enter,
* presents the proposed text to the user via Squid's native ConfirmationCard,
* and upon user acceptance ("Allow"), automatically types the suggested text
* into the target field using the standard Pi Agent `type_text` tool.
* into the target field using the TextTypingExecutor.
*
* Honors the GUI switch / environment variable (SQUID_INPUT_SUGGESTIONS !== "off").
*/
Expand All @@ -16,7 +16,11 @@ import type {
ActionConfirmationRequest,
} from "./action-confirmation.js";
import type { FocusChangedPayload, WorldState } from "./protocol.js";
import { TextTypingService } from "./text-typing-service.js";
import {
TextTypingService,
type TextTypingExecutor,
type TextTypingTarget,
} from "./text-typing-service.js";
import {
createTypeTextTool,
type typeTextParams,
Expand All @@ -33,12 +37,22 @@ export interface SuggestionContext {
readonly worldState: WorldState | null;
}

export type InputPredictor = (
context: SuggestionContext,
) => Promise<InputSuggestion | null> | InputSuggestion | null;

export interface Resettable {
reset(): void;
}

export interface InputSuggestionServiceOptions {
readonly isEnabled: () => boolean;
readonly confirmationPolicy: ActionConfirmationPolicy;
readonly textTypingService?: TextTypingService | undefined;
readonly textTypingService?: TextTypingExecutor | undefined;
/** @deprecated Pass textTypingService instead. Retained for backward compatibility. */
readonly typeTextTool?: ToolDefinition<typeof typeTextParams, TypeTextToolDetails> | undefined;
readonly predictSuggestion?: ((context: SuggestionContext) => Promise<InputSuggestion | null> | InputSuggestion | null) | undefined;
readonly predictSuggestion?: InputPredictor | undefined;
readonly onSuggestionAccepted?: ((suggestion: InputSuggestion, payload: FocusChangedPayload) => void) | undefined;
readonly cooldownMs?: number | undefined;
readonly promptTimeoutMs?: number | undefined;
readonly log?: ((line: string) => void) | undefined;
Expand Down Expand Up @@ -142,33 +156,56 @@ export function heuristicSuggestInput(context: SuggestionContext): InputSuggesti
};
}

export class InputSuggestionService {
export class InputSuggestionService implements Resettable {
private readonly isEnabled: () => boolean;
private readonly confirmationPolicy: ActionConfirmationPolicy;
private readonly typeTextTool: ToolDefinition<typeof typeTextParams, TypeTextToolDetails>;
private readonly typingExecutor: TextTypingExecutor;
private readonly predictSuggestion: (context: SuggestionContext) => Promise<InputSuggestion | null>;
private readonly onSuggestionAccepted?: ((suggestion: InputSuggestion, payload: FocusChangedPayload) => void) | undefined;
private readonly cooldownMs: number;
private readonly promptTimeoutMs: number;
private readonly log?: ((line: string) => void) | undefined;
private readonly logError?: ((line: string) => void) | undefined;
private readonly now: () => number;

private _typeTextTool?: ToolDefinition<typeof typeTextParams, TypeTextToolDetails> | undefined;
private lastTargetKey = "";
private lastTriggerTime = 0;
private inFlight = false;

constructor(options: InputSuggestionServiceOptions) {
this.isEnabled = options.isEnabled;
this.confirmationPolicy = options.confirmationPolicy;
if (options.typeTextTool !== undefined) {
this.typeTextTool = options.typeTextTool;
} else if (options.textTypingService !== undefined) {
this.typeTextTool = createTypeTextTool(options.textTypingService);
if (options.textTypingService !== undefined) {
this.typingExecutor = options.textTypingService;
} else if (options.typeTextTool !== undefined) {
const tool = options.typeTextTool;
this._typeTextTool = tool;
this.typingExecutor = {
async typeText(target: TextTypingTarget) {
const res = await tool.execute(
`exec-${Date.now()}`,
{
text: target.text,
...(target.targetWindowTitle !== undefined ? { targetWindowTitle: target.targetWindowTitle } : {}),
...(target.targetApplicationId !== undefined ? { targetApplicationId: target.targetApplicationId } : {}),
},
undefined,
undefined,
{} as any,
);
return {
ok: res.details?.ok ?? true,
...(res.details?.error !== undefined ? { error: res.details.error } : {}),
};
},
};
} else {
this.typeTextTool = createTypeTextTool(new TextTypingService());
this.typingExecutor = new TextTypingService();
}
const predictor = options.predictSuggestion ?? heuristicSuggestInput;
this.predictSuggestion = async (ctx) => predictor(ctx);
this.onSuggestionAccepted = options.onSuggestionAccepted;
this.cooldownMs = options.cooldownMs ?? 4_000;
this.promptTimeoutMs = options.promptTimeoutMs ?? 25_000;
this.log = options.log;
Expand All @@ -177,10 +214,20 @@ export class InputSuggestionService {
}

/**
* Returns the registered Pi Agent ToolDefinition.
* Returns the registered Pi Agent ToolDefinition adapter.
*/
getTypeTextTool(): ToolDefinition<typeof typeTextParams, TypeTextToolDetails> {
return this.typeTextTool;
if (!this._typeTextTool) {
this._typeTextTool = createTypeTextTool(this.typingExecutor);
}
return this._typeTextTool;
}

/**
* Returns the underlying TextTypingExecutor.
*/
getTypingExecutor(): TextTypingExecutor {
return this.typingExecutor;
}

/**
Expand Down Expand Up @@ -253,28 +300,26 @@ export class InputSuggestionService {
}),
);

// 6. User accepted: auto-type into target window via standard Pi Agent tool
const toolResult = await this.typeTextTool.execute(
`suggest-${nowMs}`,
{
text: suggestion.text,
...(payload.windowTitle !== undefined ? { targetWindowTitle: payload.windowTitle } : {}),
...(payload.applicationId !== undefined ? { targetApplicationId: payload.applicationId } : {}),
},
undefined,
undefined,
{} as any,
);
// 6. User accepted: auto-type into target window via typing executor
const target: TextTypingTarget = {
text: suggestion.text,
...(payload.windowTitle !== undefined ? { targetWindowTitle: payload.windowTitle } : {}),
...(payload.applicationId !== undefined ? { targetApplicationId: payload.applicationId } : {}),
};

const result = await this.typingExecutor.typeText(target);

if (toolResult.details?.ok === false) {
if (!result.ok) {
this.logError?.(
JSON.stringify({
inputSuggestionTypingFailed: {
target: targetKey,
error: toolResult.details.error,
error: result.error ?? "unknown error",
},
}),
);
} else {
this.onSuggestionAccepted?.(suggestion, payload);
}
} else {
this.log?.(
Expand Down
14 changes: 13 additions & 1 deletion src/text-typing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ export interface TextTypingTarget {
readonly targetWindowId?: string;
readonly targetWindowTitle?: string;
readonly targetApplicationId?: string;
readonly signal?: AbortSignal | undefined;
}

export interface TextTypingResult {
readonly ok: boolean;
readonly error?: string;
}

/**
* Abstraction for text typing execution.
* Decouples consumers (e.g. InputSuggestionService, typeTextTool) from concrete OS runners.
*/
export interface TextTypingExecutor {
typeText(target: TextTypingTarget): Promise<TextTypingResult>;
}

export interface SubprocessExecutionResult {
readonly exitCode: number;
readonly stdout?: string;
Expand Down Expand Up @@ -89,7 +98,7 @@ export function escapeSendKeys(text: string): string {
.replace(/\r?\n/g, "{ENTER}");
}

export class TextTypingService {
export class TextTypingService implements TextTypingExecutor {
private readonly platform: NodeJS.Platform;
private readonly runner: SubprocessRunner;
private readonly timeoutMs: number;
Expand All @@ -110,6 +119,9 @@ export class TextTypingService {
* Types the specified text into the focused target input.
*/
async typeText(target: TextTypingTarget): Promise<TextTypingResult> {
if (target.signal?.aborted) {
return { ok: false, error: "Aborted" };
}
const { text, targetWindowId, targetWindowTitle, targetApplicationId } = target;
if (text.length === 0) {
return { ok: true };
Expand Down
55 changes: 32 additions & 23 deletions src/type-text-tool.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,48 @@
import { Type } from "typebox";
import type { Static } from "typebox";
import {
defineTool,
type AgentToolResult,
type ExtensionToolContext,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { TextTypingService, type TextTypingTarget } from "./text-typing-service.js";
import type { TextTypingExecutor, TextTypingTarget } from "./text-typing-service.js";
import { Type, type Static } from "typebox";

/**
* Parameters for the standard Pi Agent `type_text` tool.
*/
export const typeTextParams = Type.Object({
text: Type.String({
description: "The text to type into the focused text target.",
description: "The text string to type into the currently active or specified input target.",
}),
targetWindowTitle: Type.Optional(
Type.String({
description: "Optional window title to activate before typing.",
description:
"Optional window title to focus and verify before typing, preventing text injection into the wrong window.",
}),
),
targetApplicationId: Type.Optional(
Type.String({
description: "Optional application name or process identifier to activate before typing.",
description: "Optional application identifier (e.g. process name or bundle id) for targeting.",
}),
),
});

export type TypeTextArgs = Static<typeof typeTextParams>;

export interface TypeTextToolDetails {
ok: boolean;
charsTyped?: number;
error?: string;
readonly ok: boolean;
readonly charsTyped?: number;
readonly error?: string;
}

/**
* Creates the official Pi Agent ToolDefinition for typing text into desktop fields.
*
* Wraps `TextTypingService` to satisfy the official `@earendil-works/pi-coding-agent`
* Wraps `TextTypingExecutor` to satisfy the official `@earendil-works/pi-coding-agent`
* tool specification, standardizing argument validation, execution, and result formatting.
*/
export function createTypeTextTool(
service: TextTypingService,
service: TextTypingExecutor,
): ToolDefinition<typeof typeTextParams, TypeTextToolDetails> {
return defineTool({
name: "type_text",
Expand Down Expand Up @@ -70,23 +70,32 @@ export function createTypeTextTool(
text: params.text,
...(params.targetWindowTitle !== undefined ? { targetWindowTitle: params.targetWindowTitle } : {}),
...(params.targetApplicationId !== undefined ? { targetApplicationId: params.targetApplicationId } : {}),
...(signal !== undefined ? { signal } : {}),
};

const result = await service.typeText(target);
if (!result.ok) {
try {
const result = await service.typeText(target);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to type text: ${result.error ?? "unknown error"}` }],
details: {
ok: false,
...(result.error !== undefined ? { error: result.error } : {}),
},
};
}

return {
content: [{ type: "text", text: `Successfully typed ${params.text.length} characters.` }],
details: { ok: true, charsTyped: params.text.length },
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Failed to type text: ${result.error ?? "unknown error"}` }],
details: {
ok: false,
...(result.error !== undefined ? { error: result.error } : {}),
},
content: [{ type: "text", text: `Failed to type text: ${message}` }],
details: { ok: false, error: message },
};
}

return {
content: [{ type: "text", text: `Successfully typed ${params.text.length} characters.` }],
details: { ok: true, charsTyped: params.text.length },
};
},
});
}
Loading