Skip to content

Commit 8c8dfe1

Browse files
ohernandezdevclaude
andcommitted
feat(terminal): real right-click context menu; claude native image paste
Replaces the old copy-or-paste right-click with a real menu (Copy, Paste, Copy Command Output, Select All, Find, Scroll to Bottom, Clear Screen, Stop Process, Show in File Explorer when the click lands on a detected path, New Tab, Close Tab) — right-click no longer silently pastes the clipboard. Verified live against an isolated test build (separate app identifier, CDP + Playwright) rather than just by reading the code: - Shift+Tab / keyboard-synthesized clicks can no longer open the file preview panel (activate() now requires a real pointer click). - Escape now actually closes the preview: xterm.js intercepts Escape internally and stops its propagation before it reaches app-level listeners while the terminal still has focus, so opening the panel now moves focus onto it (matching the existing confirm-dialog pattern). - The close button and outside-click both release preview state immediately (title/body/path), so a stale file from another tab can't linger behind the next open. - The panel's z-index is raised above every other in-page overlay so its close button can never be covered and unclickable. Also: pasting into a Claude Code session now feeds the raw Ctrl+V keystroke through to the pty when the clipboard holds no text, so Claude Code's own native clipboard-image handling runs (shows "[Image #1]") instead of AFKode substituting a temp-file path. Non-Claude CLIs keep the path-substitution fallback. Version bumped to 0.8.18 per repo convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHgSVKPsaWRfuJMo1rc2AN
1 parent f22bda1 commit 8c8dfe1

8 files changed

Lines changed: 222 additions & 26 deletions

File tree

index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ <h2 id="empty-title"></h2>
135135
</div>
136136
<div class="file-preview-body" id="file-preview-body"></div>
137137
</div>
138+
139+
<div class="term-context-menu hidden" id="term-context-menu"></div>
138140
</main>
139141

140142
<footer class="statusbar">

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "afkode",
33
"private": true,
4-
"version": "0.8.17",
4+
"version": "0.8.18",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "afkode"
3-
version = "0.8.17"
3+
version = "0.8.18"
44
description = "In-game overlay to supervise AI coding agents while you play"
55
authors = ["Omar Hernandez"]
66
license = "MIT"

src-tauri/capabilities/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"permissions": [
77
"core:default",
88
"opener:default",
9+
"opener:allow-reveal-item-in-dir",
910
"core:window:allow-hide",
1011
"core:window:allow-show",
1112
"core:window:allow-set-focus",

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "AFKode",
4-
"version": "0.8.17",
4+
"version": "0.8.18",
55
"identifier": "app.afkode.overlay",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/main.ts

Lines changed: 171 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from "@tauri-apps/plugin-notification";
1313
import { getCurrentWebview } from "@tauri-apps/api/webview";
1414
import { getCurrentWindow } from "@tauri-apps/api/window";
15-
import { openUrl } from "@tauri-apps/plugin-opener";
15+
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
1616
import { marked } from "marked";
1717
import DOMPurify from "dompurify";
1818
import hljs from "highlight.js";
@@ -388,6 +388,15 @@ const I18N: Record<Lang, Record<string, string>> = {
388388
filePreviewCopied: "Copiado al portapapeles",
389389
filePreviewSaved: "Guardado",
390390
filePreviewSaveError: "No se pudo guardar el archivo",
391+
ctxPaste: "Pegar",
392+
ctxSelectAll: "Seleccionar todo",
393+
ctxFind: "Buscar…",
394+
ctxClear: "Limpiar pantalla",
395+
ctxScrollBottom: "Ir al final",
396+
ctxCopyOutput: "Copiar salida del comando",
397+
ctxStopProcess: "Detener proceso (Ctrl+C)",
398+
ctxRevealExplorer: "Mostrar en el explorador de archivos",
399+
ctxCloseTab: "Cerrar pestaña",
391400
closeTabConfirm: "Hay un proceso en ejecución en esta pestaña. ¿Cerrarla de todas formas?",
392401
closeTabConfirmTitle: "Cerrar pestaña",
393402
closeTabConfirmOk: "Cerrar de todas formas",
@@ -529,6 +538,15 @@ const I18N: Record<Lang, Record<string, string>> = {
529538
filePreviewCopied: "Copied to clipboard",
530539
filePreviewSaved: "Saved",
531540
filePreviewSaveError: "Couldn't save the file",
541+
ctxPaste: "Paste",
542+
ctxSelectAll: "Select All",
543+
ctxFind: "Find…",
544+
ctxClear: "Clear Screen",
545+
ctxScrollBottom: "Scroll to Bottom",
546+
ctxCopyOutput: "Copy Command Output",
547+
ctxStopProcess: "Stop Process (Ctrl+C)",
548+
ctxRevealExplorer: "Show in File Explorer",
549+
ctxCloseTab: "Close Tab",
532550
closeTabConfirm: "This tab has a running process. Close it anyway?",
533551
closeTabConfirmTitle: "Close tab",
534552
closeTabConfirmOk: "Close anyway",
@@ -670,6 +688,15 @@ const I18N: Record<Lang, Record<string, string>> = {
670688
filePreviewCopied: "Copié dans le presse-papiers",
671689
filePreviewSaved: "Enregistré",
672690
filePreviewSaveError: "Impossible d'enregistrer le fichier",
691+
ctxPaste: "Coller",
692+
ctxSelectAll: "Tout sélectionner",
693+
ctxFind: "Rechercher…",
694+
ctxClear: "Effacer l'écran",
695+
ctxScrollBottom: "Aller en bas",
696+
ctxCopyOutput: "Copier la sortie de la commande",
697+
ctxStopProcess: "Arrêter le processus (Ctrl+C)",
698+
ctxRevealExplorer: "Afficher dans l'explorateur de fichiers",
699+
ctxCloseTab: "Fermer l'onglet",
673700
closeTabConfirm: "Un processus est en cours d'exécution dans cet onglet. Le fermer quand même ?",
674701
closeTabConfirmTitle: "Fermer l'onglet",
675702
closeTabConfirmOk: "Fermer quand même",
@@ -811,6 +838,15 @@ const I18N: Record<Lang, Record<string, string>> = {
811838
filePreviewCopied: "Copiato negli appunti",
812839
filePreviewSaved: "Salvato",
813840
filePreviewSaveError: "Impossibile salvare il file",
841+
ctxPaste: "Incolla",
842+
ctxSelectAll: "Seleziona tutto",
843+
ctxFind: "Trova…",
844+
ctxClear: "Pulisci schermo",
845+
ctxScrollBottom: "Vai in fondo",
846+
ctxCopyOutput: "Copia output del comando",
847+
ctxStopProcess: "Interrompi processo (Ctrl+C)",
848+
ctxRevealExplorer: "Mostra in Esplora file",
849+
ctxCloseTab: "Chiudi scheda",
814850
closeTabConfirm: "In questa scheda è in esecuzione un processo. Chiuderla comunque?",
815851
closeTabConfirmTitle: "Chiudi scheda",
816852
closeTabConfirmOk: "Chiudi comunque",
@@ -1595,12 +1631,50 @@ async function newSession(
15951631
pane.addEventListener("contextmenu", (ev) => {
15961632
ev.preventDefault();
15971633
const sel = term.getSelection();
1598-
if (sel) {
1599-
clipWrite(sel).catch(() => {});
1600-
term.clearSelection();
1601-
} else {
1602-
pasteFromClipboard();
1603-
}
1634+
const rowText = rowTextAtPoint(pane, ev.clientY);
1635+
const linkMatch = rowText ? FILE_LINK_RE.exec(rowText) : null;
1636+
1637+
openTermContextMenu(
1638+
[
1639+
{
1640+
label: t("copy"),
1641+
disabled: !sel,
1642+
onClick: () => {
1643+
if (sel) clipWrite(sel).catch(() => {});
1644+
term.clearSelection();
1645+
},
1646+
},
1647+
{ label: t("ctxPaste"), onClick: () => pasteFromClipboard() },
1648+
sep,
1649+
{
1650+
label: t("ctxCopyOutput"),
1651+
hidden: !blocks.active(),
1652+
onClick: () => blocks.copySelectedOutput(),
1653+
},
1654+
{ label: t("ctxSelectAll"), onClick: () => term.selectAll() },
1655+
{ label: t("ctxFind"), onClick: () => openSearch() },
1656+
{ label: t("ctxScrollBottom"), onClick: () => term.scrollToBottom() },
1657+
{ label: t("ctxClear"), onClick: () => term.clear() },
1658+
sep,
1659+
{
1660+
label: t("ctxStopProcess"),
1661+
hidden: !session.alive,
1662+
onClick: () => invoke("write_pty", { id, data: "\x03" }).catch(() => {}),
1663+
},
1664+
{
1665+
label: t("ctxRevealExplorer"),
1666+
hidden: !linkMatch,
1667+
onClick: () => {
1668+
if (linkMatch) revealItemInDir(resolveFilePath(linkMatch[0], cwd)).catch(() => {});
1669+
},
1670+
},
1671+
sep,
1672+
{ label: t("tooltipNew"), onClick: () => $("#btn-new-tab").click() },
1673+
{ label: t("ctxCloseTab"), onClick: () => closeSession(id) },
1674+
],
1675+
ev.clientX,
1676+
ev.clientY,
1677+
);
16041678
});
16051679

16061680
const session: Session = {
@@ -2930,6 +3004,66 @@ document.addEventListener("click", (e) => {
29303004
if (!tabColorMenu.contains(e.target as Node)) tabColorMenu.classList.add("hidden");
29313005
});
29323006

3007+
// ── Terminal right-click menu ───────────────────────────────
3008+
3009+
// WebGL rendering draws rows to canvas, leaving no per-character DOM to hit
3010+
// -test — but xterm's screenReaderMode keeps one accessibility row div per
3011+
// line, positioned to match the real rendered row, which doubles as a free
3012+
// row-text-at-a-point lookup.
3013+
function rowTextAtPoint(pane: HTMLElement, y: number): string {
3014+
const rows = pane.querySelectorAll(".xterm-accessibility-tree > *");
3015+
for (const row of rows) {
3016+
const r = row.getBoundingClientRect();
3017+
if (y >= r.top && y < r.bottom) return row.textContent ?? "";
3018+
}
3019+
return "";
3020+
}
3021+
3022+
type CtxItem =
3023+
| "sep"
3024+
| { label: string; onClick: () => void; disabled?: boolean; hidden?: boolean };
3025+
const sep: CtxItem = "sep";
3026+
3027+
const termContextMenu = $("#term-context-menu");
3028+
3029+
function openTermContextMenu(items: CtxItem[], x: number, y: number) {
3030+
termContextMenu.replaceChildren();
3031+
let pendingSep = false;
3032+
for (const item of items) {
3033+
if (item === "sep") {
3034+
pendingSep = termContextMenu.childElementCount > 0;
3035+
continue;
3036+
}
3037+
if (item.hidden) continue;
3038+
if (pendingSep) {
3039+
termContextMenu.appendChild(document.createElement("div")).className = "term-context-sep";
3040+
pendingSep = false;
3041+
}
3042+
const btn = document.createElement("button");
3043+
btn.className = "term-context-item";
3044+
btn.textContent = item.label;
3045+
btn.disabled = !!item.disabled;
3046+
btn.addEventListener("click", () => {
3047+
termContextMenu.classList.add("hidden");
3048+
item.onClick();
3049+
});
3050+
termContextMenu.appendChild(btn);
3051+
}
3052+
// Clamp so a right-click near the window edge doesn't open off-screen.
3053+
termContextMenu.classList.remove("hidden");
3054+
const menuRect = termContextMenu.getBoundingClientRect();
3055+
const left = Math.min(x, window.innerWidth - menuRect.width - 8);
3056+
const top = Math.min(y, window.innerHeight - menuRect.height - 8);
3057+
termContextMenu.style.left = `${Math.max(8, left)}px`;
3058+
termContextMenu.style.top = `${Math.max(8, top)}px`;
3059+
}
3060+
document.addEventListener("click", (e) => {
3061+
if (!termContextMenu.contains(e.target as Node)) termContextMenu.classList.add("hidden");
3062+
});
3063+
document.addEventListener("keydown", (e) => {
3064+
if (e.key === "Escape") termContextMenu.classList.add("hidden");
3065+
});
3066+
29333067
$("#btn-ghost").addEventListener("click", () =>
29343068
invoke("set_ghost_mode", { enabled: !overlayEl.classList.contains("ghost") }),
29353069
);
@@ -3175,6 +3309,29 @@ const FILE_LINK_RE = new RegExp(
31753309
"i",
31763310
);
31773311

3312+
// POSIX absolute paths start with "/" — only meaningful off Windows, where a
3313+
// leading "/" in agent output is instead likely a relative separator
3314+
// artifact and the drive-letter/UNC/~ checks do the work.
3315+
function isAbsolutePath(raw: string): boolean {
3316+
const win = platform.os === "windows";
3317+
return (
3318+
/^[A-Za-z]:[\\/]/.test(raw) ||
3319+
raw.startsWith("\\\\") ||
3320+
raw.startsWith("~") ||
3321+
(!win && raw.startsWith("/"))
3322+
);
3323+
}
3324+
3325+
// Resolves a bare/relative-looking path matched from terminal text against
3326+
// the session's cwd — shared by the file-preview panel and the terminal's
3327+
// right-click "Show in File Explorer" action.
3328+
function resolveFilePath(raw: string, cwd: string | null): string {
3329+
if (isAbsolutePath(raw)) return raw;
3330+
return platform.os === "windows"
3331+
? `${cwd ?? "."}\\${raw}`.replace(/\//g, "\\")
3332+
: `${cwd ?? "."}/${raw}`;
3333+
}
3334+
31783335
// WebLinksAddon rejects any match that doesn't round-trip through `new
31793336
// URL()`, which throws for a plain file path — it's built strictly for
31803337
// http(s) links even when given a custom regex. A hand-rolled link
@@ -3371,20 +3528,7 @@ function setPreviewEditing(editing: boolean) {
33713528
}
33723529

33733530
async function openFilePreview(raw: string, cwd: string | null) {
3374-
// POSIX absolute paths start with "/" — only meaningful off Windows,
3375-
// where a leading "/" in agent output is instead likely a relative
3376-
// separator artifact and the drive-letter/UNC/~ checks do the work.
3377-
const win = platform.os === "windows";
3378-
const isAbsolute =
3379-
/^[A-Za-z]:[\\/]/.test(raw) ||
3380-
raw.startsWith("\\\\") ||
3381-
raw.startsWith("~") ||
3382-
(!win && raw.startsWith("/"));
3383-
const path = isAbsolute
3384-
? raw
3385-
: win
3386-
? `${cwd ?? "."}\\${raw}`.replace(/\//g, "\\")
3387-
: `${cwd ?? "."}/${raw}`;
3531+
const path = resolveFilePath(raw, cwd);
33883532
disposeModelPreview();
33893533
previewPath = path;
33903534
previewText = null;
@@ -3394,6 +3538,11 @@ async function openFilePreview(raw: string, cwd: string | null) {
33943538
filePreviewBody.className = "file-preview-body plain";
33953539
filePreviewBody.textContent = "…";
33963540
filePreviewModal.classList.add("open");
3541+
// Move focus off the terminal and onto the panel: xterm's own keydown
3542+
// handler treats Escape as a key it must own (cancels the browser event
3543+
// unconditionally), which stops it from ever reaching our document-level
3544+
// Escape handler while the terminal textarea still has focus.
3545+
$("#file-preview-close").focus();
33973546
ignoreNextOutsideClick = true;
33983547
setTimeout(() => (ignoreNextOutsideClick = false), 0);
33993548
const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1].toLowerCase();
@@ -3450,7 +3599,7 @@ async function openFilePreview(raw: string, cwd: string | null) {
34503599
// A bare name with no path separators (e.g. from a bullet list) was
34513600
// guessed relative to the session folder — say so instead of just
34523601
// surfacing a raw "file not found", since the real folder is unknown.
3453-
const bareName = !isAbsolute && !/[\\/]/.test(raw);
3602+
const bareName = !isAbsolutePath(raw) && !/[\\/]/.test(raw);
34543603
filePreviewBody.textContent = bareName
34553604
? `${t("filePreviewNotFoundBare")}\n\n"${raw}" — ${t("filePreviewTriedIn")} ${path}`
34563605
: `${t("filePreviewError")}: ${path}\n${err}`;

src/styles.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,50 @@ kbd {
16591659
color: var(--text-bright);
16601660
}
16611661

1662+
.term-context-menu {
1663+
position: fixed;
1664+
z-index: 90;
1665+
width: 230px;
1666+
padding: 4px;
1667+
background: rgb(var(--bar-rgb));
1668+
border: 1px solid rgba(255, 255, 255, 0.1);
1669+
border-radius: 9px;
1670+
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
1671+
}
1672+
.term-context-item {
1673+
display: flex;
1674+
align-items: center;
1675+
justify-content: space-between;
1676+
gap: 12px;
1677+
width: 100%;
1678+
padding: 7px 9px;
1679+
border: 0;
1680+
border-radius: 6px;
1681+
background: transparent;
1682+
color: var(--text);
1683+
font-size: 12px;
1684+
font-family: inherit;
1685+
text-align: left;
1686+
cursor: pointer;
1687+
}
1688+
.term-context-item:hover:not(:disabled) {
1689+
background: rgba(255, 255, 255, 0.07);
1690+
}
1691+
.term-context-item:disabled {
1692+
color: var(--text-dim);
1693+
cursor: default;
1694+
}
1695+
.term-context-item kbd {
1696+
font-size: 10.5px;
1697+
opacity: 0.55;
1698+
font-family: inherit;
1699+
}
1700+
.term-context-sep {
1701+
height: 1px;
1702+
margin: 4px 6px;
1703+
background: rgba(255, 255, 255, 0.08);
1704+
}
1705+
16621706
.set-row select {
16631707
min-width: 170px;
16641708
padding: 6px 9px;

0 commit comments

Comments
 (0)