feat: add Tauri desktop app support and terminal management
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shortens a file path by replacing the home directory prefix with ~.
|
||||
* Handles both macOS (/Users/username) and Linux (/home/username) paths.
|
||||
*/
|
||||
export function shortenPath(path: string | undefined | null): string {
|
||||
if (!path) {
|
||||
return "";
|
||||
}
|
||||
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||
|
||||
let tauriWindow: any = null;
|
||||
|
||||
async function getTauriWindow() {
|
||||
if (tauriWindow) return tauriWindow;
|
||||
if (!getIsTauriMac()) return null;
|
||||
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
tauriWindow = getCurrentWindow();
|
||||
return tauriWindow;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDragging() {
|
||||
const win = await getTauriWindow();
|
||||
if (win) {
|
||||
await win.startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = await getTauriWindow();
|
||||
if (win) {
|
||||
await win.toggleMaximize();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns event handlers for drag region behavior
|
||||
export function useTauriDragHandlers() {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseDown: (e: React.MouseEvent) => {
|
||||
// Only handle left click, ignore if clicking on interactive elements
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button, a, input, [role='button']")) return;
|
||||
|
||||
// Prevent text selection when dragging
|
||||
e.preventDefault();
|
||||
|
||||
// Start dragging on mouse down
|
||||
startDragging();
|
||||
},
|
||||
onDoubleClick: (e: React.MouseEvent) => {
|
||||
// Ignore if clicking on interactive elements
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button, a, input, [role='button']")) return;
|
||||
|
||||
e.preventDefault();
|
||||
toggleMaximize();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Hook that returns traffic light padding, accounting for fullscreen state
|
||||
// In fullscreen, traffic lights are hidden so no padding is needed
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
async function setup() {
|
||||
const win = await getTauriWindow();
|
||||
if (!win) return;
|
||||
|
||||
// Check initial fullscreen state
|
||||
const fullscreen = await win.isFullscreen();
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
// Listen for resize events which include fullscreen changes
|
||||
unlisten = await win.onResized(async () => {
|
||||
const fs = await win.isFullscreen();
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
}
|
||||
|
||||
setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsTauriMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
left: TAURI_TRAFFIC_LIGHT_WIDTH,
|
||||
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -42,8 +42,9 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const result = { type: "command", command: "pwd", output: "/some/path" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Bash", input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
expect(display.toolName).toBe("Shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("pwd");
|
||||
expect(display.output).toBe("/some/path");
|
||||
@@ -55,8 +56,9 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const result = undefined;
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Bash", input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
expect(display.toolName).toBe("Shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("pwd");
|
||||
expect(display.output).toBe("");
|
||||
@@ -67,19 +69,44 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { command: ["git", "status"] };
|
||||
const result = { type: "command", output: "On branch main" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("shell", input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
expect(display.toolName).toBe("Shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("git status");
|
||||
expect(display.output).toBe("On branch main");
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes tool names - shell to Shell", () => {
|
||||
const input = { command: "pwd" };
|
||||
const display = parseToolCallDisplay("shell", input, undefined);
|
||||
expect(display.toolName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("normalizes tool names - Bash to Shell", () => {
|
||||
const input = { command: "pwd" };
|
||||
const display = parseToolCallDisplay("Bash", input, undefined);
|
||||
expect(display.toolName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("normalizes tool names - read_file to Read", () => {
|
||||
const input = { file_path: "/some/file.txt" };
|
||||
const display = parseToolCallDisplay("read_file", input, undefined);
|
||||
expect(display.toolName).toBe("Read");
|
||||
});
|
||||
|
||||
test("preserves unknown tool names", () => {
|
||||
const input = { some_arg: "value" };
|
||||
const display = parseToolCallDisplay("MyCustomTool", input, undefined);
|
||||
expect(display.toolName).toBe("MyCustomTool");
|
||||
});
|
||||
|
||||
test("parses non-command tool call into generic type", () => {
|
||||
const input = { file_path: "/some/file.txt" };
|
||||
const result = { content: "file contents here", lineCount: 42 };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("SomeTool", input, result);
|
||||
expect(display.type).toBe("generic");
|
||||
if (display.type === "generic") {
|
||||
expect(display.input).toContainEqual({ key: "file_path", value: "/some/file.txt" });
|
||||
@@ -92,12 +119,12 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { file_path: "/some/file.txt", content: "new content" };
|
||||
const result = { type: "file_write", filePath: "/some/file.txt" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Write", input, result);
|
||||
expect(display.type).toBe("generic");
|
||||
});
|
||||
|
||||
test("handles undefined input and result gracefully", () => {
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(undefined, undefined);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("unknown", undefined, undefined);
|
||||
expect(display.type).toBe("generic");
|
||||
if (display.type === "generic") {
|
||||
expect(display.input).toEqual([]);
|
||||
@@ -116,7 +143,7 @@ describe("parseToolCallDisplay", () => {
|
||||
filePath: "/some/file.txt",
|
||||
};
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
@@ -133,7 +160,7 @@ describe("parseToolCallDisplay", () => {
|
||||
};
|
||||
const result = undefined;
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
@@ -149,7 +176,7 @@ describe("parseToolCallDisplay", () => {
|
||||
new_string: "new content",
|
||||
};
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, undefined);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, undefined);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
@@ -158,3 +185,111 @@ describe("parseToolCallDisplay", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay - apply_patch (Codex)", () => {
|
||||
test("parses apply_patch into edit type with unified diff", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
kind: "update",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
patch: "@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**",
|
||||
kind: "update",
|
||||
},
|
||||
],
|
||||
message: "Success. Updated the following files:\nM .tasks/578561c8.md",
|
||||
success: true,
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
expect(display.toolName).toBe("Edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md");
|
||||
expect(display.unifiedDiff).toBe("@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**");
|
||||
expect(display.oldString).toBe("");
|
||||
expect(display.newString).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending apply_patch (no result yet)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/file.txt",
|
||||
kind: "create",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, undefined);
|
||||
expect(display.type).toBe("edit");
|
||||
expect(display.toolName).toBe("Edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
expect(display.unifiedDiff).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("handles apply_patch with multiple files (uses first file)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{ path: "/first/file.txt", kind: "update" },
|
||||
{ path: "/second/file.txt", kind: "create" },
|
||||
],
|
||||
};
|
||||
const result = {
|
||||
files: [
|
||||
{ path: "/first/file.txt", patch: "@@ -1 +1 @@\n-old\n+new", kind: "update" },
|
||||
{ path: "/second/file.txt", patch: "@@ -0,0 +1 @@\n+content", kind: "create" },
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/first/file.txt");
|
||||
expect(display.unifiedDiff).toBe("@@ -1 +1 @@\n-old\n+new");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay - read_file (Codex)", () => {
|
||||
test("parses Codex read_file into read type", () => {
|
||||
const input = {
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
};
|
||||
const result = {
|
||||
type: "read_file",
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
content: "260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`",
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("read_file", input, result);
|
||||
expect(display.type).toBe("read");
|
||||
expect(display.toolName).toBe("Read");
|
||||
if (display.type === "read") {
|
||||
expect(display.filePath).toBe("/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md");
|
||||
expect(display.content).toBe("260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`");
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex read_file falls through to generic when result is missing", () => {
|
||||
const input = {
|
||||
path: "/some/file.txt",
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("read_file", input, undefined);
|
||||
// Without result, it can't match the schema so falls through to generic
|
||||
expect(display.type).toBe("generic");
|
||||
expect(display.toolName).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,7 +270,7 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
|
||||
return diff;
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
export function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
if (!diffText) {
|
||||
return [];
|
||||
}
|
||||
@@ -1023,7 +1023,7 @@ const EditToolCallSchema = z
|
||||
input: EditInputSchema,
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string } => {
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const filePath = data.input.file_path;
|
||||
const oldString = "old_string" in data.input
|
||||
? (data.input as { old_string: string }).old_string
|
||||
@@ -1040,6 +1040,52 @@ const EditToolCallSchema = z
|
||||
};
|
||||
});
|
||||
|
||||
// Codex apply_patch input: { files: [{ path: string, kind: string }] }
|
||||
const ApplyPatchInputSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
kind: z.string().optional(),
|
||||
})).min(1),
|
||||
}).passthrough();
|
||||
|
||||
// Codex apply_patch result: { files: [{ path: string, patch: string, kind: string }], message: string, success: boolean }
|
||||
const ApplyPatchResultSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
patch: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
})).optional(),
|
||||
message: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
// Apply patch tool call display schema - transforms to edit type with unified diff
|
||||
const ApplyPatchToolCallSchema = z
|
||||
.object({
|
||||
input: ApplyPatchInputSchema,
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const firstFile = data.input.files[0];
|
||||
const filePath = firstFile.path;
|
||||
|
||||
// Try to get the patch from the result
|
||||
const resultParsed = ApplyPatchResultSchema.safeParse(data.result);
|
||||
let unifiedDiff: string | undefined;
|
||||
if (resultParsed.success && resultParsed.data.files) {
|
||||
const resultFile = resultParsed.data.files.find(f => f.path === filePath) ?? resultParsed.data.files[0];
|
||||
unifiedDiff = resultFile?.patch;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
unifiedDiff,
|
||||
};
|
||||
});
|
||||
|
||||
// Read input: { file_path: string, offset?: number, limit?: number }
|
||||
const ReadInputSchema = z.object({
|
||||
file_path: z.string(),
|
||||
@@ -1054,7 +1100,7 @@ const ReadResultSchema = z.object({
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Read tool call display schema
|
||||
// Read tool call display schema (Claude)
|
||||
const ReadToolCallSchema = z
|
||||
.object({
|
||||
input: ReadInputSchema,
|
||||
@@ -1068,6 +1114,30 @@ const ReadToolCallSchema = z
|
||||
limit: data.input.limit,
|
||||
}));
|
||||
|
||||
// Codex read_file input: { path: string }
|
||||
const CodexReadInputSchema = z.object({
|
||||
path: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Codex read_file result: { type: "read_file", path: string, content: string }
|
||||
const CodexReadResultSchema = z.object({
|
||||
type: z.literal("read_file"),
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Codex read_file tool call display schema
|
||||
const CodexReadToolCallSchema = z
|
||||
.object({
|
||||
input: CodexReadInputSchema,
|
||||
result: CodexReadResultSchema,
|
||||
})
|
||||
.transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({
|
||||
type: "read",
|
||||
filePath: data.input.path,
|
||||
content: data.result.content,
|
||||
}));
|
||||
|
||||
// Generic tool call display schema (fallback)
|
||||
const GenericToolCallSchema = z
|
||||
.object({
|
||||
@@ -1085,12 +1155,60 @@ const GenericToolCallSchema = z
|
||||
};
|
||||
});
|
||||
|
||||
const ToolCallDisplaySchema = z.union([ShellToolCallSchema, EditToolCallSchema, ReadToolCallSchema, GenericToolCallSchema]);
|
||||
// Normalizes tool names for consistent display across agents
|
||||
const TOOL_NAME_MAP: Record<string, string> = {
|
||||
shell: "Shell",
|
||||
Bash: "Shell",
|
||||
read_file: "Read",
|
||||
apply_patch: "Edit",
|
||||
};
|
||||
|
||||
const ToolCallDisplaySchema = z
|
||||
.object({
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const normalizedToolName = TOOL_NAME_MAP[data.toolName] ?? data.toolName;
|
||||
|
||||
// Try each schema in order
|
||||
const shellParsed = ShellToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (shellParsed.success) {
|
||||
return { ...shellParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
const editParsed = EditToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (editParsed.success) {
|
||||
return { ...editParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
// Codex apply_patch - try before read since it also uses files array
|
||||
const applyPatchParsed = ApplyPatchToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (applyPatchParsed.success) {
|
||||
return { ...applyPatchParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
const readParsed = ReadToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (readParsed.success) {
|
||||
return { ...readParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
// Codex read_file
|
||||
const codexReadParsed = CodexReadToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (codexReadParsed.success) {
|
||||
return { ...codexReadParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
// Fallback to generic
|
||||
const genericParsed = GenericToolCallSchema.parse({ input: data.input, result: data.result });
|
||||
return { ...genericParsed, toolName: normalizedToolName };
|
||||
});
|
||||
|
||||
export type ToolCallDisplay = z.infer<typeof ToolCallDisplaySchema>;
|
||||
|
||||
export function parseToolCallDisplay(input: unknown, result: unknown): ToolCallDisplay {
|
||||
return ToolCallDisplaySchema.parse({ input, result });
|
||||
export function parseToolCallDisplay(toolName: string, input: unknown, result: unknown): ToolCallDisplay {
|
||||
return ToolCallDisplaySchema.parse({ toolName, input, result });
|
||||
}
|
||||
|
||||
// ---- Principal Parameter Extraction ----
|
||||
|
||||
Reference in New Issue
Block a user