merge v1.10.4-booterm-mobile
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
||||||
|
|
||||||
|
Plus `apps/booterm` (second container, port 9501, bookworm-slim+glibc): Fastify + node-pty + tmux. Browser terminal panes WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. Shells drop privs to samkintop via `gosu` in `tmux.conf` default-command.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -35,7 +37,7 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite).
|
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), and `apps/booterm` (Fastify + node-pty + tmux).
|
||||||
|
|
||||||
### Server (`apps/server/src/`)
|
### Server (`apps/server/src/`)
|
||||||
|
|
||||||
@@ -99,6 +101,10 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
|
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||||
|
- node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed.
|
||||||
|
- pnpm 10 `--frozen-lockfile` skips node-pty's postinstall — the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile.
|
||||||
|
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -109,3 +115,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||||
|
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||||
|
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||||
|
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||||
|
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
|
"xterm-addon-search": "^0.13.0",
|
||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { AgentPicker } from '@/components/AgentPicker';
|
|||||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { chatInputsRegistry, sendToChat } from '@/lib/events';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
@@ -51,9 +52,16 @@ interface Props {
|
|||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
// disables slash-command dispatch (input is sent as literal text).
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||||
|
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||||
|
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||||
|
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||||
|
// an event appends the text to the current draft (with a newline separator
|
||||||
|
// when non-empty) and focuses — no auto-send.
|
||||||
|
chatId?: string;
|
||||||
|
chatLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -107,6 +115,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// v1.10.4: register this input in the chat-input registry so the terminal
|
||||||
|
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
|
||||||
|
// changes (e.g. rename) so the menu reflects the current name.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, [chatId, chatLabel]);
|
||||||
|
|
||||||
|
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
|
||||||
|
// payload text to the current draft (with a newline separator if the
|
||||||
|
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
return sendToChat.subscribe(({ chat_id, text }) => {
|
||||||
|
if (chat_id !== chatId) return;
|
||||||
|
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
ta.focus();
|
||||||
|
// Put caret at end so the user can keep typing immediately.
|
||||||
|
const end = ta.value.length;
|
||||||
|
ta.selectionStart = ta.selectionEnd = end;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
function removeAttachment(id: string) {
|
function removeAttachment(id: string) {
|
||||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -31,6 +31,15 @@ interface Props {
|
|||||||
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec
|
||||||
|
// (80px). Vertical bail-out at 30px because the pill sits inside a vertical
|
||||||
|
// scrollable header — diagonal-ish swipes shouldn't accidentally close panes.
|
||||||
|
const SWIPE_CLOSE_PX = 80;
|
||||||
|
const SWIPE_VERTICAL_BAIL_PX = 30;
|
||||||
|
// Visual cap: pill translates left up to this much. Past this, dragX stays
|
||||||
|
// pinned so the user has a clear "release to close" indicator.
|
||||||
|
const SWIPE_VISUAL_CAP = 120;
|
||||||
|
|
||||||
function paneIcon(kind: WorkspacePane['kind']) {
|
function paneIcon(kind: WorkspacePane['kind']) {
|
||||||
if (kind === 'terminal') return <Terminal size={14} />;
|
if (kind === 'terminal') return <Terminal size={14} />;
|
||||||
if (kind === 'agent') return <Bot size={14} />;
|
if (kind === 'agent') return <Bot size={14} />;
|
||||||
@@ -70,11 +79,66 @@ export function MobileTabSwitcher({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
// v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset
|
||||||
|
// in px. suppressClick latches when a swipe completes so the trailing click
|
||||||
|
// doesn't pop open the BottomSheet on the just-closed pane.
|
||||||
|
const [dragX, setDragX] = useState(0);
|
||||||
|
const swipeStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const swipeBailed = useRef(false);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
const active = panes[activePaneIdx];
|
const active = panes[activePaneIdx];
|
||||||
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
|
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
|
||||||
const activeChatId = paneActiveChatId(active);
|
const activeChatId = paneActiveChatId(active);
|
||||||
|
|
||||||
|
function onPillTouchStart(e: React.TouchEvent<HTMLDivElement>): void {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
swipeStart.current = { x: t.clientX, y: t.clientY };
|
||||||
|
swipeBailed.current = false;
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
function onPillTouchMove(e: React.TouchEvent<HTMLDivElement>): void {
|
||||||
|
if (!swipeStart.current || swipeBailed.current) return;
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
const dx = t.clientX - swipeStart.current.x;
|
||||||
|
const dy = t.clientY - swipeStart.current.y;
|
||||||
|
// Bail to scroll if vertical motion dominates before horizontal.
|
||||||
|
if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) {
|
||||||
|
swipeBailed.current = true;
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only allow leftward drag (negative). Cap visual displacement.
|
||||||
|
const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx));
|
||||||
|
setDragX(clamped);
|
||||||
|
}
|
||||||
|
function onPillTouchEnd(): void {
|
||||||
|
const finalDx = dragX;
|
||||||
|
swipeStart.current = null;
|
||||||
|
if (swipeBailed.current) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) {
|
||||||
|
suppressClick.current = true;
|
||||||
|
// Reset dragX after the close so subsequent re-renders look right.
|
||||||
|
setDragX(0);
|
||||||
|
onRemovePane(activePaneIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
function onPillClick(): void {
|
||||||
|
if (suppressClick.current) {
|
||||||
|
suppressClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX);
|
||||||
|
|
||||||
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
|
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
|
||||||
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
|
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
|
||||||
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
||||||
@@ -113,17 +177,39 @@ export function MobileTabSwitcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
className="flex-1 relative min-w-0"
|
||||||
|
onTouchStart={onPillTouchStart}
|
||||||
|
onTouchMove={onPillTouchMove}
|
||||||
|
onTouchEnd={onPillTouchEnd}
|
||||||
|
onTouchCancel={onPillTouchEnd}
|
||||||
|
>
|
||||||
|
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the
|
||||||
|
swipe progress (0 at rest, 1 at the close threshold). aria-hidden
|
||||||
|
because the actionable affordance is the swipe, not this label. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 flex items-center justify-end pr-4 rounded-full bg-destructive/80 text-destructive-foreground text-xs font-medium"
|
||||||
|
style={{ opacity: swipeProgress, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={onPillClick}
|
||||||
className="flex-1 inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0"
|
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
|
||||||
aria-label="Switch pane"
|
aria-label="Switch pane"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${dragX}px)`,
|
||||||
|
transition: dragX === 0 ? 'transform 180ms ease-out' : 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
|
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
|
||||||
<StatusDot chatId={activeChatId} />
|
<StatusDot chatId={activeChatId} />
|
||||||
<span className="truncate flex-1 text-left">{activeLabel}</span>
|
<span className="truncate flex-1 text-left">{activeLabel}</span>
|
||||||
<ChevronDown size={14} className="opacity-60 shrink-0" />
|
<ChevronDown size={14} className="opacity-60 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
|
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
|
||||||
<ul className="px-2 py-2 space-y-1">
|
<ul className="px-2 py-2 space-y-1">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, X } from 'lucide-react';
|
||||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
import { terminalsRegistry } from '@/lib/events';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
import { TerminalPane } from '@/components/panes/TerminalPane';
|
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||||
@@ -238,6 +239,23 @@ export function Workspace({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
|
||||||
|
outside direct user gestures. A real button click IS a
|
||||||
|
gesture, so this works where keystroke-driven paste may
|
||||||
|
not on iOS. The action lives in TerminalPane behind the
|
||||||
|
registry's paste() callback. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
terminalsRegistry.get(pane.id)?.paste();
|
||||||
|
}}
|
||||||
|
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
|
aria-label="Paste from clipboard"
|
||||||
|
title="Paste from clipboard"
|
||||||
|
>
|
||||||
|
<Clipboard size={12} />
|
||||||
|
</button>
|
||||||
{panes.length > 1 && (
|
{panes.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -245,7 +263,7 @@ export function Workspace({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removePane(idx);
|
removePane(idx);
|
||||||
}}
|
}}
|
||||||
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
aria-label="Close terminal pane"
|
aria-label="Close terminal pane"
|
||||||
title="Close terminal pane"
|
title="Close terminal pane"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
|
chatId={chatId}
|
||||||
|
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Terminal } from 'xterm';
|
import { Terminal } from 'xterm';
|
||||||
import { FitAddon } from 'xterm-addon-fit';
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import { SearchAddon } from 'xterm-addon-search';
|
||||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { ChevronDown, ChevronUp, RefreshCw, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
|
import {
|
||||||
|
chatInputsRegistry,
|
||||||
|
sendToChat,
|
||||||
|
sendToTerminal,
|
||||||
|
terminalsRegistry,
|
||||||
|
type ChatInputRegistration,
|
||||||
|
} from '@/lib/events';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -54,29 +61,103 @@ const XTERM_THEME = {
|
|||||||
const XTERM_STYLE_OVERRIDES = `
|
const XTERM_STYLE_OVERRIDES = `
|
||||||
.xterm { width: 100% !important; height: 100% !important; }
|
.xterm { width: 100% !important; height: 100% !important; }
|
||||||
.xterm .xterm-screen { width: 100% !important; }
|
.xterm .xterm-screen { width: 100% !important; }
|
||||||
|
/* v1.10.4 gap fix: hide overflow (was: auto) to eliminate scrollbar gutter
|
||||||
|
* that FitAddon's proposeDimensions still accounts for. Transparent bg lets
|
||||||
|
* the host's TERM_BG show through any sub-cell rounding strip. */
|
||||||
.xterm .xterm-viewport {
|
.xterm .xterm-viewport {
|
||||||
overflow-y: auto;
|
overflow-y: hidden !important;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none !important;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
.xterm .xterm-viewport::-webkit-scrollbar { width: 0 !important; height: 0 !important; display: none !important; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
|
type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
|
||||||
|
|
||||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||||
|
// v1.10.4: long-press timing for touch-driven selection. 500ms is the common
|
||||||
|
// "long-press" threshold; 10px is the dead-zone before we treat the gesture
|
||||||
|
// as a scroll/swipe instead.
|
||||||
|
const LONG_PRESS_MS = 500;
|
||||||
|
const LONG_PRESS_TOLERANCE_PX = 10;
|
||||||
|
// xterm 5 ships no public dimensions API — `_core._renderService.dimensions`
|
||||||
|
// is internal. We try it first and fall back to (container px / term.cols).
|
||||||
|
// The fallback overcounts because xterm reserves the right edge for the
|
||||||
|
// scrollbar (hidden, but the cells still respect the reserved px).
|
||||||
|
function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const dims = (term as any)._core?._renderService?.dimensions?.css?.cell;
|
||||||
|
if (dims && dims.width > 0 && dims.height > 0) {
|
||||||
|
return { w: dims.width, h: dims.height };
|
||||||
|
}
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer → buffer cell. Returns { col, bufferRow } where bufferRow is the
|
||||||
|
// absolute row in the scrollback buffer (i.e. viewportY-offset applied), so
|
||||||
|
// the result is stable across scroll. Clamped to valid ranges.
|
||||||
|
function pointToCell(
|
||||||
|
term: Terminal,
|
||||||
|
container: HTMLElement,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): { col: number; bufferRow: number } {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const { w, h } = cellSize(term, container);
|
||||||
|
const localX = Math.max(0, clientX - rect.left);
|
||||||
|
const localY = Math.max(0, clientY - rect.top);
|
||||||
|
const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
|
||||||
|
const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
|
||||||
|
const bufferRow = term.buffer.active.viewportY + screenRow;
|
||||||
|
return { col, bufferRow };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word boundary on a buffer line. Letters/digits/_/-/./~/$ count as word
|
||||||
|
// chars (path-friendly); everything else is a separator. matchAll keeps the
|
||||||
|
// scan purely iterator-based — no manual cursor needed.
|
||||||
|
const WORD_RE = /[\w.~$\-/]+/g;
|
||||||
|
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
|
||||||
|
for (const m of line.matchAll(WORD_RE)) {
|
||||||
|
const start = m.index ?? 0;
|
||||||
|
const end = start + m[0].length;
|
||||||
|
if (col >= start && col < end) return { start, end };
|
||||||
|
if (start > col) return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
|
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
|
const searchRef = useRef<SearchAddon | null>(null);
|
||||||
const reconnectRef = useRef<() => void>(() => {});
|
const reconnectRef = useRef<() => void>(() => {});
|
||||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||||
|
// v1.10.4: floating menu state. Positioned in client coords.
|
||||||
|
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
// Forces the floating menu's chat list to re-read chatInputsRegistry when
|
||||||
|
// it's actually opened — keeps the registry-subscription tick local.
|
||||||
|
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
|
||||||
|
// Refs over state so the long-living useEffect can call the latest setters
|
||||||
|
// without re-running the effect (which would tear down xterm + WS).
|
||||||
|
const setMenuRef = useRef(setMenu);
|
||||||
|
setMenuRef.current = setMenu;
|
||||||
|
const setSearchOpenRef = useRef(setSearchOpen);
|
||||||
|
setSearchOpenRef.current = setSearchOpen;
|
||||||
|
const setChatInputsRef = useRef(setChatInputs);
|
||||||
|
setChatInputsRef.current = setChatInputs;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
// TS doesn't preserve the null-narrowing across nested function bodies
|
||||||
|
// below (onTouchStart, etc.) because container is a closure capture. Bind
|
||||||
|
// a narrowed-type local that the inner closures can reference directly.
|
||||||
|
const ctr: HTMLDivElement = container;
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -97,7 +178,10 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
fitRef.current = fit;
|
fitRef.current = fit;
|
||||||
|
const search = new SearchAddon();
|
||||||
|
searchRef.current = search;
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
|
term.loadAddon(search);
|
||||||
term.loadAddon(new WebLinksAddon());
|
term.loadAddon(new WebLinksAddon());
|
||||||
term.open(container);
|
term.open(container);
|
||||||
try {
|
try {
|
||||||
@@ -125,17 +209,38 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared paste path used by Cmd-V handler, the floating menu's "Paste"
|
||||||
|
// item, and the pane-header Paste button (via terminalsRegistry).
|
||||||
|
function pasteFromClipboard(): void {
|
||||||
|
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
|
||||||
|
toast.error('Paste blocked — long-press input area instead');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => {
|
||||||
|
if (!text) return;
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Paste blocked — long-press input area instead');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// v1.10.3 copy/paste: xterm v5 ships no clipboard addon — bind manually.
|
// v1.10.3 copy/paste: xterm v5 ships no clipboard addon — bind manually.
|
||||||
// Cmd/Ctrl-C + selection → copy, swallow keystroke (no ^C)
|
// Cmd/Ctrl-C + selection → copy, swallow keystroke (no ^C)
|
||||||
// Cmd/Ctrl-C, no selection → fall through (xterm sends SIGINT)
|
// Cmd/Ctrl-C, no selection → fall through (xterm sends SIGINT)
|
||||||
// Cmd/Ctrl-Shift-C → ALWAYS swallow; copy if selection, no-op otherwise
|
// Cmd/Ctrl-Shift-C → ALWAYS swallow; copy if selection, no-op otherwise
|
||||||
// Cmd/Ctrl-V / Cmd/Ctrl-Shift-V → paste from clipboard, swallow
|
// Cmd/Ctrl-V / Cmd/Ctrl-Shift-V → paste from clipboard, swallow
|
||||||
|
// v1.10.4: Cmd/Ctrl-F → open search bar over terminal, swallow
|
||||||
term.attachCustomKeyEventHandler((e) => {
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
if (e.type !== 'keydown') return true;
|
if (e.type !== 'keydown') return true;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
if (!mod) return true;
|
if (!mod) return true;
|
||||||
const isC = e.key === 'c' || e.key === 'C';
|
const isC = e.key === 'c' || e.key === 'C';
|
||||||
const isV = e.key === 'v' || e.key === 'V';
|
const isV = e.key === 'v' || e.key === 'V';
|
||||||
|
const isF = e.key === 'f' || e.key === 'F';
|
||||||
if (isC) {
|
if (isC) {
|
||||||
if (term.hasSelection()) {
|
if (term.hasSelection()) {
|
||||||
const sel = term.getSelection();
|
const sel = term.getSelection();
|
||||||
@@ -151,16 +256,14 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
return !e.shiftKey;
|
return !e.shiftKey;
|
||||||
}
|
}
|
||||||
if (isV) {
|
if (isV) {
|
||||||
navigator.clipboard
|
pasteFromClipboard();
|
||||||
.readText()
|
return false;
|
||||||
.then((text) => {
|
}
|
||||||
if (!text) return;
|
if (isF) {
|
||||||
const ws = wsRef.current;
|
// Cmd/Ctrl-F when xterm has focus → open search. The Session-level
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
|
// shortcut handles the case where it doesn't, but xterm intercepts
|
||||||
})
|
// keys when focused so we need this binding too.
|
||||||
.catch(() => {
|
setSearchOpenRef.current(true);
|
||||||
toast.error('Clipboard read failed');
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -287,13 +390,126 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', onVis);
|
document.addEventListener('visibilitychange', onVis);
|
||||||
|
|
||||||
const unregister = terminalsRegistry.register(paneId, label, () => {
|
// v1.10.4: long-press selection + floating menu. Touch handlers live on
|
||||||
|
// the xterm host container; we don't preventDefault on touchmove unless
|
||||||
|
// we've entered selection mode, so vertical scroll-by-finger still works.
|
||||||
|
let lpTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lpStart: { x: number; y: number } | null = null;
|
||||||
|
let lpAnchor: { col: number; bufferRow: number } | null = null;
|
||||||
|
let inSelection = false;
|
||||||
|
function clearLp(): void {
|
||||||
|
if (lpTimer !== null) {
|
||||||
|
clearTimeout(lpTimer);
|
||||||
|
lpTimer = null;
|
||||||
|
}
|
||||||
|
lpStart = null;
|
||||||
|
}
|
||||||
|
function selectWord(col: number, bufferRow: number): boolean {
|
||||||
|
const line = term.buffer.active.getLine(bufferRow);
|
||||||
|
if (!line) return false;
|
||||||
|
const text = line.translateToString(true);
|
||||||
|
const range = wordRangeAt(text, col);
|
||||||
|
if (!range) return false;
|
||||||
|
term.select(range.start, bufferRow, range.end - range.start);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function extendSelection(
|
||||||
|
anchor: { col: number; bufferRow: number },
|
||||||
|
to: { col: number; bufferRow: number },
|
||||||
|
): void {
|
||||||
|
// Compute the lexicographic min/max of (row, col) so the user can drag
|
||||||
|
// up-left or down-right and still extend in the natural reading order.
|
||||||
|
const a = anchor;
|
||||||
|
const b = to;
|
||||||
|
let s: { col: number; row: number };
|
||||||
|
let e: { col: number; row: number };
|
||||||
|
if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
|
||||||
|
s = { col: a.col, row: a.bufferRow };
|
||||||
|
e = { col: b.col, row: b.bufferRow };
|
||||||
|
} else {
|
||||||
|
s = { col: b.col, row: b.bufferRow };
|
||||||
|
e = { col: a.col, row: a.bufferRow };
|
||||||
|
}
|
||||||
|
const rowsBetween = e.row - s.row;
|
||||||
|
const length = rowsBetween * term.cols + (e.col - s.col) + 1;
|
||||||
|
term.select(s.col, s.row, length);
|
||||||
|
}
|
||||||
|
function onTouchStart(e: TouchEvent): void {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
|
||||||
|
lpStart = { x: t.clientX, y: t.clientY };
|
||||||
|
lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||||
|
inSelection = false;
|
||||||
|
lpTimer = setTimeout(() => {
|
||||||
|
if (disposed || !lpAnchor) return;
|
||||||
|
const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
|
||||||
|
if (!ok) return;
|
||||||
|
inSelection = true;
|
||||||
|
// Anchor the menu above the touch point. Slight upward offset so the
|
||||||
|
// user's finger doesn't cover it.
|
||||||
|
setMenuRef.current({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent): void {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
if (inSelection && lpAnchor) {
|
||||||
|
e.preventDefault();
|
||||||
|
const to = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||||
|
extendSelection(lpAnchor, to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lpStart) return;
|
||||||
|
const dx = t.clientX - lpStart.x;
|
||||||
|
const dy = t.clientY - lpStart.y;
|
||||||
|
if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
|
||||||
|
clearLp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onTouchEnd(): void {
|
||||||
|
if (!inSelection) clearLp();
|
||||||
|
// Leave the menu visible after release; user dismisses via tap-outside.
|
||||||
|
inSelection = false;
|
||||||
|
}
|
||||||
|
function onTouchCancel(): void {
|
||||||
|
clearLp();
|
||||||
|
inSelection = false;
|
||||||
|
}
|
||||||
|
container.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
container.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
container.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
container.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||||
|
|
||||||
|
// Desktop right-click: open the same floating menu.
|
||||||
|
function onContextMenu(e: MouseEvent): void {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuRef.current({ x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
container.addEventListener('contextmenu', onContextMenu);
|
||||||
|
|
||||||
|
// Tap-outside dismiss for the floating menu. Pointerdown fires before
|
||||||
|
// any click handler inside the menu, so we re-check the target.
|
||||||
|
function onDocPointerDown(e: PointerEvent): void {
|
||||||
|
const t = e.target as Element | null;
|
||||||
|
if (t && t.closest('[data-term-menu]')) return;
|
||||||
|
setMenuRef.current(null);
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onDocPointerDown);
|
||||||
|
|
||||||
|
const unregister = terminalsRegistry.register(
|
||||||
|
paneId,
|
||||||
|
label,
|
||||||
|
() => {
|
||||||
try {
|
try {
|
||||||
term.focus();
|
term.focus();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
() => setSearchOpenRef.current(true),
|
||||||
|
() => pasteFromClipboard(),
|
||||||
|
);
|
||||||
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
||||||
if (pane_id !== paneId) return;
|
if (pane_id !== paneId) return;
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
@@ -301,6 +517,10 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
||||||
ws.send(payload);
|
ws.send(payload);
|
||||||
});
|
});
|
||||||
|
setChatInputsRef.current(chatInputsRegistry.list());
|
||||||
|
const unsubChats = chatInputsRegistry.subscribe(() => {
|
||||||
|
setChatInputsRef.current(chatInputsRegistry.list());
|
||||||
|
});
|
||||||
|
|
||||||
api.terminals.start(sessionId, paneId).catch(() => {});
|
api.terminals.start(sessionId, paneId).catch(() => {});
|
||||||
connect();
|
connect();
|
||||||
@@ -308,10 +528,18 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
document.removeEventListener('visibilitychange', onVis);
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
|
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||||
|
container.removeEventListener('touchstart', onTouchStart);
|
||||||
|
container.removeEventListener('touchmove', onTouchMove);
|
||||||
|
container.removeEventListener('touchend', onTouchEnd);
|
||||||
|
container.removeEventListener('touchcancel', onTouchCancel);
|
||||||
|
container.removeEventListener('contextmenu', onContextMenu);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
unsubChats();
|
||||||
unregister();
|
unregister();
|
||||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
||||||
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
||||||
|
if (lpTimer !== null) clearTimeout(lpTimer);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
try {
|
try {
|
||||||
wsRef.current?.close();
|
wsRef.current?.close();
|
||||||
@@ -322,6 +550,7 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
fitRef.current = null;
|
fitRef.current = null;
|
||||||
|
searchRef.current = null;
|
||||||
reconnectRef.current = () => {};
|
reconnectRef.current = () => {};
|
||||||
};
|
};
|
||||||
}, [sessionId, paneId, label]);
|
}, [sessionId, paneId, label]);
|
||||||
@@ -344,6 +573,46 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
|
// Floating menu actions. Each operates on termRef.current and clears menu.
|
||||||
|
function actCopy(): void {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
const sel = term.getSelection();
|
||||||
|
if (!sel) {
|
||||||
|
setMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
|
||||||
|
term.clearSelection();
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
function actPaste(): void {
|
||||||
|
const reg = terminalsRegistry.get(paneId);
|
||||||
|
reg?.paste();
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
function actSelectAll(): void {
|
||||||
|
termRef.current?.selectAll();
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
function actSearch(): void {
|
||||||
|
setSearchOpen(true);
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
function actSendToChat(chatId: string): void {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
const sel = term.getSelection();
|
||||||
|
if (!sel) {
|
||||||
|
setMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendToChat.emit({ chat_id: chatId, text: sel });
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelection = !!termRef.current?.getSelection();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// v1.10.3 Issue 5: flex-1 + min-w-0 + self-stretch + w-full lets the
|
// v1.10.3 Issue 5: flex-1 + min-w-0 + self-stretch + w-full lets the
|
||||||
@@ -351,9 +620,6 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
// either side of the xterm canvas.
|
// either side of the xterm canvas.
|
||||||
className="relative flex-1 min-w-0 self-stretch w-full h-full bg-[#0b0f14]"
|
className="relative flex-1 min-w-0 self-stretch w-full h-full bg-[#0b0f14]"
|
||||||
>
|
>
|
||||||
{/* v1.10.3 Issue 5: per-component CSS override (not global). React
|
|
||||||
deduplicates identical <style> bodies in modern browsers, so
|
|
||||||
multiple terminal panes don't bloat the head. */}
|
|
||||||
<style>{XTERM_STYLE_OVERRIDES}</style>
|
<style>{XTERM_STYLE_OVERRIDES}</style>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -361,6 +627,27 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
style={{ touchAction: 'pan-y', background: TERM_BG }}
|
style={{ touchAction: 'pan-y', background: TERM_BG }}
|
||||||
data-testid="terminal-pane"
|
data-testid="terminal-pane"
|
||||||
/>
|
/>
|
||||||
|
{searchOpen && (
|
||||||
|
<SearchBar
|
||||||
|
searchRef={searchRef}
|
||||||
|
theme={XTERM_THEME}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{menu && (
|
||||||
|
<FloatingMenu
|
||||||
|
x={menu.x}
|
||||||
|
y={menu.y}
|
||||||
|
hasSelection={hasSelection}
|
||||||
|
chatInputs={chatInputs}
|
||||||
|
onCopy={actCopy}
|
||||||
|
onPaste={actPaste}
|
||||||
|
onSelectAll={actSelectAll}
|
||||||
|
onSearch={actSearch}
|
||||||
|
onSendToChat={actSendToChat}
|
||||||
|
onDismiss={() => setMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{connState === 'reconnecting' && (
|
{connState === 'reconnecting' && (
|
||||||
<div className="absolute inset-x-0 top-0 bg-amber-900/85 text-amber-100 text-xs px-3 py-1 flex items-center gap-2 pointer-events-none">
|
<div className="absolute inset-x-0 top-0 bg-amber-900/85 text-amber-100 text-xs px-3 py-1 flex items-center gap-2 pointer-events-none">
|
||||||
<RefreshCw size={12} className="animate-spin" />
|
<RefreshCw size={12} className="animate-spin" />
|
||||||
@@ -382,3 +669,321 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.4: shared floating menu used by both desktop right-click and mobile
|
||||||
|
// long-press. shadcn-style chrome: dark bg, subtle border, 14px text, 44px
|
||||||
|
// touch targets via padding. Tap-outside dismiss is wired in the parent's
|
||||||
|
// document pointerdown listener.
|
||||||
|
interface FloatingMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hasSelection: boolean;
|
||||||
|
chatInputs: ChatInputRegistration[];
|
||||||
|
onCopy: () => void;
|
||||||
|
onPaste: () => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
onSendToChat: (chatId: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
function FloatingMenu({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hasSelection,
|
||||||
|
chatInputs,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
onSelectAll,
|
||||||
|
onSearch,
|
||||||
|
onSendToChat,
|
||||||
|
onDismiss,
|
||||||
|
}: FloatingMenuProps) {
|
||||||
|
const [submenu, setSubmenu] = useState(false);
|
||||||
|
// Clamp into viewport so the menu doesn't render off-screen on small
|
||||||
|
// viewports / near edges.
|
||||||
|
const MENU_W = 200;
|
||||||
|
const MENU_H = 220;
|
||||||
|
const left = Math.min(x, window.innerWidth - MENU_W - 8);
|
||||||
|
const top = Math.min(y, window.innerHeight - MENU_H - 8);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(ev: KeyboardEvent): void {
|
||||||
|
if (ev.key === 'Escape') onDismiss();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
|
// Exactly one chat input registered → flat "Send to <N>" entry instead of
|
||||||
|
// a submenu (per v1.10.4 spec).
|
||||||
|
const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-term-menu="1"
|
||||||
|
role="menu"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
background: '#1a1d24',
|
||||||
|
border: '1px solid #2a2d34',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
minWidth: MENU_W,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 50,
|
||||||
|
color: '#d6deeb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
|
||||||
|
<MenuItem onClick={onPaste}>Paste</MenuItem>
|
||||||
|
<MenuItem onClick={onSelectAll}>Select All</MenuItem>
|
||||||
|
<MenuItem onClick={onSearch}>Search</MenuItem>
|
||||||
|
{flatChat && (
|
||||||
|
<MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
|
||||||
|
Send to {flatChat.label}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{chatInputs.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onClick={() => setSubmenu((v) => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
minHeight: 44,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 0,
|
||||||
|
color: hasSelection ? '#d6deeb' : '#575656',
|
||||||
|
cursor: hasSelection ? 'pointer' : 'not-allowed',
|
||||||
|
borderRadius: 6,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Send to chat</span>
|
||||||
|
{submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
{submenu && hasSelection && (
|
||||||
|
<div style={{ paddingLeft: 8 }}>
|
||||||
|
{chatInputs.map((c) => (
|
||||||
|
<MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
|
||||||
|
{c.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
|
||||||
|
<MenuItem onClick={onDismiss}>Dismiss</MenuItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
minHeight: 44,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 0,
|
||||||
|
color: disabled ? '#575656' : '#d6deeb',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(ev) => {
|
||||||
|
if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(ev) => {
|
||||||
|
(ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.10.4: floating search bar pinned to the top of the terminal pane. Uses
|
||||||
|
// SearchAddon.findNext / findPrevious. Incremental search on each keystroke
|
||||||
|
// keeps the highlighted match in sync.
|
||||||
|
interface SearchBarProps {
|
||||||
|
searchRef: React.MutableRefObject<SearchAddon | null>;
|
||||||
|
theme: typeof XTERM_THEME;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
// onDidChangeResults fires whenever the SearchAddon's decoration set
|
||||||
|
// updates. We mirror it into local state for the "N of M" indicator.
|
||||||
|
useEffect(() => {
|
||||||
|
const addon = searchRef.current;
|
||||||
|
if (!addon) return;
|
||||||
|
const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
|
||||||
|
setCounts({ idx: resultIndex, total: resultCount });
|
||||||
|
});
|
||||||
|
return () => sub.dispose();
|
||||||
|
}, [searchRef]);
|
||||||
|
useEffect(() => {
|
||||||
|
const addon = searchRef.current;
|
||||||
|
if (!addon) return;
|
||||||
|
if (q.length === 0) {
|
||||||
|
addon.clearDecorations?.();
|
||||||
|
setCounts({ idx: -1, total: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addon.findNext(q, {
|
||||||
|
incremental: true,
|
||||||
|
decorations: {
|
||||||
|
matchBackground: theme.selectionBackground,
|
||||||
|
matchOverviewRuler: theme.cursor,
|
||||||
|
activeMatchBackground: theme.cursor,
|
||||||
|
activeMatchColorOverviewRuler: theme.cursor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [q, searchRef, theme]);
|
||||||
|
|
||||||
|
function findNext(): void {
|
||||||
|
if (!q) return;
|
||||||
|
searchRef.current?.findNext(q);
|
||||||
|
}
|
||||||
|
function findPrev(): void {
|
||||||
|
if (!q) return;
|
||||||
|
searchRef.current?.findPrevious(q);
|
||||||
|
}
|
||||||
|
function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
ev.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (ev.shiftKey) findPrev();
|
||||||
|
else findNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
background: '#1a1d24',
|
||||||
|
border: '1px solid #2a2d34',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||||
|
zIndex: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={q}
|
||||||
|
onChange={(ev) => setQ(ev.target.value)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
placeholder="Search…"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 0,
|
||||||
|
outline: 'none',
|
||||||
|
color: '#d6deeb',
|
||||||
|
padding: '8px 8px',
|
||||||
|
fontSize: 13,
|
||||||
|
width: 160,
|
||||||
|
minHeight: 36,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{q.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: counts.total === 0 ? '#ef5350' : '#575656',
|
||||||
|
minWidth: 56,
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: '0 4px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{counts.total === 0
|
||||||
|
? 'No match'
|
||||||
|
: counts.idx === -1
|
||||||
|
? `${counts.total}+`
|
||||||
|
: `${counts.idx + 1} of ${counts.total}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={findPrev}
|
||||||
|
aria-label="Previous match"
|
||||||
|
title="Previous (Shift+Enter)"
|
||||||
|
style={iconBtnStyle}
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={findNext}
|
||||||
|
aria-label="Next match"
|
||||||
|
title="Next (Enter)"
|
||||||
|
style={iconBtnStyle}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close search"
|
||||||
|
title="Close (Esc)"
|
||||||
|
style={iconBtnStyle}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconBtnStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 0,
|
||||||
|
color: '#d6deeb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
//
|
//
|
||||||
// Also exposes a tiny registry of currently-mounted terminal panes so the
|
// Also exposes a tiny registry of currently-mounted terminal panes so the
|
||||||
// MessageBubble context menu can list them. TerminalPane registers on mount,
|
// MessageBubble context menu can list them. TerminalPane registers on mount,
|
||||||
// unregisters on unmount.
|
// unregisters on unmount. v1.10.4 adds a parallel ChatInput registry used by
|
||||||
|
// the terminal floating menu's "Send to chat" submenu.
|
||||||
|
|
||||||
type Listener<T> = (payload: T) => void;
|
type Listener<T> = (payload: T) => void;
|
||||||
|
|
||||||
@@ -41,12 +42,25 @@ export interface SendToTerminalPayload {
|
|||||||
|
|
||||||
export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
||||||
|
|
||||||
|
// v1.10.4: reverse direction. Terminal floating menu "Send to chat" emits this
|
||||||
|
// with the target chat's chat_id; ChatInput subscribes and appends to its draft.
|
||||||
|
export interface SendToChatPayload {
|
||||||
|
chat_id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendToChat = createEvent<SendToChatPayload>();
|
||||||
|
|
||||||
export interface TerminalRegistration {
|
export interface TerminalRegistration {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
label: string;
|
label: string;
|
||||||
// v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm
|
// v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm
|
||||||
// input layer. TerminalPane binds this to term.focus().
|
// input layer. TerminalPane binds this to term.focus().
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
// v1.10.4: Cmd+F opens the search bar over the active terminal. Workspace
|
||||||
|
// also binds a "Paste" button in the terminal pane header to paste().
|
||||||
|
openSearch: () => void;
|
||||||
|
paste: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalRegistry = new Map<string, TerminalRegistration>();
|
const terminalRegistry = new Map<string, TerminalRegistration>();
|
||||||
@@ -63,8 +77,14 @@ function notifyRegistry(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const terminalsRegistry = {
|
export const terminalsRegistry = {
|
||||||
register(paneId: string, label: string, focus: () => void): () => void {
|
register(
|
||||||
terminalRegistry.set(paneId, { paneId, label, focus });
|
paneId: string,
|
||||||
|
label: string,
|
||||||
|
focus: () => void,
|
||||||
|
openSearch: () => void,
|
||||||
|
paste: () => void,
|
||||||
|
): () => void {
|
||||||
|
terminalRegistry.set(paneId, { paneId, label, focus, openSearch, paste });
|
||||||
notifyRegistry();
|
notifyRegistry();
|
||||||
return () => {
|
return () => {
|
||||||
terminalRegistry.delete(paneId);
|
terminalRegistry.delete(paneId);
|
||||||
@@ -84,3 +104,48 @@ export const terminalsRegistry = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.10.4: parallel registry of mounted ChatInput components so the terminal
|
||||||
|
// floating menu's "Send to chat" submenu can list open chats. Mirrors
|
||||||
|
// terminalsRegistry exactly; same subscriber pattern.
|
||||||
|
export interface ChatInputRegistration {
|
||||||
|
chatId: string;
|
||||||
|
label: string;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatInputRegistry = new Map<string, ChatInputRegistration>();
|
||||||
|
const chatInputListeners = new Set<Listener<void>>();
|
||||||
|
|
||||||
|
function notifyChatInputs(): void {
|
||||||
|
for (const l of chatInputListeners) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatInputsRegistry = {
|
||||||
|
register(chatId: string, label: string, focus: () => void): () => void {
|
||||||
|
chatInputRegistry.set(chatId, { chatId, label, focus });
|
||||||
|
notifyChatInputs();
|
||||||
|
return () => {
|
||||||
|
chatInputRegistry.delete(chatId);
|
||||||
|
notifyChatInputs();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list(): ChatInputRegistration[] {
|
||||||
|
return Array.from(chatInputRegistry.values());
|
||||||
|
},
|
||||||
|
get(chatId: string): ChatInputRegistration | undefined {
|
||||||
|
return chatInputRegistry.get(chatId);
|
||||||
|
},
|
||||||
|
subscribe(listener: Listener<void>): () => void {
|
||||||
|
chatInputListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
chatInputListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -251,6 +251,18 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.4: Cmd/Ctrl + F — when the active pane is a terminal, open the
|
||||||
|
// scrollback search bar. When it isn't, fall through to the browser's
|
||||||
|
// native find (no preventDefault, no early return).
|
||||||
|
if (key === 'f' && !e.shiftKey) {
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
if (activePane?.kind === 'terminal') {
|
||||||
|
e.preventDefault();
|
||||||
|
terminalsRegistry.get(activePane.id)?.openSearch();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
|
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
|
||||||
if (key === 'tab') {
|
if (key === 'tab') {
|
||||||
if (panes.length <= 1) return;
|
if (panes.length <= 1) return;
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -142,6 +142,9 @@ importers:
|
|||||||
xterm-addon-fit:
|
xterm-addon-fit:
|
||||||
specifier: ^0.8.0
|
specifier: ^0.8.0
|
||||||
version: 0.8.0(xterm@5.3.0)
|
version: 0.8.0(xterm@5.3.0)
|
||||||
|
xterm-addon-search:
|
||||||
|
specifier: ^0.13.0
|
||||||
|
version: 0.13.0(xterm@5.3.0)
|
||||||
xterm-addon-web-links:
|
xterm-addon-web-links:
|
||||||
specifier: ^0.9.0
|
specifier: ^0.9.0
|
||||||
version: 0.9.0(xterm@5.3.0)
|
version: 0.9.0(xterm@5.3.0)
|
||||||
@@ -3909,6 +3912,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
xterm: ^5.0.0
|
xterm: ^5.0.0
|
||||||
|
|
||||||
|
xterm-addon-search@0.13.0:
|
||||||
|
resolution: {integrity: sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==}
|
||||||
|
deprecated: This package is now deprecated. Move to @xterm/addon-search instead.
|
||||||
|
peerDependencies:
|
||||||
|
xterm: ^5.0.0
|
||||||
|
|
||||||
xterm-addon-web-links@0.9.0:
|
xterm-addon-web-links@0.9.0:
|
||||||
resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==}
|
resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==}
|
||||||
deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead.
|
deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead.
|
||||||
@@ -7967,6 +7976,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xterm: 5.3.0
|
xterm: 5.3.0
|
||||||
|
|
||||||
|
xterm-addon-search@0.13.0(xterm@5.3.0):
|
||||||
|
dependencies:
|
||||||
|
xterm: 5.3.0
|
||||||
|
|
||||||
xterm-addon-web-links@0.9.0(xterm@5.3.0):
|
xterm-addon-web-links@0.9.0(xterm@5.3.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
xterm: 5.3.0
|
xterm: 5.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user