mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 07:55:29 +08:00
Compare commits
10 Commits
fix/mobile
...
fix/sideba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bcb541560 | ||
|
|
fcc469b55c | ||
|
|
2afe0955ed | ||
|
|
c88baaf8dc | ||
|
|
f8430dc886 | ||
|
|
98a3a3a1f4 | ||
|
|
2c08060f65 | ||
|
|
75bbafb438 | ||
|
|
7c8928c66d | ||
|
|
ed4ae3114a |
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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, interactive-widget=resizes-content" />
|
||||
<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" />
|
||||
<title>CloudCLI UI</title>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
|
||||
@@ -207,6 +207,15 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
break;
|
||||
}
|
||||
|
||||
// A result with a toolId but no matching tool_use in the loaded set is
|
||||
// almost always a tool_use/tool_result pair split across a pagination
|
||||
// boundary (older page not loaded yet). Rendering its raw content here
|
||||
// produces an unstyled dump that "fixes itself" once the older page
|
||||
// loads; skip it and let it attach to its tool_use when that arrives.
|
||||
if (msg.toolId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const content = formatToolResultContent(msg.content || '');
|
||||
if (!content.trim()) {
|
||||
break;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
|
||||
import type { SubagentChildTool } from '../types/types';
|
||||
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { PlanDisplay } from './components/PlanDisplay';
|
||||
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
||||
import type { ToolStatus } from './components/ToolStatusBadge';
|
||||
@@ -125,6 +125,31 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
|
||||
if (!displayConfig) return null;
|
||||
|
||||
// Bash renders as a Codex-style command row: the command on a single line with
|
||||
// a chevron that expands to show the output inline. The combined view lives on
|
||||
// the input render; the separate result section is suppressed in MessageComponent.
|
||||
if (toolName === 'Bash' && mode === 'input') {
|
||||
const command = parsedData?.command || '';
|
||||
const description = parsedData?.description;
|
||||
const output = typeof toolResult?.content === 'string'
|
||||
? toolResult.content
|
||||
: toolResult?.content != null
|
||||
? String(toolResult.content)
|
||||
: '';
|
||||
return (
|
||||
<BashCommandDisplay
|
||||
command={command}
|
||||
description={description}
|
||||
output={output}
|
||||
isError={Boolean(toolResult?.isError)}
|
||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||
// Commands stay collapsed by default (even consecutive ones); only
|
||||
// failures auto-expand so they remain visible.
|
||||
defaultOpen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig.type === 'one-line') {
|
||||
const value = displayConfig.getValue?.(parsedData) || '';
|
||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||
|
||||
155
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
155
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Copy, Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
||||
import type { ToolStatus } from './ToolStatusBadge';
|
||||
|
||||
interface BashCommandDisplayProps {
|
||||
command: string;
|
||||
description?: string;
|
||||
/** Combined stdout/stderr from the tool result (empty while running). */
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
status?: ToolStatus;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex-in-VSCode style command row: a compact, single-line command with a
|
||||
* chevron on the left. When the command produced output, the row becomes a
|
||||
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
|
||||
* keep it clean in both light and dark mode; consecutive commands stack tightly
|
||||
* into a clean list.
|
||||
*/
|
||||
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
|
||||
command,
|
||||
description,
|
||||
output,
|
||||
isError = false,
|
||||
status,
|
||||
defaultOpen = false,
|
||||
}) => {
|
||||
const trimmedOutput = (output || '').replace(/\s+$/, '');
|
||||
const hasOutput = trimmedOutput.length > 0;
|
||||
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
|
||||
const isRunning = status === 'running';
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Output (and errors) often arrive after this component first mounts, so apply
|
||||
// the auto-open intent once when there is finally something to show. After that
|
||||
// the user is in control of the toggle.
|
||||
const autoAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
|
||||
autoAppliedRef.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasOutput, defaultOpen, isError]);
|
||||
|
||||
const toggle = () => {
|
||||
if (hasOutput) {
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const didCopy = await copyTextToClipboard(command);
|
||||
if (!didCopy) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
|
||||
isError ? 'border-red-500/30' : 'border-border/60',
|
||||
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
|
||||
open && 'bg-muted/50 shadow-sm',
|
||||
)}
|
||||
>
|
||||
{/* Command header — clickable when there is output to expand */}
|
||||
<div
|
||||
role={hasOutput ? 'button' : undefined}
|
||||
tabIndex={hasOutput ? 0 : undefined}
|
||||
aria-expanded={hasOutput ? open : undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
|
||||
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
!hasOutput && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
|
||||
$
|
||||
</span>
|
||||
<code
|
||||
className={cn(
|
||||
'min-w-0 flex-1 font-mono text-xs text-foreground',
|
||||
open ? 'whitespace-pre-wrap break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
|
||||
{isRunning && (
|
||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
||||
)}
|
||||
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
|
||||
{!open && hasOutput && !isRunning && (
|
||||
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
|
||||
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
||||
title="Copy command"
|
||||
aria-label="Copy command"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{description && !open && (
|
||||
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded output */}
|
||||
{open && hasOutput && (
|
||||
<div className="settings-content-enter border-t border-border/50 bg-background/50">
|
||||
{description && (
|
||||
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
|
||||
)}
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
|
||||
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{trimmedOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
124
src/components/chat/tools/components/CommandRunGroup.tsx
Normal file
124
src/components/chat/tools/components/CommandRunGroup.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Terminal } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import { BashCommandDisplay } from './BashCommandDisplay';
|
||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
||||
import type { ToolStatus } from './ToolStatusBadge';
|
||||
|
||||
interface CommandRunGroupProps {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
type ExtractedCommand = {
|
||||
key: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
status: ToolStatus;
|
||||
};
|
||||
|
||||
function extractCommand(message: ChatMessage, index: number): ExtractedCommand {
|
||||
let command = '';
|
||||
let description: string | undefined;
|
||||
try {
|
||||
const parsed =
|
||||
typeof message.toolInput === 'string' ? JSON.parse(message.toolInput) : message.toolInput;
|
||||
command = parsed?.command || '';
|
||||
description = parsed?.description;
|
||||
} catch {
|
||||
command = typeof message.toolInput === 'string' ? message.toolInput : '';
|
||||
}
|
||||
|
||||
const result = message.toolResult;
|
||||
const rawContent = result?.content;
|
||||
const output =
|
||||
typeof rawContent === 'string' ? rawContent : rawContent != null ? String(rawContent) : '';
|
||||
const isError = Boolean(result?.isError);
|
||||
const status: ToolStatus = !result ? 'running' : isError ? 'error' : 'completed';
|
||||
|
||||
return {
|
||||
key: message.toolId || `${command}-${index}`,
|
||||
command,
|
||||
description,
|
||||
output,
|
||||
isError,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a run of consecutive shell commands under a single collapsible header
|
||||
* (Codex-in-VSCode style). Collapsed by default so long command runs stay tidy;
|
||||
* expanding reveals every command in the run, each independently expandable for
|
||||
* its own output.
|
||||
*/
|
||||
export const CommandRunGroup: React.FC<CommandRunGroupProps> = ({ messages }) => {
|
||||
const commands = messages.map(extractCommand);
|
||||
const count = commands.length;
|
||||
const anyRunning = commands.some((c) => c.status === 'running');
|
||||
const anyError = commands.some((c) => c.isError);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Surface failed runs without a click: open once when an error first appears.
|
||||
const autoAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoAppliedRef.current && anyError) {
|
||||
autoAppliedRef.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
}, [anyError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border bg-muted/30 transition-all duration-200',
|
||||
anyError ? 'border-red-500/30' : 'border-border/60',
|
||||
open && 'shadow-sm',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">
|
||||
{anyRunning ? 'Running' : 'Ran'} <span className="text-muted-foreground">{count} commands</span>
|
||||
</span>
|
||||
{anyRunning && (
|
||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
||||
)}
|
||||
{anyError && <ToolStatusBadge status="error" className="flex-shrink-0" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="settings-content-enter space-y-1 border-t border-border/50 p-2">
|
||||
{commands.map((cmd) => (
|
||||
<BashCommandDisplay
|
||||
key={cmd.key}
|
||||
command={cmd.command}
|
||||
description={cmd.description}
|
||||
output={cmd.output}
|
||||
isError={cmd.isError}
|
||||
status={cmd.status !== 'completed' ? cmd.status : undefined}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
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,7 +15,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
}) => {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
// Tool inputs are runtime data loaded from session transcripts and may be
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -24,11 +28,23 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{questions.map((q, idx) => {
|
||||
{questions.map((rawQuestion, 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 answerLabels = answer ? answer.split(', ') : [];
|
||||
// `answer` may be a non-string (or absent) in malformed payloads.
|
||||
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
||||
const skipped = !answer;
|
||||
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 (
|
||||
<div
|
||||
@@ -74,7 +90,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
{!isExpanded && answerLabels.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{answerLabels.map((lbl) => {
|
||||
const isCustom = !q.options.some(o => o.label === lbl);
|
||||
const isCustom = !options.some(o => o.label === lbl);
|
||||
return (
|
||||
<span
|
||||
key={lbl}
|
||||
@@ -110,7 +126,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
{isExpanded && (
|
||||
<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">
|
||||
{q.options.map((opt) => {
|
||||
{options.map((opt) => {
|
||||
const wasSelected = answerLabels.includes(opt.label);
|
||||
return (
|
||||
<div
|
||||
@@ -148,7 +164,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
||||
<div
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { CollapsibleSection } from './CollapsibleSection';
|
||||
export { ToolDiffViewer } from './ToolDiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { BashCommandDisplay } from './BashCommandDisplay';
|
||||
export { CommandRunGroup } from './CommandRunGroup';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export { SubagentContainer } from './SubagentContainer';
|
||||
export * from './ContentRenderers';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import type {
|
||||
@@ -13,6 +13,7 @@ import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
import { CommandRunGroup } from '../../tools';
|
||||
|
||||
interface ChatMessagesPaneProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
@@ -252,25 +253,81 @@ export default function ChatMessagesPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
const isBashCommand = (m: ChatMessage | null | undefined) =>
|
||||
Boolean(m && m.isToolUse && m.toolName === 'Bash' && !m.isSubagentContainer);
|
||||
// Messages that render nothing (e.g. thinking hidden when showThinking
|
||||
// is off) shouldn't break a visual run of commands.
|
||||
const isRendered = (m: ChatMessage) => !(m.isThinking && !showThinking);
|
||||
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
for (let index = 0; index < visibleMessages.length; index++) {
|
||||
const message = visibleMessages[index];
|
||||
|
||||
// Collapse a run of 2+ consecutive shell commands under a single
|
||||
// header so long command runs stay tidy (Codex-in-VSCode style).
|
||||
// Skip over non-rendered messages (e.g. hidden reasoning that Codex
|
||||
// interleaves between commands) so they don't split the run.
|
||||
if (isBashCommand(message)) {
|
||||
const runIndices = [index];
|
||||
let cursor = index + 1;
|
||||
while (cursor < visibleMessages.length) {
|
||||
const candidate = visibleMessages[cursor];
|
||||
if (!isRendered(candidate)) {
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
if (isBashCommand(candidate)) {
|
||||
runIndices.push(cursor);
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (runIndices.length >= 2) {
|
||||
const groupMessages = runIndices.map((i) => visibleMessages[i]);
|
||||
items.push(
|
||||
<CommandRunGroup key={getMessageKey(groupMessages[0])} messages={groupMessages} />,
|
||||
);
|
||||
// Consume everything up to the last command in the run (any
|
||||
// trailing skipped messages render nothing anyway).
|
||||
index = runIndices[runIndices.length - 1];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk back past messages that are not actually rendered (e.g. thinking
|
||||
// messages hidden when showThinking is off). Otherwise a hidden thinking
|
||||
// message would make the following message look "grouped" and suppress its
|
||||
// provider header/icon — which is why Claude turns lost their icon.
|
||||
let prevMessage: ChatMessage | null = null;
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const candidate = visibleMessages[i];
|
||||
if (candidate.isThinking && !showThinking) continue;
|
||||
prevMessage = candidate;
|
||||
break;
|
||||
}
|
||||
items.push(
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Cpu,
|
||||
Gauge,
|
||||
@@ -59,19 +57,6 @@ type ModelOption = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const formatUpdatedAt = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
|
||||
function ModelsContent({
|
||||
data,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
@@ -254,14 +238,12 @@ function ModelsContent({
|
||||
}: {
|
||||
data: ModelCommandData;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||
@@ -269,7 +251,6 @@ function ModelsContent({
|
||||
const currentModel = data?.current?.model || 'Unknown';
|
||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||
const liveDefinition = providerModelCatalog[currentProvider];
|
||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||
return liveDefinition.OPTIONS;
|
||||
@@ -282,7 +263,6 @@ function ModelsContent({
|
||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||
return availableModels.map((model) => ({ value: model, label: model }));
|
||||
}, [data, liveDefinition]);
|
||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
@@ -296,18 +276,8 @@ function ModelsContent({
|
||||
});
|
||||
}, [availableOptions, query]);
|
||||
|
||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||
|
||||
const copyModel = (model: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
||||
}
|
||||
setCopiedModel(model);
|
||||
window.setTimeout(() => {
|
||||
setCopiedModel((current) => (current === model ? null : current));
|
||||
}, 1300);
|
||||
};
|
||||
const showSearch = availableOptions.length > 6;
|
||||
|
||||
const handleSelectModel = async (model: string) => {
|
||||
setChangingModel(model);
|
||||
@@ -330,162 +300,106 @@ function ModelsContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{providerLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||
{availableOptions.length} models
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
||||
{currentModel}
|
||||
</p>
|
||||
{activeOption?.label && activeOption.label !== currentModel && (
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
||||
)}
|
||||
{activeOption?.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
||||
)}
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Next response: {pendingSessionModel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||
Catalog Refresh
|
||||
</p>
|
||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||
All providers
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||
or when a new model is missing.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||
>
|
||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||
{hasConcreteSessionId
|
||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
{/* Compact context bar: active model + refresh, no clutter */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Active model · {providerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
|
||||
→ {pendingSessionModel} next
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
title="Refresh model list from providers"
|
||||
aria-label="Refresh model list from providers"
|
||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
||||
{filteredOptions.length} shown
|
||||
</Badge>
|
||||
</div>
|
||||
{showSearch && (
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
)}
|
||||
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const wasCopied = copiedModel === option.value;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Use model ${option.value}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||
Next response selection
|
||||
</span>
|
||||
)}
|
||||
{isChanging && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Applying...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyModel(option.value)}
|
||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
||||
aria-label={`Copy model id ${option.value}`}
|
||||
>
|
||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
aria-label={`Select model ${option.value}`}
|
||||
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent ? (
|
||||
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : isChanging ? (
|
||||
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
|
||||
)}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
|
||||
Applies next response
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single quiet line of guidance / feedback */}
|
||||
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
|
||||
{selectionNotice ? (
|
||||
<span className="text-foreground">{selectionNotice}</span>
|
||||
) : hasConcreteSessionId ? (
|
||||
'Your choice applies to this session on the next response.'
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
'Your choice becomes the default model for new turns.'
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -606,7 +520,6 @@ export default function CommandResultModal({
|
||||
payload,
|
||||
onClose,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
@@ -624,9 +537,9 @@ export default function CommandResultModal({
|
||||
icon: CircleHelp,
|
||||
},
|
||||
models: {
|
||||
eyebrow: 'Model inventory',
|
||||
title: 'Available Models',
|
||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||
eyebrow: 'Model selection',
|
||||
title: 'Choose a Model',
|
||||
subtitle: 'Pick the model this provider should use.',
|
||||
icon: Cpu,
|
||||
},
|
||||
cost: {
|
||||
@@ -700,7 +613,6 @@ export default function CommandResultModal({
|
||||
<ModelsContent
|
||||
data={payload.data as ModelCommandData}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId}
|
||||
|
||||
@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||
|
||||
type MarkdownProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Links to the wider web (or in-page anchors) keep normal browser navigation;
|
||||
// everything else is treated as a workspace file reference.
|
||||
const isExternalHref = (href?: string): boolean =>
|
||||
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
|
||||
|
||||
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
|
||||
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
|
||||
|
||||
// A usable file path contains a separator or a filename with an extension.
|
||||
const looksLikeFilePath = (value?: string): value is string => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const cleaned = stripLineSuffix(value.trim());
|
||||
if (!cleaned || cleaned === '#') {
|
||||
return false;
|
||||
}
|
||||
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
|
||||
};
|
||||
|
||||
// Extract plain text from link children so a reference rendered only as link
|
||||
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
|
||||
const childrenToText = (children: React.ReactNode): string => {
|
||||
if (typeof children === 'string' || typeof children === 'number') {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(childrenToText).join('');
|
||||
}
|
||||
if (React.isValidElement(children)) {
|
||||
return childrenToText((children.props as { children?: React.ReactNode }).children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
type CodeBlockProps = {
|
||||
node?: any;
|
||||
inline?: boolean;
|
||||
@@ -123,11 +159,6 @@ const markdownComponents = {
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
const { openFileInEditor } = usePaletteOps();
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
...markdownComponents,
|
||||
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
|
||||
// Prefer the href when it is a real path; otherwise fall back to the
|
||||
// link text, since models often emit `[src/foo.ts]()` with an empty href.
|
||||
const linkText = childrenToText(linkChildren);
|
||||
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
|
||||
|
||||
if (fileRef && !isExternalHref(href)) {
|
||||
return (
|
||||
<a
|
||||
href={href || fileRef}
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openFileInEditor(stripLineSuffix(fileRef));
|
||||
}}
|
||||
>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[openFileInEditor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -218,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tool Result Section */}
|
||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
{/* Tool Result Section — Bash renders its output inside the command row above. */}
|
||||
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
message.toolResult.isError ? (
|
||||
// Error results - red error box with content
|
||||
<div
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||
@@ -77,6 +78,10 @@ function MainContent({
|
||||
isMobile,
|
||||
});
|
||||
|
||||
// Resolves bare/partial file references (e.g. links inside chat messages) to
|
||||
// real project files before opening them in the in-app editor.
|
||||
const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen);
|
||||
|
||||
useEffect(() => {
|
||||
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
||||
// same identifier to key its internal maps.
|
||||
@@ -121,6 +126,10 @@ function MainContent({
|
||||
setActiveTab('files');
|
||||
handleFileOpen(filePath);
|
||||
},
|
||||
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
|
||||
openFileInEditor: (filePath: string) => {
|
||||
resolvedFileOpen(filePath);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 TERMINAL_INIT_DELAY_MS = 100;
|
||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||
|
||||
@@ -24,6 +24,7 @@ type UseShellConnectionOptions = {
|
||||
autoConnect: boolean;
|
||||
closeSocket: () => void;
|
||||
clearTerminalScreen: () => void;
|
||||
setAuthUrl: (nextAuthUrl: string) => void;
|
||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||
};
|
||||
|
||||
@@ -48,6 +49,7 @@ export function useShellConnection({
|
||||
autoConnect,
|
||||
closeSocket,
|
||||
clearTerminalScreen,
|
||||
setAuthUrl,
|
||||
onOutputRef,
|
||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
@@ -98,8 +100,14 @@ export function useShellConnection({
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'auth_url' || message.type === 'url_open') {
|
||||
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
||||
if (nextAuthUrl) {
|
||||
setAuthUrl(nextAuthUrl);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleProcessCompletion, onOutputRef, terminalRef],
|
||||
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
|
||||
);
|
||||
|
||||
const connectWebSocket = useCallback(
|
||||
@@ -125,6 +133,7 @@ export function useShellConnection({
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
connectingRef.current = false;
|
||||
setAuthUrl('');
|
||||
|
||||
window.setTimeout(() => {
|
||||
const currentTerminal = terminalRef.current;
|
||||
@@ -187,6 +196,7 @@ export function useShellConnection({
|
||||
isPlainShellRef,
|
||||
selectedProjectRef,
|
||||
selectedSessionRef,
|
||||
setAuthUrl,
|
||||
terminalRef,
|
||||
wsRef,
|
||||
],
|
||||
@@ -215,7 +225,8 @@ export function useShellConnection({
|
||||
setIsConnecting(false);
|
||||
connectingRef.current = false;
|
||||
forceRestartOnInitRef.current = false;
|
||||
}, [clearTerminalScreen, closeSocket]);
|
||||
setAuthUrl('');
|
||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import { useShellConnection } from './useShellConnection';
|
||||
import { useShellTerminal } from './useShellTerminal';
|
||||
|
||||
@@ -23,11 +22,15 @@ export function useShellRuntime({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const [authUrl, setAuthUrl] = useState('');
|
||||
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
||||
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
const authUrlRef = useRef('');
|
||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||
|
||||
// Keep mutable values in refs so websocket handlers always read current data.
|
||||
@@ -39,6 +42,12 @@ export function useShellRuntime({
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||
|
||||
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
||||
authUrlRef.current = nextAuthUrl;
|
||||
setAuthUrl(nextAuthUrl);
|
||||
setAuthUrlVersion((previous) => previous + 1);
|
||||
}, []);
|
||||
|
||||
const closeSocket = useCallback(() => {
|
||||
const activeSocket = wsRef.current;
|
||||
if (!activeSocket) {
|
||||
@@ -55,6 +64,32 @@ export function useShellRuntime({
|
||||
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({
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
@@ -63,6 +98,10 @@ export function useShellRuntime({
|
||||
selectedProject,
|
||||
minimal,
|
||||
isRestarting,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
authUrlRef,
|
||||
copyAuthUrlToClipboard,
|
||||
closeSocket,
|
||||
});
|
||||
|
||||
@@ -79,6 +118,7 @@ export function useShellRuntime({
|
||||
autoConnect,
|
||||
closeSocket,
|
||||
clearTerminalScreen,
|
||||
setAuthUrl: setCurrentAuthUrl,
|
||||
onOutputRef,
|
||||
});
|
||||
|
||||
@@ -116,7 +156,11 @@ export function useShellRuntime({
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isConnecting,
|
||||
authUrl,
|
||||
authUrlVersion,
|
||||
connectToShell,
|
||||
disconnectFromShell,
|
||||
openAuthUrlInBrowser,
|
||||
copyAuthUrlToClipboard,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,18 +4,15 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project } from '../../../types/app';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import {
|
||||
CODEX_DEVICE_AUTH_URL,
|
||||
TERMINAL_INIT_DELAY_MS,
|
||||
TERMINAL_OPTIONS,
|
||||
TERMINAL_RESIZE_DELAY_MS,
|
||||
} from '../constants/constants';
|
||||
import {
|
||||
installMobileTerminalSelection,
|
||||
type MobileTerminalSelectionManager,
|
||||
} from '../utils/mobileTerminalSelection';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import { isCodexLoginCommand } from '../utils/auth';
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||
|
||||
@@ -27,6 +24,10 @@ type UseShellTerminalOptions = {
|
||||
selectedProject: Project | null | undefined;
|
||||
minimal: boolean;
|
||||
isRestarting: boolean;
|
||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||
isPlainShellRef: MutableRefObject<boolean>;
|
||||
authUrlRef: MutableRefObject<string>;
|
||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||
closeSocket: () => void;
|
||||
};
|
||||
|
||||
@@ -44,11 +45,14 @@ export function useShellTerminal({
|
||||
selectedProject,
|
||||
minimal,
|
||||
isRestarting,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
authUrlRef,
|
||||
copyAuthUrlToClipboard,
|
||||
closeSocket,
|
||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const resizeTimeoutRef = useRef<number | null>(null);
|
||||
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||
const hasSelectedProject = Boolean(selectedProject);
|
||||
|
||||
@@ -66,11 +70,6 @@ export function useShellTerminal({
|
||||
}, [terminalRef]);
|
||||
|
||||
const disposeTerminal = useCallback(() => {
|
||||
if (mobileSelectionRef.current) {
|
||||
mobileSelectionRef.current.dispose();
|
||||
mobileSelectionRef.current = null;
|
||||
}
|
||||
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.dispose();
|
||||
terminalRef.current = null;
|
||||
@@ -81,8 +80,7 @@ export function useShellTerminal({
|
||||
}, [fitAddonRef, terminalRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const terminalContainer = terminalContainerRef.current;
|
||||
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,28 +102,7 @@ export function useShellTerminal({
|
||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
nextTerminal.open(terminalContainerRef.current);
|
||||
|
||||
const copyTerminalSelection = async () => {
|
||||
const selection = nextTerminal.getSelection();
|
||||
@@ -156,9 +133,29 @@ export function useShellTerminal({
|
||||
void copyTextToClipboard(selection);
|
||||
};
|
||||
|
||||
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
||||
|
||||
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 (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
@@ -243,10 +240,10 @@ export function useShellTerminal({
|
||||
}, TERMINAL_RESIZE_DELAY_MS);
|
||||
});
|
||||
|
||||
resizeObserver.observe(terminalContainer);
|
||||
resizeObserver.observe(terminalContainerRef.current);
|
||||
|
||||
return () => {
|
||||
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
||||
resizeObserver.disconnect();
|
||||
if (resizeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resizeTimeoutRef.current);
|
||||
@@ -257,12 +254,16 @@ export function useShellTerminal({
|
||||
disposeTerminal();
|
||||
};
|
||||
}, [
|
||||
authUrlRef,
|
||||
closeSocket,
|
||||
copyAuthUrlToClipboard,
|
||||
disposeTerminal,
|
||||
fitAddonRef,
|
||||
initialCommandRef,
|
||||
isPlainShellRef,
|
||||
isRestarting,
|
||||
hasSelectedProject,
|
||||
minimal,
|
||||
hasSelectedProject,
|
||||
selectedProjectKey,
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||
|
||||
export type ShellInitMessage = {
|
||||
type: 'init';
|
||||
projectPath: string;
|
||||
@@ -52,6 +54,7 @@ export type ShellSharedRefs = {
|
||||
wsRef: MutableRefObject<WebSocket | null>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||
authUrlRef: MutableRefObject<string>;
|
||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||
@@ -66,6 +69,10 @@ export type UseShellRuntimeResult = {
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
isConnecting: boolean;
|
||||
authUrl: string;
|
||||
authUrlVersion: number;
|
||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
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 {
|
||||
if (!session) {
|
||||
@@ -8,4 +21,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
|
||||
return session.__provider === 'cursor'
|
||||
? session.name || 'Untitled Session'
|
||||
: session.summary || 'New Session';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,8 +59,12 @@ export default function Shell({
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isConnecting,
|
||||
authUrl,
|
||||
authUrlVersion,
|
||||
connectToShell,
|
||||
disconnectFromShell,
|
||||
openAuthUrlInBrowser,
|
||||
copyAuthUrlToClipboard,
|
||||
} = useShellRuntime({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -239,7 +243,15 @@ export default function Shell({
|
||||
if (minimal) {
|
||||
return (
|
||||
<>
|
||||
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
|
||||
<ShellMinimalView
|
||||
terminalContainerRef={terminalContainerRef}
|
||||
authUrl={authUrl}
|
||||
authUrlVersion={authUrlVersion}
|
||||
initialCommand={initialCommand}
|
||||
isConnected={isConnected}
|
||||
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
||||
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
||||
/>
|
||||
<TerminalShortcutsPanel
|
||||
wsRef={wsRef}
|
||||
terminalRef={terminalRef}
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { AuthCopyStatus } from '../../types/types';
|
||||
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
||||
|
||||
type ShellMinimalViewProps = {
|
||||
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({
|
||||
terminalContainerRef,
|
||||
authUrl,
|
||||
authUrlVersion,
|
||||
initialCommand,
|
||||
isConnected,
|
||||
openAuthUrlInBrowser,
|
||||
copyAuthUrlToClipboard,
|
||||
}: 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 (
|
||||
<div className="relative h-full w-full bg-gray-900">
|
||||
<div
|
||||
@@ -14,6 +47,67 @@ export default function ShellMinimalView({
|
||||
className="h-full w-full focus: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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const MOBILE_KEYS: Shortcut[] = [
|
||||
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
||||
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
||||
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
||||
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
|
||||
];
|
||||
|
||||
const ARROW_ICONS = {
|
||||
|
||||
@@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
||||
parts.push(snippet.slice(cursor));
|
||||
}
|
||||
return (
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 break-words text-xs leading-relaxed text-muted-foreground">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
||||
import { Badge, Tooltip, buttonVariants } from '../../../../shared/view/ui';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider } from '../../types/types';
|
||||
@@ -195,9 +195,10 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<a
|
||||
href={`/session/${session.id}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||
!isSelected && isProcessing
|
||||
@@ -206,7 +207,13 @@ export default function SidebarSessionItem({
|
||||
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
||||
: 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => onSessionSelect(session, project.projectId)}
|
||||
// Left-click keeps in-app navigation; Ctrl/Cmd/middle-click and the
|
||||
// native right-click menu use the href to open a new tab/window.
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
onSessionSelect(session, project.projectId);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<div
|
||||
@@ -249,7 +256,7 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<div
|
||||
ref={editingContainerRef}
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react';
|
||||
|
||||
export type PaletteOps = {
|
||||
openFile: (path: string) => void;
|
||||
// Opens a file in the editor side panel without changing the active tab
|
||||
// (used by in-chat file links so they behave like the inline edit view).
|
||||
openFileInEditor: (path: string) => void;
|
||||
openSettings: (tab?: string) => void;
|
||||
refreshProjects: () => Promise<void> | void;
|
||||
};
|
||||
@@ -13,6 +16,7 @@ const PaletteOpsContext = createContext<Registry | null>(null);
|
||||
|
||||
const defaultOps: PaletteOps = {
|
||||
openFile: () => undefined,
|
||||
openFileInEditor: () => undefined,
|
||||
openSettings: () => undefined,
|
||||
refreshProjects: () => undefined,
|
||||
};
|
||||
@@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps {
|
||||
return useMemo<PaletteOps>(
|
||||
() => ({
|
||||
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
||||
openFileInEditor: (path) =>
|
||||
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
|
||||
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
||||
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
||||
}),
|
||||
@@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps {
|
||||
|
||||
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
||||
const ref = useContext(PaletteOpsContext);
|
||||
const { openFile, openSettings, refreshProjects } = partial;
|
||||
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref) return undefined;
|
||||
const prev = { ...ref.current };
|
||||
if (openFile) ref.current.openFile = openFile;
|
||||
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
|
||||
if (openSettings) ref.current.openSettings = openSettings;
|
||||
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
||||
return () => {
|
||||
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
||||
if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor;
|
||||
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
||||
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
||||
};
|
||||
}, [ref, openFile, openSettings, refreshProjects]);
|
||||
}, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
|
||||
}
|
||||
|
||||
108
src/hooks/useFileOpenResolver.ts
Normal file
108
src/hooks/useFileOpenResolver.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import type { Project } from '../types/app';
|
||||
|
||||
type FileNode = {
|
||||
type: 'file' | 'directory';
|
||||
name: string;
|
||||
path: string;
|
||||
children?: FileNode[];
|
||||
};
|
||||
|
||||
type FlatFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers
|
||||
// that expect a concrete diff payload type as well as generic callers.
|
||||
type OnFileOpen = (filePath: string, diffInfo?: any) => void;
|
||||
|
||||
const normalize = (value: string): string => value.replace(/\\/g, '/');
|
||||
|
||||
const flatten = (nodes: FileNode[], out: FlatFile[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'file') {
|
||||
out.push({ name: node.name, path: node.path });
|
||||
} else if (node.children && node.children.length > 0) {
|
||||
flatten(node.children, out);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// References inside chat messages are often bare basenames (`foo.ts`) or partial
|
||||
// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and
|
||||
// fall back to filename equality.
|
||||
const findBestMatch = (files: FlatFile[], ref: string): string | null => {
|
||||
const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffixMatch = files.find((file) => {
|
||||
const filePath = normalize(file.path);
|
||||
return filePath === target || filePath.endsWith(`/${target}`);
|
||||
});
|
||||
if (suffixMatch) {
|
||||
return suffixMatch.path;
|
||||
}
|
||||
|
||||
const base = target.split('/').pop() || target;
|
||||
return files.find((file) => file.name === base)?.path ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps an `onFileOpen` handler so a possibly bare/partial file reference is
|
||||
* resolved against the project's file tree (cached per project) before the file
|
||||
* is opened in the in-app editor.
|
||||
*/
|
||||
export function useFileOpenResolver(
|
||||
selectedProject: Project | null | undefined,
|
||||
onFileOpen: OnFileOpen,
|
||||
): OnFileOpen {
|
||||
const projectId = selectedProject?.projectId;
|
||||
const cacheRef = useRef<{ projectId?: string; files: Promise<FlatFile[]> | null }>({
|
||||
projectId: undefined,
|
||||
files: null,
|
||||
});
|
||||
|
||||
const loadFiles = useCallback((): Promise<FlatFile[]> => {
|
||||
if (!projectId) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (cacheRef.current.projectId === projectId && cacheRef.current.files) {
|
||||
return cacheRef.current.files;
|
||||
}
|
||||
|
||||
const filesPromise = (async () => {
|
||||
try {
|
||||
const response = await api.getFiles(projectId);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
const tree: FileNode[] = Array.isArray(data) ? data : [];
|
||||
const flat: FlatFile[] = [];
|
||||
flatten(tree, flat);
|
||||
return flat;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
cacheRef.current = { projectId, files: filesPromise };
|
||||
return filesPromise;
|
||||
}, [projectId]);
|
||||
|
||||
return useCallback(
|
||||
(filePath: string, diffInfo?: any) => {
|
||||
const ref = normalize(filePath).trim();
|
||||
void loadFiles().then((files) => {
|
||||
const match = findBestMatch(files, ref);
|
||||
onFileOpen(match ?? filePath, diffInfo);
|
||||
});
|
||||
},
|
||||
[loadFiles, onFileOpen],
|
||||
);
|
||||
}
|
||||
@@ -137,12 +137,6 @@
|
||||
height: 100%;
|
||||
margin: 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 */
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
className="w-[100px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="w-auto min-w-[120px] max-w-[160px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
|
||||
Reference in New Issue
Block a user