[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
66
components/ai-elements/LazyMessageResponse.tsx
Normal file
66
components/ai-elements/LazyMessageResponse.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { lazy, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
enqueueChatMarkdownHydrate,
|
||||
isAiMarkdownRendererReady,
|
||||
scheduleWhenAiComposerIdle,
|
||||
subscribeAiMarkdownRendererReady,
|
||||
warmAiMarkdownRenderer,
|
||||
} from '../ai/aiMarkdownWarmup';
|
||||
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
|
||||
|
||||
type LazyMessageResponseProps = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
isAnimating?: boolean;
|
||||
/**
|
||||
* Keep plaintext until Streamdown is already warmed. Chat history uses this
|
||||
* so expanding the panel cannot start the ~350KB parse during first typing.
|
||||
*/
|
||||
deferUntilWarm?: boolean;
|
||||
};
|
||||
|
||||
const MessageResponse = lazy(() =>
|
||||
import('./messageResponse').then((module) => ({ default: module.MessageResponse })),
|
||||
);
|
||||
|
||||
const PlainTextFallback = ({ children, className }: LazyMessageResponseProps) => (
|
||||
<div className={cn('size-full whitespace-pre-wrap break-words', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export function LazyMessageResponse(props: LazyMessageResponseProps) {
|
||||
const { deferUntilWarm = false, ...rendererProps } = props;
|
||||
const [ready, setReady] = useState(() => !deferUntilWarm && isAiMarkdownRendererReady());
|
||||
const resetKey = typeof rendererProps.children === 'string' ? rendererProps.children : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) return undefined;
|
||||
if (!deferUntilWarm) {
|
||||
const unsubscribe = subscribeAiMarkdownRendererReady(() => setReady(true));
|
||||
if (isAiMarkdownRendererReady()) return unsubscribe;
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void warmAiMarkdownRenderer();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
cancelIdle();
|
||||
};
|
||||
}
|
||||
return enqueueChatMarkdownHydrate(() => setReady(true));
|
||||
}, [deferUntilWarm, ready]);
|
||||
|
||||
if (deferUntilWarm && !ready) {
|
||||
return <PlainTextFallback {...rendererProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyLoadBoundary fallback={<PlainTextFallback {...rendererProps} />} resetKey={resetKey}>
|
||||
<Suspense fallback={<PlainTextFallback {...rendererProps} />}>
|
||||
<MessageResponse {...rendererProps} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
}
|
||||
59
components/ai-elements/conversation.tsx
Normal file
59
components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
|
||||
initial="instant"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
scrollToBottom();
|
||||
}, [onClick, scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
'shadow-sm',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
13
components/ai-elements/hasMarkdownCodeFence.test.ts
Normal file
13
components/ai-elements/hasMarkdownCodeFence.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { hasMarkdownCodeFence } from './hasMarkdownCodeFence';
|
||||
|
||||
test('detects common markdown fences', () => {
|
||||
assert.equal(hasMarkdownCodeFence('hello'), false);
|
||||
assert.equal(hasMarkdownCodeFence('use `code` inline'), false);
|
||||
assert.equal(hasMarkdownCodeFence('```ts\nconst a = 1\n```'), true);
|
||||
assert.equal(hasMarkdownCodeFence('prefix\n```\nplain\n```'), true);
|
||||
assert.equal(hasMarkdownCodeFence('~~~\nbash\n~~~'), true);
|
||||
assert.equal(hasMarkdownCodeFence(' ```json\n{}\n```'), true);
|
||||
});
|
||||
6
components/ai-elements/hasMarkdownCodeFence.ts
Normal file
6
components/ai-elements/hasMarkdownCodeFence.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const FENCE_RE = /(?:^|\n)\s{0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/** True when markdown contains a fenced code block that may want Shiki. */
|
||||
export function hasMarkdownCodeFence(text: string): boolean {
|
||||
return FENCE_RE.test(text);
|
||||
}
|
||||
11
components/ai-elements/message.tsx
Normal file
11
components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Barrel re-export. Prefer messageShell (layout) or LazyMessageResponse (markdown). */
|
||||
export {
|
||||
Message,
|
||||
MessageContent,
|
||||
type MessageContentProps,
|
||||
type MessageProps,
|
||||
} from './messageShell';
|
||||
export {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from './messageResponse';
|
||||
20
components/ai-elements/messageResponse.test.ts
Normal file
20
components/ai-elements/messageResponse.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('messageResponse does not statically import Shiki', () => {
|
||||
const source = readFileSync(new URL('./messageResponse.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /from ['"]@streamdown\/code['"]/);
|
||||
assert.match(source, /from ['"]@streamdown\/cjk['"]/);
|
||||
assert.match(source, /hasMarkdownCodeFence/);
|
||||
assert.match(source, /warmAiCodeHighlighter/);
|
||||
assert.match(source, /scheduleWhenAiComposerIdle/);
|
||||
});
|
||||
|
||||
test('Shiki lives in an isolated plugin module', () => {
|
||||
const plugin = readFileSync(new URL('./streamdownCodePlugin.ts', import.meta.url), 'utf8');
|
||||
const warmup = readFileSync(new URL('./streamdownCodeWarmup.ts', import.meta.url), 'utf8');
|
||||
assert.match(plugin, /from ['"]@streamdown\/code['"]/);
|
||||
assert.match(warmup, /import\('\.\/streamdownCodePlugin'\)/);
|
||||
assert.doesNotMatch(warmup, /from ['"]@streamdown\/code['"]/);
|
||||
});
|
||||
75
components/ai-elements/messageResponse.tsx
Normal file
75
components/ai-elements/messageResponse.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { cjk } from '@streamdown/cjk';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
import { scheduleWhenAiComposerIdle } from '../ai/aiMarkdownWarmup';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { hasMarkdownCodeFence } from './hasMarkdownCodeFence';
|
||||
import {
|
||||
getCachedStreamdownCodePlugin,
|
||||
warmAiCodeHighlighter,
|
||||
} from './streamdownCodeWarmup';
|
||||
|
||||
const STREAMDOWN_CLASS = [
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
|
||||
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
|
||||
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_hr]:border-border/30 [&_hr]:my-3',
|
||||
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
|
||||
].join(' ');
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
function MessageResponseView({ className, children, ...props }: MessageResponseProps) {
|
||||
const [codePlugin, setCodePlugin] = useState(getCachedStreamdownCodePlugin);
|
||||
const source = typeof children === 'string' ? children : '';
|
||||
const wantsCode = hasMarkdownCodeFence(source);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsCode || codePlugin) return undefined;
|
||||
let cancelled = false;
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void warmAiCodeHighlighter().then((plugin) => {
|
||||
if (!cancelled) setCodePlugin(plugin);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [codePlugin, wantsCode]);
|
||||
|
||||
const plugins = useMemo(
|
||||
() => (codePlugin ? { cjk, code: codePlugin } : { cjk }),
|
||||
[codePlugin],
|
||||
);
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(STREAMDOWN_CLASS, className)}
|
||||
plugins={plugins}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
||||
/** Streamdown + CJK only. Shiki loads later when a fence exists and the composer is idle. */
|
||||
export const MessageResponse = memo(
|
||||
MessageResponseView,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
);
|
||||
MessageResponse.displayName = 'MessageResponse';
|
||||
43
components/ai-elements/messageShell.tsx
Normal file
43
components/ai-elements/messageShell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
// Public CSS hooks for user customization (Settings → Appearance → Custom CSS):
|
||||
// .ai-chat-message[data-role="user"] — outer row, user-authored
|
||||
// .ai-chat-message[data-role="assistant"] — outer row, assistant reply
|
||||
// .ai-chat-message-content[data-role=...] — inner bubble / content area
|
||||
// These attributes are part of the UI's stable contract; do not rename
|
||||
// without updating Custom CSS docs.
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'ai-chat-message group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const MessageContent = ({ children, className, from, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'ai-chat-message-content flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-[7px]',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
15
components/ai-elements/prompt-input.test.tsx
Normal file
15
components/ai-elements/prompt-input.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { shouldSubmitPromptInput } from './prompt-input';
|
||||
|
||||
test('requires text by default', () => {
|
||||
assert.equal(shouldSubmitPromptInput(''), false);
|
||||
assert.equal(shouldSubmitPromptInput(' '), false);
|
||||
assert.equal(shouldSubmitPromptInput('continue'), true);
|
||||
});
|
||||
|
||||
test('allows an empty textarea when attachment context is submittable', () => {
|
||||
assert.equal(shouldSubmitPromptInput('', true), true);
|
||||
assert.equal(shouldSubmitPromptInput(' ', true), true);
|
||||
});
|
||||
228
components/ai-elements/prompt-input.tsx
Normal file
228
components/ai-elements/prompt-input.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
|
||||
*
|
||||
* Simplified: no file attachments, screenshots, drag-drop, command palette,
|
||||
* hover cards, referenced sources, or tabs. Core input + footer + submit.
|
||||
*/
|
||||
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '../ui/input-group';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInput (form wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
|
||||
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
/** Allow attachments that carry their own prompt context to submit without textarea text. */
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Optional styling for the bordered input group inside the form. */
|
||||
inputGroupClassName?: string;
|
||||
}
|
||||
|
||||
export const shouldSubmitPromptInput = (text: string, allowEmptySubmit = false): boolean =>
|
||||
Boolean(text.trim()) || allowEmptySubmit;
|
||||
|
||||
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
||||
({ className, onSubmit, allowEmptySubmit = false, inputGroupClassName, children, ...props }, ref) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const textarea = form.querySelector('textarea');
|
||||
const text = textarea?.value?.trim() ?? '';
|
||||
if (!shouldSubmitPromptInput(text, allowEmptySubmit)) return;
|
||||
onSubmit(text, e);
|
||||
},
|
||||
[allowEmptySubmit, onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit}
|
||||
className={className}
|
||||
data-allow-empty-submit={allowEmptySubmit ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
<InputGroup className={inputGroupClassName}>{children}</InputGroup>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInput.displayName = 'PromptInput';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTextarea
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
|
||||
/** Called when Enter is pressed (without Shift) to trigger form submit */
|
||||
onSubmitRequest?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
|
||||
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const setRef = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
internalRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
// CJK composition guard
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmitRequest?.();
|
||||
// Trigger form submit
|
||||
const form = internalRef.current?.closest('form');
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmitRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
ref={setRef}
|
||||
className={className}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputTextarea.displayName = 'PromptInputTextarea';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputFooter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<InputGroupAddon
|
||||
ref={ref}
|
||||
align="block-end"
|
||||
className={cn('gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputFooter.displayName = 'PromptInputFooter';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTools (left side of footer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-0.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputTools.displayName = 'PromptInputTools';
|
||||
|
||||
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
||||
status?: PromptInputStatus;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
|
||||
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
|
||||
const isRunning = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRunning && onStop) {
|
||||
onStop();
|
||||
}
|
||||
}, [isRunning, onStop]);
|
||||
|
||||
const icon =
|
||||
status === 'submitted' ? (
|
||||
<Spinner size={14} />
|
||||
) : status === 'streaming' ? (
|
||||
<Square size={14} />
|
||||
) : status === 'error' ? (
|
||||
<X size={14} />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
);
|
||||
|
||||
const tooltipLabel =
|
||||
status === 'submitted'
|
||||
? 'Waiting...'
|
||||
: status === 'streaming'
|
||||
? 'Stop'
|
||||
: status === 'error'
|
||||
? 'Error'
|
||||
: 'Send';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InputGroupButton
|
||||
ref={ref}
|
||||
type={isRunning ? 'button' : 'submit'}
|
||||
onClick={isRunning ? handleClick : undefined}
|
||||
aria-label={tooltipLabel}
|
||||
variant="ghost"
|
||||
disabled={disabled && !isRunning}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
|
||||
isRunning
|
||||
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
|
||||
: disabled
|
||||
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
|
||||
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</InputGroupButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
77
components/ai-elements/streamdownCodeHighlighter.ts
Normal file
77
components/ai-elements/streamdownCodeHighlighter.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
CodeHighlighterPlugin,
|
||||
HighlightOptions,
|
||||
} from 'streamdown';
|
||||
import type { BundledLanguage } from 'shiki';
|
||||
|
||||
type HighlightResult = NonNullable<ReturnType<CodeHighlighterPlugin['highlight']>>;
|
||||
|
||||
const PLAIN_TEXT_LANGUAGES = new Set([
|
||||
'',
|
||||
'plain',
|
||||
'plaintext',
|
||||
'text',
|
||||
'txt',
|
||||
]);
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
cfg: 'ini',
|
||||
conf: 'ini',
|
||||
config: 'ini',
|
||||
};
|
||||
|
||||
export const createPlainCodeHighlightResult = (source: string): HighlightResult => {
|
||||
const code = source.replace(/\n+$/, '');
|
||||
return {
|
||||
bg: 'transparent',
|
||||
fg: 'inherit',
|
||||
tokens: code.split('\n').map((line) => [
|
||||
{
|
||||
content: line,
|
||||
color: 'inherit',
|
||||
bgColor: 'transparent',
|
||||
htmlStyle: {},
|
||||
offset: 0,
|
||||
},
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeLanguageKey = (language: string): string =>
|
||||
language.trim().toLowerCase();
|
||||
|
||||
export const resolveSupportedCodeLanguage = (
|
||||
highlighter: CodeHighlighterPlugin,
|
||||
language: string,
|
||||
): BundledLanguage | null => {
|
||||
const key = normalizeLanguageKey(language);
|
||||
if (PLAIN_TEXT_LANGUAGES.has(key)) return null;
|
||||
|
||||
const direct = key as BundledLanguage;
|
||||
if (highlighter.supportsLanguage(direct)) return direct;
|
||||
|
||||
const alias = LANGUAGE_ALIASES[key];
|
||||
if (alias && highlighter.supportsLanguage(alias)) return alias;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createSafeCodeHighlighter = (
|
||||
highlighter: CodeHighlighterPlugin,
|
||||
): CodeHighlighterPlugin => ({
|
||||
...highlighter,
|
||||
supportsLanguage(language) {
|
||||
return resolveSupportedCodeLanguage(highlighter, language) !== null;
|
||||
},
|
||||
highlight(options: HighlightOptions, callback?: (result: HighlightResult) => void) {
|
||||
const supportedLanguage = resolveSupportedCodeLanguage(highlighter, options.language);
|
||||
if (!supportedLanguage) {
|
||||
return createPlainCodeHighlightResult(options.code);
|
||||
}
|
||||
|
||||
return highlighter.highlight(
|
||||
{ ...options, language: supportedLanguage },
|
||||
callback,
|
||||
);
|
||||
},
|
||||
});
|
||||
6
components/ai-elements/streamdownCodePlugin.ts
Normal file
6
components/ai-elements/streamdownCodePlugin.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { code } from '@streamdown/code';
|
||||
|
||||
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
|
||||
|
||||
/** Isolated Shiki entry — import this file only through warmAiCodeHighlighter. */
|
||||
export const streamdownCodePlugin = createSafeCodeHighlighter(code);
|
||||
24
components/ai-elements/streamdownCodeWarmup.ts
Normal file
24
components/ai-elements/streamdownCodeWarmup.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CodeHighlighterPlugin } from 'streamdown';
|
||||
|
||||
let cachedCodePlugin: CodeHighlighterPlugin | null = null;
|
||||
let codePluginPromise: Promise<CodeHighlighterPlugin> | null = null;
|
||||
|
||||
export function getCachedStreamdownCodePlugin(): CodeHighlighterPlugin | null {
|
||||
return cachedCodePlugin;
|
||||
}
|
||||
|
||||
export function isAiCodeHighlighterReady(): boolean {
|
||||
return cachedCodePlugin != null;
|
||||
}
|
||||
|
||||
/** Prefetch Shiki only when a message actually contains a code fence. */
|
||||
export function warmAiCodeHighlighter(): Promise<CodeHighlighterPlugin> {
|
||||
codePluginPromise ??= import('./streamdownCodePlugin').then((module) => {
|
||||
cachedCodePlugin = module.streamdownCodePlugin;
|
||||
return module.streamdownCodePlugin;
|
||||
}, (error) => {
|
||||
codePluginPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return codePluginPromise;
|
||||
}
|
||||
221
components/ai-elements/tool-call.test.tsx
Normal file
221
components/ai-elements/tool-call.test.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
approvalArgsHaveExtraContext,
|
||||
approvalCommandWasUnwrapped,
|
||||
extractApprovalExecutionContext,
|
||||
extractDisplayCommand,
|
||||
isNestedInteractiveApprovalTarget,
|
||||
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
|
||||
truncateToolCommandTooltip,
|
||||
} from './tool-call';
|
||||
|
||||
const toolCallSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'tool-call.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
|
||||
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
|
||||
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
|
||||
// outer quote leaks (the "netcatty: \"" / "netcatty: …md\"" garbage titles).
|
||||
|
||||
test('unwraps a /bin/zsh -lc string wrapper (codex SDK shape)', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({ command: `/bin/zsh -lc 'echo "hi"'` }),
|
||||
'echo "hi"',
|
||||
);
|
||||
});
|
||||
|
||||
test('codex Skills+CLI exec: unwrap shell + netcatty-cli -> remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
});
|
||||
|
||||
test('codex Skills+CLI session subcommand -> friendly title', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" session --session X'`,
|
||||
}),
|
||||
'netcatty: inspect session',
|
||||
);
|
||||
});
|
||||
|
||||
test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({ command: `"/abs/netcatty-tool-cli" exec --session X -- "uptime"` }),
|
||||
'uptime',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cjs wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli.cjs" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"/Resources/netcatty-tool-cli.cjs" exec --session X -- "df -h"`,
|
||||
}),
|
||||
'df -h',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cmd wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"C:\\\\App\\\\netcatty-tool-cli.cmd" exec --session X -- "whoami"`,
|
||||
}),
|
||||
'whoami',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli env -> list sessions', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
|
||||
});
|
||||
|
||||
test('array shell-wrap shape still unwraps (regression)', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({ command: ['zsh', '-lc', 'ls -la /tmp'] }),
|
||||
'ls -la /tmp',
|
||||
);
|
||||
});
|
||||
|
||||
test('plain command passes through unchanged', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'ls -la /tmp' }), 'ls -la /tmp');
|
||||
});
|
||||
|
||||
test('limits long command tooltips to a compact single-line preview', () => {
|
||||
const tooltip = truncateToolCommandTooltip(` echo first\n${'x'.repeat(300)} `);
|
||||
assert.equal(tooltip.length, MAX_TOOL_COMMAND_TOOLTIP_CHARS);
|
||||
assert.equal(tooltip.endsWith('…'), true);
|
||||
assert.equal(tooltip.includes('\n'), false);
|
||||
});
|
||||
|
||||
test('empty / missing args -> null', () => {
|
||||
assert.equal(extractDisplayCommand(undefined), null);
|
||||
assert.equal(extractDisplayCommand({ command: '' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces session/cwd/shell without rewriting command', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
sessionId: 'term-1',
|
||||
cwd: '/var/log',
|
||||
command: ['zsh', '-lc', 'df -h | sort'],
|
||||
}),
|
||||
{ sessionId: 'term-1', cwd: '/var/log', shell: 'zsh', reason: undefined },
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/bash -lc 'uptime'`,
|
||||
}),
|
||||
{ sessionId: undefined, cwd: undefined, shell: 'bash', reason: undefined },
|
||||
);
|
||||
assert.equal(extractApprovalExecutionContext({ path: '/tmp' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext reads --session from netcatty-tool-cli wrappers', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session term-9 --chat-session chat-1 -- "uptime"'`,
|
||||
}),
|
||||
{ sessionId: 'term-9', cwd: undefined, shell: 'zsh', reason: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces Codex reason for approval review', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: 'rm -rf /tmp/x',
|
||||
cwd: '/tmp',
|
||||
reason: 'Clean stale build artifacts',
|
||||
commandActions: [{ type: 'delete' }],
|
||||
}),
|
||||
{
|
||||
sessionId: undefined,
|
||||
cwd: '/tmp',
|
||||
shell: undefined,
|
||||
reason: 'Clean stale build artifacts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('approvalCommandWasUnwrapped detects Skills+CLI display unwrap', () => {
|
||||
const args = {
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
};
|
||||
const display = extractDisplayCommand(args);
|
||||
assert.equal(display, 'uptime');
|
||||
assert.equal(approvalCommandWasUnwrapped(args, display), true);
|
||||
assert.equal(approvalCommandWasUnwrapped({ command: 'uptime' }, 'uptime'), false);
|
||||
});
|
||||
|
||||
test('approvalArgsHaveExtraContext keeps commandActions visible beside the command block', () => {
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({
|
||||
command: 'echo hi',
|
||||
reason: 'demo',
|
||||
commandActions: [{ type: 'read' }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({ command: 'echo hi', cwd: '/tmp', reason: 'demo' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isNestedInteractiveApprovalTarget ignores Enter on Copy/Expand review controls', () => {
|
||||
const card = { id: 'card' };
|
||||
const copyBtn = {
|
||||
closest: (selector: string) => (selector.includes('button') ? copyBtn : null),
|
||||
};
|
||||
const plainSpan = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
assert.equal(isNestedInteractiveApprovalTarget(copyBtn, card), true);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(plainSpan, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(card, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(null, card), false);
|
||||
});
|
||||
|
||||
// Pending approvals with no display command (Codex file-change/permissions,
|
||||
// write tools with JSON-only args) only render the overflow args <pre>. Wheel /
|
||||
// trackpad scroll must re-arm idle the same way the command block does.
|
||||
test('args overflow block re-arms review timers on scroll/wheel while pending', () => {
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/JSON\.stringify\(args,\s*null,\s*2\)[\s\S]{0,200}?<\/pre>/,
|
||||
);
|
||||
// Both review surfaces (command + args) wire markReviewing for continuous scroll.
|
||||
const markReviewingScrollBindings = toolCallSource.match(
|
||||
/onScroll=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
const markReviewingWheelBindings = toolCallSource.match(
|
||||
/onWheel=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingScrollBindings && markReviewingScrollBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onScroll',
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingWheelBindings && markReviewingWheelBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onWheel',
|
||||
);
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/onScroll=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}onWheel=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}JSON\.stringify\(args/,
|
||||
);
|
||||
});
|
||||
691
components/ai-elements/tool-call.tsx
Normal file
691
components/ai-elements/tool-call.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
|
||||
/** Collapsed approval command block max height (px). Full text remains scrollable. */
|
||||
export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144;
|
||||
/** Expanded approval command block max height (px). */
|
||||
export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384;
|
||||
/** Prefer expand control when the raw command exceeds this many characters. */
|
||||
export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180;
|
||||
|
||||
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
|
||||
|
||||
/**
|
||||
* Enter on the pending-card root means Approve Once. Enter on nested review
|
||||
* controls (Copy / Expand / action buttons) must not approve — those controls
|
||||
* also stopPropagation on Enter so the card handler is a second line of defense.
|
||||
*/
|
||||
export function isNestedInteractiveApprovalTarget(
|
||||
target: { closest?: (selector: string) => unknown } | null,
|
||||
currentTarget: unknown,
|
||||
): boolean {
|
||||
if (!target || target === currentTarget) return false;
|
||||
if (typeof target.closest !== 'function') return false;
|
||||
return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function truncateToolCommandTooltip(
|
||||
command: string,
|
||||
maxChars = MAX_TOOL_COMMAND_TOOLTIP_CHARS,
|
||||
): string {
|
||||
const normalized = command.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
if (maxChars <= 1) return '…'.slice(0, maxChars);
|
||||
return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the user-meaningful shell command out of the tool-call args.
|
||||
*
|
||||
* Different tool surfaces hand us different shapes:
|
||||
* - Netcatty's own `terminal_execute` MCP tool → `{command: "<string>"}`
|
||||
* - Codex `local_shell` → `{command: ["zsh","-lc","<full>"]}`
|
||||
* - Codex command_execution (SDK) → `{command: "/bin/zsh -lc '<full>'"}`
|
||||
* - Claude `Bash` → `{command: "<string>"}`
|
||||
*
|
||||
* The SDK form is a STRING that wraps the real command in `<shell> -lc '<full>'`,
|
||||
* so we unwrap that wrapper too (the array branch already did the equivalent) —
|
||||
* otherwise the outer shell quotes leak into the title.
|
||||
*
|
||||
* And under the "Skill + CLI" integration, the agent's shell tool wraps a
|
||||
* call to our internal `netcatty-tool-cli` binary, so the real intent is one
|
||||
* level deeper:
|
||||
*
|
||||
* netcatty-tool-cli exec --session <id> --chat-session <id> -- <real-cmd>
|
||||
*
|
||||
* We unwrap both layers so the chat panel shows what the user actually
|
||||
* cares about (the remote command), not Codex's wrapper title which is
|
||||
* just the local path to the CLI binary.
|
||||
*/
|
||||
export function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
|
||||
let cmdString: string;
|
||||
if (typeof raw === 'string') {
|
||||
if (!raw) return null;
|
||||
cmdString = raw;
|
||||
} else if (Array.isArray(raw) && raw.length > 0) {
|
||||
const isShellWrap =
|
||||
raw.length >= 3 &&
|
||||
/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/.test(String(raw[0] ?? '')) &&
|
||||
/^-l?c$/.test(String(raw[1] ?? ''));
|
||||
cmdString = isShellWrap
|
||||
? String(raw[raw.length - 1] ?? '')
|
||||
: raw.map((p) => String(p)).join(' ');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unwrap a STRING shell wrapper, e.g. Codex SDK's `/bin/zsh -lc '<full>'`.
|
||||
// The array branch above already extracts the inner command; the string form
|
||||
// (codex command_execution) does not, so strip `<shell> -l?c <quote>…<quote>`
|
||||
// here. Without this the outer quote leaks into the netcatty-cli title below.
|
||||
const strWrap = cmdString.match(
|
||||
/^(?:\S*\/)?(?:sh|bash|zsh|fish|ash|dash)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/,
|
||||
);
|
||||
if (strWrap) cmdString = strWrap[2];
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
// Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the
|
||||
// optional extension so the subcommand after the binary is still found.
|
||||
const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
if (cliIdx >= 0) {
|
||||
const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length;
|
||||
const afterCli = cmdString
|
||||
.slice(cliIdx + cliTokenLen)
|
||||
.replace(/^["']?\s*/, '');
|
||||
const subMatch = afterCli.match(/^(\S+)/);
|
||||
const sub = subMatch ? subMatch[1] : '';
|
||||
|
||||
if (sub === 'exec' || sub === 'job-start') {
|
||||
// Pull out the command after the ` -- ` separator.
|
||||
const dashIdx = afterCli.indexOf(' -- ');
|
||||
if (dashIdx >= 0) {
|
||||
let inner = afterCli.slice(dashIdx + 4).trim();
|
||||
if (
|
||||
inner.length >= 2 &&
|
||||
((inner[0] === '"' && inner.endsWith('"')) ||
|
||||
(inner[0] === "'" && inner.endsWith("'")))
|
||||
) {
|
||||
inner = inner.slice(1, -1);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
if (sub === 'job-poll') return 'netcatty: poll job';
|
||||
if (sub === 'job-stop') return 'netcatty: stop job';
|
||||
if (sub === 'session') return 'netcatty: inspect session';
|
||||
if (sub === 'env') return 'netcatty: list sessions';
|
||||
if (sub === 'status') return 'netcatty: status';
|
||||
if (sub) return `netcatty: ${sub}`;
|
||||
}
|
||||
|
||||
return cmdString;
|
||||
}
|
||||
|
||||
export interface ApprovalExecutionContext {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rawCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' ');
|
||||
return null;
|
||||
}
|
||||
|
||||
const APPROVAL_CONTEXT_ARG_KEYS = new Set([
|
||||
'command',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workdir',
|
||||
'workingDirectory',
|
||||
'sessionId',
|
||||
'shell',
|
||||
'reason',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when pending args still carry review-relevant fields beyond the
|
||||
* command block / execution-context strip (e.g. commandActions).
|
||||
*/
|
||||
export function approvalArgsHaveExtraContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!args) return false;
|
||||
return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reviewable display command was unwrapped from a Skills+CLI /
|
||||
* shell wrapper — the pending card should still surface target flags.
|
||||
*/
|
||||
export function approvalCommandWasUnwrapped(
|
||||
args: Record<string, unknown> | undefined,
|
||||
displayCommand: string | null,
|
||||
): boolean {
|
||||
if (!displayCommand) return false;
|
||||
const raw = rawCommandString(args);
|
||||
if (!raw || raw === displayCommand) return false;
|
||||
return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw)
|
||||
|| (Array.isArray(args?.command) && args.command.length >= 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort execution context for approval review (session / cwd / shell).
|
||||
* Never invents host names; only surfaces fields already present on tool args
|
||||
* or explicit netcatty-tool-cli flags in the command string.
|
||||
*/
|
||||
export function extractApprovalExecutionContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): ApprovalExecutionContext | null {
|
||||
if (!args) return null;
|
||||
|
||||
let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined;
|
||||
|
||||
let shell = typeof args.shell === 'string' && args.shell.trim()
|
||||
? args.shell.trim()
|
||||
: undefined;
|
||||
|
||||
const reason = typeof args.reason === 'string' && args.reason.trim()
|
||||
? args.reason.trim()
|
||||
: undefined;
|
||||
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (!shell) {
|
||||
if (Array.isArray(raw) && raw.length >= 2) {
|
||||
const first = String(raw[0] ?? '');
|
||||
const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
} else if (typeof raw === 'string') {
|
||||
const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap.
|
||||
if (!sessionId) {
|
||||
const cmd = rawCommandString(args);
|
||||
if (cmd && cmd.includes('netcatty-tool-cli')) {
|
||||
const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
||||
const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3];
|
||||
if (fromFlag) sessionId = fromFlag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId && !cwd && !shell && !reason) return null;
|
||||
return { sessionId, cwd, shell, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
*/
|
||||
function formatToolResult(result: unknown): string {
|
||||
let parsed = result;
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(parsed);
|
||||
if (obj && typeof obj === 'object') parsed = obj;
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
|
||||
const parts: string[] = [];
|
||||
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
|
||||
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
|
||||
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
|
||||
parts.push(`exit code: ${obj.exitCode}`);
|
||||
}
|
||||
if (parts.length > 0) return parts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
className?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Pending approval id used to cancel the auto-deny timer on review. */
|
||||
approvalId?: string;
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
onReject?: () => void;
|
||||
/** Called when user approves once without persisting a grant rule. */
|
||||
onApproveOnce?: () => void;
|
||||
/** Called when user approves and persists an always-allow grant rule. */
|
||||
onAlwaysAllow?: () => void;
|
||||
/** Optional source-specific label for the persistent/session approval action. */
|
||||
alwaysAllowLabel?: string;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commandExpanded, setCommandExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [frozenCommand, setFrozenCommand] = useState<string | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
const liveDisplayCommand = extractDisplayCommand(args);
|
||||
const reviewCommand = isPendingApproval
|
||||
? (frozenCommand ?? liveDisplayCommand)
|
||||
: liveDisplayCommand;
|
||||
const executionContext = extractApprovalExecutionContext(args);
|
||||
const showApprovalCommand = Boolean(isPendingApproval && reviewCommand);
|
||||
const showArgsAlongsideCommand = Boolean(
|
||||
showApprovalCommand
|
||||
&& args
|
||||
&& Object.keys(args).length > 0
|
||||
&& (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)),
|
||||
);
|
||||
const commandNeedsExpand = Boolean(
|
||||
reviewCommand
|
||||
&& (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD
|
||||
|| reviewCommand.includes('\n')),
|
||||
);
|
||||
|
||||
// Each review interaction re-arms the Catty idle window (capped by hard
|
||||
// deadline). Do not one-shot cancel — subsequent focus/scroll/key events
|
||||
// must keep extending idle while the user is still deciding.
|
||||
const markReviewing = useCallback(() => {
|
||||
if (!isPendingApproval || !approvalId) return;
|
||||
cancelApprovalTimeout(approvalId);
|
||||
}, [approvalId, isPendingApproval]);
|
||||
|
||||
const handleApproveOnce = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
(onApproveOnce ?? onApprove)?.();
|
||||
}, [isPendingApproval, onApproveOnce, onApprove]);
|
||||
|
||||
const handleAlwaysAllow = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
(onAlwaysAllow ?? onApprove)?.();
|
||||
}, [isPendingApproval, onAlwaysAllow, onApprove]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
const handleCopyCommand = useCallback(async () => {
|
||||
if (!reviewCommand) return;
|
||||
markReviewing();
|
||||
const ok = await copyTextToClipboard(reviewCommand);
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}, [markReviewing, reviewCommand]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending).
|
||||
// Ignore Enter from nested controls (Copy / Expand / action buttons) so it
|
||||
// activates that control instead of approving the pending command.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') {
|
||||
if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleApproveOnce();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
} else {
|
||||
// Typing / navigation while reviewing cancels the idle auto-deny timer.
|
||||
markReviewing();
|
||||
}
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending.
|
||||
// Do not treat this programmatic expand/focus as user review (timeout stays armed).
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || !cardRef.current) return;
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
setExpanded(true);
|
||||
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Freeze the reviewable command for the life of this pending approval.
|
||||
// Do not reset review/timeout state when args identity churns while still pending.
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') {
|
||||
setResponded(false);
|
||||
setCommandExpanded(false);
|
||||
setFrozenCommand(extractDisplayCommand(args));
|
||||
return;
|
||||
}
|
||||
setFrozenCommand(null);
|
||||
setCommandExpanded(false);
|
||||
// Intentionally depend only on approvalStatus so late arg patches cannot
|
||||
// replace the command the user is already reviewing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter
|
||||
}, [approvalStatus]);
|
||||
|
||||
// If the first pending paint had no command yet, accept the first non-empty one.
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || frozenCommand) return;
|
||||
const next = extractDisplayCommand(args);
|
||||
if (next) setFrozenCommand(next);
|
||||
}, [args, frozenCommand, isPendingApproval]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: approvalStatus === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: approvalStatus === 'denied'
|
||||
? 'border-red-500/20 bg-red-500/[0.03]'
|
||||
: 'border-border/25 bg-muted/10';
|
||||
const statusIconClass = 'shrink-0';
|
||||
|
||||
const statusIcon = approvalStatus === 'pending' ? (
|
||||
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
const headerCommand = reviewCommand ?? liveDisplayCommand;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
onPointerDownCapture={isPendingApproval ? markReviewing : undefined}
|
||||
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isPendingApproval) markReviewing();
|
||||
setExpanded((e) => !e);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{headerCommand ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{headerCommand}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(headerCommand)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.toolApproved')}
|
||||
</Badge>
|
||||
)}
|
||||
{approvalStatus === 'denied' && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
|
||||
{t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
{statusIcon}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{showApprovalCommand && reviewCommand && (
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{executionContext && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
|
||||
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.targetLabel')}
|
||||
</span>
|
||||
{executionContext.sessionId && (
|
||||
<span className="font-mono truncate" title={executionContext.sessionId}>
|
||||
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.shell && (
|
||||
<span className="font-mono">
|
||||
{t('ai.chat.approvalShell')}: {executionContext.shell}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.cwd && (
|
||||
<span className="font-mono truncate" title={executionContext.cwd}>
|
||||
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.reason && (
|
||||
<span className="truncate" title={executionContext.reason}>
|
||||
{t('ai.chat.approvalReason')}: {executionContext.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.rawCommand')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{commandNeedsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
markReviewing();
|
||||
setCommandExpanded((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Keep Enter on this control; let Escape bubble to card reject.
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{commandExpanded ? t('ai.chat.collapse') : t('ai.chat.expand')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => { void handleCopyCommand(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Copy size={10} className="shrink-0" />
|
||||
{copied ? t('ai.chat.commandCopied') : t('ai.chat.copyCommand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-auto rounded-md border border-border/25 bg-muted/20 px-2.5 py-2',
|
||||
'text-[11px] font-mono leading-relaxed text-foreground/80',
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere]',
|
||||
)}
|
||||
style={{
|
||||
maxHeight: commandExpanded
|
||||
? APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX
|
||||
: APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX,
|
||||
}}
|
||||
onScroll={markReviewing}
|
||||
onWheel={markReviewing}
|
||||
>
|
||||
{reviewCommand}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">
|
||||
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
|
||||
</div>
|
||||
{/*
|
||||
Args-only approvals (Codex file-change/permissions, write tools with
|
||||
JSON args) have no command pre — wheel/trackpad scroll must re-arm
|
||||
idle the same way the command overflow block does.
|
||||
*/}
|
||||
<pre
|
||||
className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]"
|
||||
onScroll={isPendingApproval ? markReviewing : undefined}
|
||||
onWheel={isPendingApproval ? markReviewing : undefined}
|
||||
>
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline approval buttons */}
|
||||
{isPendingApproval && (
|
||||
<div className="min-w-0 px-3 py-2 border-t border-border/20">
|
||||
<p className="mb-2 text-[10px] leading-snug text-muted-foreground/40">
|
||||
{t('ai.chat.toolApprovalHint')}
|
||||
</p>
|
||||
<div className="flex w-full min-w-0 items-stretch gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-red-500/25 text-red-400/90 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/40"
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={12} className="shrink-0" />
|
||||
<span className="truncate">{t('ai.chat.reject')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/25 text-green-400/90 hover:bg-green-500/10 hover:text-green-400 hover:border-green-500/40"
|
||||
onClick={handleApproveOnce}
|
||||
>
|
||||
<Check size={12} className="shrink-0" />
|
||||
<span className="truncate">{t('ai.chat.approveOnce')}</span>
|
||||
</Button>
|
||||
{onAlwaysAllow && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/35 text-green-300/95 hover:bg-green-500/10 hover:text-green-300 hover:border-green-500/50"
|
||||
onClick={handleAlwaysAllow}
|
||||
>
|
||||
<Check size={12} className="shrink-0" />
|
||||
<span className="truncate">{alwaysAllowLabel || t('ai.chat.alwaysAllow')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result !== undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
|
||||
<pre className={cn(
|
||||
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{formatToolResult(result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isInterrupted && result === undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Status</div>
|
||||
<div className="text-[11px] text-muted-foreground/50">
|
||||
Interrupted
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user