2026-02-27 04:29:51 +00:00
|
|
|
import { useCallback } from "react";
|
2026-02-28 20:42:40 +00:00
|
|
|
import { useShallow } from "zustand/react/shallow";
|
2026-02-27 04:29:51 +00:00
|
|
|
import { listen } from "@tauri-apps/api/event";
|
|
|
|
|
import { useAppState } from "../store/appState";
|
|
|
|
|
import * as commands from "../lib/tauri-commands";
|
|
|
|
|
|
2026-08-28 12:51:18 -07:00
|
|
|
/**
|
|
|
|
|
* Per-session ordered write queue.
|
|
|
|
|
*
|
|
|
|
|
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
|
|
|
|
* that command is `async` on the Rust side Tauri spawns each one as an
|
|
|
|
|
* independent task. Those tasks then race for the session mutex in
|
|
|
|
|
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
|
|
|
|
* were typed in — the visible symptom was a backspace landing *after* the
|
|
|
|
|
* characters typed behind it. The serial writer task downstream cannot help,
|
|
|
|
|
* because the order is already lost by the time anything reaches the channel.
|
|
|
|
|
*
|
|
|
|
|
* The queue restores ordering the same way the web terminal gets it for free:
|
|
|
|
|
* one write in flight at a time, the next only after the previous resolves.
|
|
|
|
|
* Anything typed while a write is in flight coalesces into the next chunk,
|
|
|
|
|
* which also collapses a burst of typing into a couple of IPC round trips
|
|
|
|
|
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
|
|
|
|
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
|
|
|
|
* caller's promise still settles only when its own bytes have gone, so
|
|
|
|
|
* `await sendInput(...)` keeps the meaning it had.
|
|
|
|
|
*
|
|
|
|
|
* Module scope, not hook scope, because `useTerminal()` is called from several
|
|
|
|
|
* components (App for speech-to-text, TerminalView for typing and image paste,
|
|
|
|
|
* useProjectActions for tile commands). A per-hook queue would give each caller
|
|
|
|
|
* its own ordering and leave them racing against each other.
|
|
|
|
|
*/
|
|
|
|
|
type PendingWrite = {
|
|
|
|
|
bytes: number[];
|
|
|
|
|
resolve: () => void;
|
|
|
|
|
reject: (reason: unknown) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
|
|
|
|
|
|
|
|
|
async function drainInputQueue(sessionId: string): Promise<void> {
|
|
|
|
|
const q = inputQueues.get(sessionId);
|
|
|
|
|
if (!q || q.draining) return;
|
|
|
|
|
|
|
|
|
|
q.draining = true;
|
|
|
|
|
try {
|
|
|
|
|
while (q.pending.length > 0) {
|
|
|
|
|
// Take everything queued so far as one batch, preserving order.
|
|
|
|
|
const batch = q.pending.splice(0, q.pending.length);
|
|
|
|
|
const bytes = batch.flatMap((w) => w.bytes);
|
|
|
|
|
try {
|
|
|
|
|
await commands.terminalInput(sessionId, bytes);
|
|
|
|
|
batch.forEach((w) => w.resolve());
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// Reject only the writes in this batch. Anything queued while it was
|
|
|
|
|
// in flight is still pending and gets its own attempt on the next lap.
|
|
|
|
|
batch.forEach((w) => w.reject(err));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
q.draining = false;
|
|
|
|
|
// Drop the entry once idle so closed sessions do not accumulate.
|
|
|
|
|
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
|
|
let q = inputQueues.get(sessionId);
|
|
|
|
|
if (!q) {
|
|
|
|
|
q = { pending: [], draining: false };
|
|
|
|
|
inputQueues.set(sessionId, q);
|
|
|
|
|
}
|
|
|
|
|
q.pending.push({ bytes, resolve, reject });
|
|
|
|
|
void drainInputQueue(sessionId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Drop any queued input for a session that is going away. */
|
|
|
|
|
function discardInputQueue(sessionId: string): void {
|
|
|
|
|
const q = inputQueues.get(sessionId);
|
|
|
|
|
if (!q) return;
|
|
|
|
|
const dropped = q.pending.splice(0, q.pending.length);
|
|
|
|
|
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
|
|
|
|
if (!q.draining) inputQueues.delete(sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
export function useTerminal() {
|
|
|
|
|
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
2026-02-28 20:42:40 +00:00
|
|
|
useAppState(
|
|
|
|
|
useShallow(s => ({
|
|
|
|
|
sessions: s.sessions,
|
|
|
|
|
activeSessionId: s.activeSessionId,
|
|
|
|
|
addSession: s.addSession,
|
|
|
|
|
removeSession: s.removeSession,
|
|
|
|
|
setActiveSession: s.setActiveSession,
|
|
|
|
|
}))
|
|
|
|
|
);
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
|
|
|
const open = useCallback(
|
2026-04-16 08:46:03 -07:00
|
|
|
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
|
2026-02-27 04:29:51 +00:00
|
|
|
const sessionId = crypto.randomUUID();
|
2026-04-16 08:46:03 -07:00
|
|
|
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
|
|
|
|
|
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
|
2026-02-27 04:29:51 +00:00
|
|
|
return sessionId;
|
|
|
|
|
},
|
|
|
|
|
[addSession],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const close = useCallback(
|
|
|
|
|
async (sessionId: string) => {
|
2026-05-24 08:50:48 -07:00
|
|
|
// Capture session/project info before we drop it from local state.
|
|
|
|
|
const { sessions: currentSessions, projects } = useAppState.getState();
|
|
|
|
|
const session = currentSessions.find((s) => s.id === sessionId);
|
|
|
|
|
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
|
|
|
|
|
2026-08-28 12:51:18 -07:00
|
|
|
discardInputQueue(sessionId);
|
2026-02-27 04:29:51 +00:00
|
|
|
await commands.closeTerminalSession(sessionId);
|
|
|
|
|
removeSession(sessionId);
|
2026-05-24 08:50:48 -07:00
|
|
|
|
|
|
|
|
// Drop any persisted custom name for this session.
|
|
|
|
|
if (project && project.renamed_session_names && sessionId in project.renamed_session_names) {
|
|
|
|
|
const map = { ...project.renamed_session_names };
|
|
|
|
|
delete map[sessionId];
|
|
|
|
|
try {
|
|
|
|
|
const updated = await commands.updateProject({ ...project, renamed_session_names: map });
|
|
|
|
|
useAppState.getState().updateProjectInList(updated);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to clear renamed tab name on close:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
},
|
|
|
|
|
[removeSession],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const sendInput = useCallback(
|
|
|
|
|
async (sessionId: string, data: string) => {
|
|
|
|
|
const bytes = Array.from(new TextEncoder().encode(data));
|
2026-08-28 12:51:18 -07:00
|
|
|
await enqueueInput(sessionId, bytes);
|
2026-02-27 04:29:51 +00:00
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const resize = useCallback(
|
|
|
|
|
async (sessionId: string, cols: number, rows: number) => {
|
|
|
|
|
await commands.terminalResize(sessionId, cols, rows);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-01 10:52:08 -08:00
|
|
|
const pasteImage = useCallback(
|
|
|
|
|
async (sessionId: string, imageData: Uint8Array) => {
|
|
|
|
|
const bytes = Array.from(imageData);
|
|
|
|
|
return commands.pasteImageToTerminal(sessionId, bytes);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
const onOutput = useCallback(
|
|
|
|
|
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
|
|
|
|
const eventName = `terminal-output-${sessionId}`;
|
|
|
|
|
return listen<number[]>(eventName, (event) => {
|
|
|
|
|
callback(new Uint8Array(event.payload));
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onExit = useCallback(
|
|
|
|
|
(sessionId: string, callback: () => void) => {
|
|
|
|
|
const eventName = `terminal-exit-${sessionId}`;
|
|
|
|
|
return listen<void>(eventName, () => {
|
|
|
|
|
callback();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sessions,
|
|
|
|
|
activeSessionId,
|
|
|
|
|
setActiveSession,
|
|
|
|
|
open,
|
|
|
|
|
close,
|
|
|
|
|
sendInput,
|
2026-03-01 10:52:08 -08:00
|
|
|
pasteImage,
|
2026-02-27 04:29:51 +00:00
|
|
|
resize,
|
|
|
|
|
onOutput,
|
|
|
|
|
onExit,
|
|
|
|
|
};
|
|
|
|
|
}
|