fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari

- Changes (#72): a file git reports `MM` whose working copy equals HEAD again
  (a staged change since undone on disk) is no longer listed — JetBrains'
  GitChangesCollector drops it the same way. Mode-only changes stay listed.
  statusCounts (the dirty-tree safety checks) is unchanged.
- Chat (#73): Claude Code runs no hook before a folder is trusted, so a new
  pane's session has no row; the chat now says the session has not started,
  offers Open terminal, and asks before typing a message or /model //effort
  into the pane (an Enter would pick the trust prompt's "No, exit").
- Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on
  each key (insertReplacementText), which xterm.js ignores — only each word's
  first letter reached the pane. The edit is now sent as DELs + the new tail.
- Also: Ctrl+V pastes a screenshot into the terminal as a file path; the
  folder browser fallback lists files for a file field (`files=1`); the macOS
  native picker opens in front of the browser; a hidden tab is marked while a
  chat waits or once a turn ended; chat live line shows running calls, turn
  time and tokens, and "Done in …".
This commit is contained in:
2026-10-12 15:40:47 +07:00
parent 5d8fc9bb98
commit 46e3899945
44 changed files with 1329 additions and 297 deletions
+65 -5
View File
@@ -14,7 +14,7 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped on it are saved on the server
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
@@ -37,7 +37,7 @@
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
@@ -184,11 +184,32 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
[]
);
const onDrop = async (e: DragEvent) => {
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const onDrop = (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(Array.from(e.dataTransfer.files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
// text/plain and would paste nothing. Capture phase, because xterm's own
// paste listener stops the event. A Ctrl+V with no file in it is the pane's
// ^V again (vim's block select, readline's quoted insert), not a text paste.
const onPaste = (e: ClipboardEvent) => {
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0 && !ctrlV) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
if (files.length === 0) return;
setDrop("uploading");
try {
@@ -327,6 +348,18 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
// Ctrl+V: xterm would send ^V, and claude would then read the
// SERVER's clipboard, never this browser's. Let the browser paste
// instead (onPaste); a browser that fires none still sends ^V.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
ctrlVRef.current = true;
setTimeout(() => {
if (!ctrlVRef.current) return;
ctrlVRef.current = false;
term.input("\x16", true);
}, 0);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -486,6 +519,30 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
// after the first letter every key fires `insertReplacementText` carrying
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
// after the change, so all but each word's first letter was lost (xterm.js
// #5887). Send the edit ourselves: a DEL per replaced char, then the tail.
let beforeReplace = "";
const onBeforeReplace = (e: Event) => {
if ((e as InputEvent).inputType === "insertReplacementText") {
beforeReplace = term.textarea?.value ?? "";
}
};
const onReplace = (e: Event) => {
if ((e as InputEvent).inputType !== "insertReplacementText") return;
const before = Array.from(beforeReplace);
const after = Array.from(term.textarea?.value ?? "");
let same = 0;
while (same < before.length && before[same] === after[same]) same++;
const edit = "\x7f".repeat(before.length - same) + after.slice(same).join("");
if (edit) term.input(edit, true);
};
term.textarea?.addEventListener("beforeinput", onBeforeReplace);
term.textarea?.addEventListener("input", onReplace);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
@@ -514,6 +571,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
term.textarea?.removeEventListener("input", onReplace);
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
@@ -634,7 +693,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
onDrop={onDrop}
onPasteCapture={onPaste}
onFocus={() => notePaneFocus(runId)}
>
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />