mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 07:55:29 +08:00
Compare commits
9 Commits
main
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a187a21ef2 | ||
|
|
7d925e95ab | ||
|
|
fb06a0ce20 | ||
|
|
da27dd93db | ||
|
|
5459d7c60e | ||
|
|
645914f2c8 | ||
|
|
c7a0891f56 | ||
|
|
241ed1da54 | ||
|
|
ee002fc3f7 |
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<title>CloudCLI UI</title>
|
<title>CloudCLI UI</title>
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import React from 'react';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { QuestionAnswerContent } from './QuestionAnswerContent';
|
|
||||||
|
|
||||||
// Regression coverage for the chat-interface crash where an AskUserQuestion
|
|
||||||
// payload loaded from a session transcript arrives with a non-array `questions`
|
|
||||||
// or a question missing its `options` array. Rendering must degrade gracefully
|
|
||||||
// instead of throwing "TypeError: e.map is not a function".
|
|
||||||
|
|
||||||
test('renders without throwing when questions is a non-array value', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
// Malformed: object instead of an array
|
|
||||||
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
|
|
||||||
answers: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when a question is missing options[]', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', header: 'H' } as never],
|
|
||||||
answers: { 'Pick one?': 'X' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when options[] contains malformed entries', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
|
|
||||||
answers: { 'Pick one?': 'A, Custom' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when a questions entry is null/non-object', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
|
|
||||||
answers: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when an answer is a non-string value', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
|
||||||
// Malformed: answer is an object instead of the expected string
|
|
||||||
answers: { 'Pick one?': { unexpected: true } } as never,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('still renders a well-formed question + answer', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
||||||
answers: { 'Pick one?': 'A' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert.ok(html.includes('Pick one?'));
|
|
||||||
});
|
|
||||||
@@ -15,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
// Tool inputs are runtime data loaded from session transcripts and may be
|
if (!questions || questions.length === 0) {
|
||||||
// malformed (e.g. `questions` arriving as a non-array). Guard with
|
|
||||||
// Array.isArray so a single bad payload can't crash the whole chat view
|
|
||||||
// with "e.map is not a function".
|
|
||||||
if (!Array.isArray(questions) || questions.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
{questions.map((rawQuestion, idx) => {
|
{questions.map((q, idx) => {
|
||||||
// Entries come from session transcripts and may be malformed; skip
|
|
||||||
// anything that isn't a proper question object with a string prompt.
|
|
||||||
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const q = rawQuestion;
|
|
||||||
const answer = answers?.[q.question];
|
const answer = answers?.[q.question];
|
||||||
// `answer` may be a non-string (or absent) in malformed payloads.
|
const answerLabels = answer ? answer.split(', ') : [];
|
||||||
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
|
||||||
const skipped = !answer;
|
const skipped = !answer;
|
||||||
const isExpanded = expandedIdx === idx;
|
const isExpanded = expandedIdx === idx;
|
||||||
// `options` is typed as an array but comes from untrusted runtime data;
|
|
||||||
// keep only valid entries so `.some`/`.map` below never throw.
|
|
||||||
const options = Array.isArray(q.options)
|
|
||||||
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -90,7 +74,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{!isExpanded && answerLabels.length > 0 && (
|
{!isExpanded && answerLabels.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{answerLabels.map((lbl) => {
|
{answerLabels.map((lbl) => {
|
||||||
const isCustom = !options.some(o => o.label === lbl);
|
const isCustom = !q.options.some(o => o.label === lbl);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={lbl}
|
key={lbl}
|
||||||
@@ -126,7 +110,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
||||||
<div className="ml-6.5 space-y-1">
|
<div className="ml-6.5 space-y-1">
|
||||||
{options.map((opt) => {
|
{q.options.map((opt) => {
|
||||||
const wasSelected = answerLabels.includes(opt.label);
|
const wasSelected = answerLabels.includes(opt.label);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||||
<div
|
<div
|
||||||
key={lbl}
|
key={lbl}
|
||||||
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ITerminalOptions } from '@xterm/xterm';
|
import type { ITerminalOptions } from '@xterm/xterm';
|
||||||
|
|
||||||
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
|
||||||
export const SHELL_RESTART_DELAY_MS = 200;
|
export const SHELL_RESTART_DELAY_MS = 200;
|
||||||
export const TERMINAL_INIT_DELAY_MS = 100;
|
export const TERMINAL_INIT_DELAY_MS = 100;
|
||||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
|
|||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
clearTerminalScreen: () => void;
|
clearTerminalScreen: () => void;
|
||||||
setAuthUrl: (nextAuthUrl: string) => void;
|
|
||||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function useShellConnection({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -100,14 +98,8 @@ export function useShellConnection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'auth_url' || message.type === 'url_open') {
|
|
||||||
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
|
||||||
if (nextAuthUrl) {
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
|
[handleProcessCompletion, onOutputRef, terminalRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(
|
const connectWebSocket = useCallback(
|
||||||
@@ -133,7 +125,6 @@ export function useShellConnection({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
setAuthUrl('');
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const currentTerminal = terminalRef.current;
|
const currentTerminal = terminalRef.current;
|
||||||
@@ -196,7 +187,6 @@ export function useShellConnection({
|
|||||||
isPlainShellRef,
|
isPlainShellRef,
|
||||||
selectedProjectRef,
|
selectedProjectRef,
|
||||||
selectedSessionRef,
|
selectedSessionRef,
|
||||||
setAuthUrl,
|
|
||||||
terminalRef,
|
terminalRef,
|
||||||
wsRef,
|
wsRef,
|
||||||
],
|
],
|
||||||
@@ -225,8 +215,7 @@ export function useShellConnection({
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
forceRestartOnInitRef.current = false;
|
||||||
setAuthUrl('');
|
}, [clearTerminalScreen, closeSocket]);
|
||||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
||||||
import { useShellConnection } from './useShellConnection';
|
import { useShellConnection } from './useShellConnection';
|
||||||
import { useShellTerminal } from './useShellTerminal';
|
import { useShellTerminal } from './useShellTerminal';
|
||||||
|
|
||||||
@@ -22,15 +23,11 @@ export function useShellRuntime({
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const [authUrl, setAuthUrl] = useState('');
|
|
||||||
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
|
||||||
|
|
||||||
const selectedProjectRef = useRef(selectedProject);
|
const selectedProjectRef = useRef(selectedProject);
|
||||||
const selectedSessionRef = useRef(selectedSession);
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
const initialCommandRef = useRef(initialCommand);
|
const initialCommandRef = useRef(initialCommand);
|
||||||
const isPlainShellRef = useRef(isPlainShell);
|
const isPlainShellRef = useRef(isPlainShell);
|
||||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||||
const authUrlRef = useRef('');
|
|
||||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||||
|
|
||||||
// Keep mutable values in refs so websocket handlers always read current data.
|
// Keep mutable values in refs so websocket handlers always read current data.
|
||||||
@@ -42,12 +39,6 @@ export function useShellRuntime({
|
|||||||
onProcessCompleteRef.current = onProcessComplete;
|
onProcessCompleteRef.current = onProcessComplete;
|
||||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||||
|
|
||||||
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
|
||||||
authUrlRef.current = nextAuthUrl;
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
setAuthUrlVersion((previous) => previous + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSocket = useCallback(() => {
|
const closeSocket = useCallback(() => {
|
||||||
const activeSocket = wsRef.current;
|
const activeSocket = wsRef.current;
|
||||||
if (!activeSocket) {
|
if (!activeSocket) {
|
||||||
@@ -64,32 +55,6 @@ export function useShellRuntime({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popup = window.open(url, '_blank');
|
|
||||||
if (popup) {
|
|
||||||
try {
|
|
||||||
popup.opener = null;
|
|
||||||
} catch {
|
|
||||||
// Ignore cross-origin restrictions when trying to null opener.
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return copyTextToClipboard(url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -98,10 +63,6 @@ export function useShellRuntime({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +79,6 @@ export function useShellRuntime({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl: setCurrentAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,11 +116,7 @@ export function useShellRuntime({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
CODEX_DEVICE_AUTH_URL,
|
|
||||||
TERMINAL_INIT_DELAY_MS,
|
TERMINAL_INIT_DELAY_MS,
|
||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
import {
|
||||||
import { isCodexLoginCommand } from '../utils/auth';
|
installMobileTerminalSelection,
|
||||||
|
type MobileTerminalSelectionManager,
|
||||||
|
} from '../utils/mobileTerminalSelection';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
|
|
||||||
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
|
|||||||
selectedProject: Project | null | undefined;
|
selectedProject: Project | null | undefined;
|
||||||
minimal: boolean;
|
minimal: boolean;
|
||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
|
||||||
isPlainShellRef: MutableRefObject<boolean>;
|
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,14 +44,11 @@ export function useShellTerminal({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const resizeTimeoutRef = useRef<number | null>(null);
|
const resizeTimeoutRef = useRef<number | null>(null);
|
||||||
|
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
||||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||||
const hasSelectedProject = Boolean(selectedProject);
|
const hasSelectedProject = Boolean(selectedProject);
|
||||||
|
|
||||||
@@ -70,6 +66,11 @@ export function useShellTerminal({
|
|||||||
}, [terminalRef]);
|
}, [terminalRef]);
|
||||||
|
|
||||||
const disposeTerminal = useCallback(() => {
|
const disposeTerminal = useCallback(() => {
|
||||||
|
if (mobileSelectionRef.current) {
|
||||||
|
mobileSelectionRef.current.dispose();
|
||||||
|
mobileSelectionRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.dispose();
|
terminalRef.current.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
@@ -80,7 +81,8 @@ export function useShellTerminal({
|
|||||||
}, [fitAddonRef, terminalRef]);
|
}, [fitAddonRef, terminalRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
const terminalContainer = terminalContainerRef.current;
|
||||||
|
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,28 @@ export function useShellTerminal({
|
|||||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTerminal.open(terminalContainerRef.current);
|
nextTerminal.open(terminalContainer);
|
||||||
|
mobileSelectionRef.current = installMobileTerminalSelection(
|
||||||
|
nextTerminal,
|
||||||
|
terminalContainer,
|
||||||
|
{
|
||||||
|
onFontSizeChange: (fontSize) => {
|
||||||
|
nextTerminal.options.fontSize = fontSize;
|
||||||
|
|
||||||
|
const currentFitAddon = fitAddonRef.current;
|
||||||
|
if (currentFitAddon) {
|
||||||
|
currentFitAddon.fit();
|
||||||
|
sendSocketMessage(wsRef.current, {
|
||||||
|
type: 'resize',
|
||||||
|
cols: nextTerminal.cols,
|
||||||
|
rows: nextTerminal.rows,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nextTerminal.refresh(0, nextTerminal.rows - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const copyTerminalSelection = async () => {
|
const copyTerminalSelection = async () => {
|
||||||
const selection = nextTerminal.getSelection();
|
const selection = nextTerminal.getSelection();
|
||||||
@@ -133,29 +156,9 @@ export function useShellTerminal({
|
|||||||
void copyTextToClipboard(selection);
|
void copyTextToClipboard(selection);
|
||||||
};
|
};
|
||||||
|
|
||||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
||||||
|
|
||||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
|
||||||
? CODEX_DEVICE_AUTH_URL
|
|
||||||
: authUrlRef.current;
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === 'keydown' &&
|
|
||||||
minimal &&
|
|
||||||
isPlainShellRef.current &&
|
|
||||||
activeAuthUrl &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
event.key?.toLowerCase() === 'c'
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void copyAuthUrlToClipboard(activeAuthUrl);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === 'keydown' &&
|
event.type === 'keydown' &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
@@ -240,10 +243,10 @@ export function useShellTerminal({
|
|||||||
}, TERMINAL_RESIZE_DELAY_MS);
|
}, TERMINAL_RESIZE_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(terminalContainerRef.current);
|
resizeObserver.observe(terminalContainer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
@@ -254,16 +257,12 @@ export function useShellTerminal({
|
|||||||
disposeTerminal();
|
disposeTerminal();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
authUrlRef,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
disposeTerminal,
|
disposeTerminal,
|
||||||
fitAddonRef,
|
fitAddonRef,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
isRestarting,
|
isRestarting,
|
||||||
minimal,
|
|
||||||
hasSelectedProject,
|
hasSelectedProject,
|
||||||
|
minimal,
|
||||||
selectedProjectKey,
|
selectedProjectKey,
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
|
|||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
|
||||||
|
|
||||||
export type ShellInitMessage = {
|
export type ShellInitMessage = {
|
||||||
type: 'init';
|
type: 'init';
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
|
|||||||
wsRef: MutableRefObject<WebSocket | null>;
|
wsRef: MutableRefObject<WebSocket | null>;
|
||||||
terminalRef: MutableRefObject<Terminal | null>;
|
terminalRef: MutableRefObject<Terminal | null>;
|
||||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||||
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
import type { ProjectSession } from '../../../types/app';
|
import type { ProjectSession } from '../../../types/app';
|
||||||
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
|
|
||||||
|
|
||||||
export function isCodexLoginCommand(command: string | null | undefined): boolean {
|
|
||||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
|
|
||||||
if (isCodexLoginCommand(command)) {
|
|
||||||
return CODEX_DEVICE_AUTH_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return authUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
|
|||||||
return session.__provider === 'cursor'
|
return session.__provider === 'cursor'
|
||||||
? session.name || 'Untitled Session'
|
? session.name || 'Untitled Session'
|
||||||
: session.summary || 'New Session';
|
: session.summary || 'New Session';
|
||||||
}
|
}
|
||||||
|
|||||||
1044
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
1044
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,12 +59,8 @@ export default function Shell({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
} = useShellRuntime({
|
} = useShellRuntime({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -243,15 +239,7 @@ export default function Shell({
|
|||||||
if (minimal) {
|
if (minimal) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShellMinimalView
|
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
|
||||||
terminalContainerRef={terminalContainerRef}
|
|
||||||
authUrl={authUrl}
|
|
||||||
authUrlVersion={authUrlVersion}
|
|
||||||
initialCommand={initialCommand}
|
|
||||||
isConnected={isConnected}
|
|
||||||
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
|
||||||
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
|
||||||
/>
|
|
||||||
<TerminalShortcutsPanel
|
<TerminalShortcutsPanel
|
||||||
wsRef={wsRef}
|
wsRef={wsRef}
|
||||||
terminalRef={terminalRef}
|
terminalRef={terminalRef}
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import type { AuthCopyStatus } from '../../types/types';
|
|
||||||
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
|
||||||
|
|
||||||
type ShellMinimalViewProps = {
|
type ShellMinimalViewProps = {
|
||||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
initialCommand: string | null | undefined;
|
|
||||||
isConnected: boolean;
|
|
||||||
openAuthUrlInBrowser: (url: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ShellMinimalView({
|
export default function ShellMinimalView({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
initialCommand,
|
|
||||||
isConnected,
|
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
}: ShellMinimalViewProps) {
|
}: ShellMinimalViewProps) {
|
||||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
|
|
||||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
|
||||||
|
|
||||||
const displayAuthUrl = useMemo(
|
|
||||||
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
|
|
||||||
[authUrl, initialCommand],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
|
|
||||||
useEffect(() => {
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(false);
|
|
||||||
}, [authUrlVersion, displayAuthUrl, isConnected]);
|
|
||||||
|
|
||||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
|
||||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
|
||||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-gray-900">
|
<div className="relative h-full w-full bg-gray-900">
|
||||||
<div
|
<div
|
||||||
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
|
|||||||
className="h-full w-full focus:outline-none"
|
className="h-full w-full focus:outline-none"
|
||||||
style={{ outline: 'none' }}
|
style={{ outline: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showMobileAuthPanel && (
|
|
||||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(true)}
|
|
||||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
Hide
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayAuthUrl}
|
|
||||||
readOnly
|
|
||||||
onClick={(event) => event.currentTarget.select()}
|
|
||||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
aria-label="Authentication URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openAuthUrlInBrowser(displayAuthUrl);
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Open URL
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
|
||||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showMobileAuthPanelToggle && (
|
|
||||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(false)}
|
|
||||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Show login URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
|
|||||||
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
||||||
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
||||||
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
||||||
|
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ARROW_ICONS = {
|
const ARROW_ICONS = {
|
||||||
|
|||||||
@@ -137,6 +137,12 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
/* The app shell is a fixed inset-0 container (see AppContent), so the
|
||||||
|
document itself never needs to scroll. Clipping it removes the phantom
|
||||||
|
full-height page scrollbar and disables the browser pull-to-refresh
|
||||||
|
gesture that reloads the page when scrolling up on mobile. */
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Root element with safe area padding for PWA */
|
/* Root element with safe area padding for PWA */
|
||||||
|
|||||||
Reference in New Issue
Block a user