feat: add Tauri desktop app support and terminal management

This commit is contained in:
Mohamed Boudra
2026-01-20 10:09:35 +07:00
parent 12f617dd10
commit d9c487dd54
125 changed files with 8906 additions and 1049 deletions
+10
View File
@@ -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)\/[^/]+/, "~");
}
+104
View File
@@ -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");
});
});
+124 -6
View File
@@ -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 ----