Skip to content
17 changes: 13 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,12 +481,21 @@ function createWindow(): void {
const ZOOM_OUT_KEYS = new Set(['-', '_']);
mainWindow.webContents.on('before-input-event', (event, input) => {
if (!mainWindow || mainWindow.isDestroyed()) return;

if (input.type !== 'keyDown') return;

// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
// keyboard handler can use it as "Refresh Session" (fixes #58).
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
if ((input.control || input.meta) && input.key.toLowerCase() === 'r') {
// Intercept Ctrl+R / Cmd+R to prevent Chromium's built-in page reload,
// then notify the renderer via IPC so it can refresh the session (fixes #58, #85).
// We must preventDefault here because Chromium handles Ctrl+R at the browser
// engine level, which also blocks the keydown from reaching the renderer —
// hence the IPC bridge.
if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
mainWindow.webContents.send('session:refresh');
return;
}
// Also block Ctrl+Shift+R (hard reload)
if ((input.control || input.meta) && input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
return;
}
Expand Down
3 changes: 3 additions & 0 deletions src/preload/constants/ipcChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,6 @@ export const WINDOW_IS_MAXIMIZED = 'window:isMaximized';

/** Relaunch the application */
export const APP_RELAUNCH = 'app:relaunch';

/** Refresh session shortcut (main → renderer, triggered by Ctrl+R / Cmd+R) */
export const SESSION_REFRESH = 'session:refresh';
10 changes: 10 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
HTTP_SERVER_GET_STATUS,
HTTP_SERVER_START,
HTTP_SERVER_STOP,
SESSION_REFRESH,
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS,
Expand Down Expand Up @@ -347,6 +348,15 @@ const electronAPI: ElectronAPI = {
};
},

// Session refresh event (Ctrl+R / Cmd+R intercepted by main process)
onSessionRefresh: (callback: () => void): (() => void) => {
const listener = (): void => callback();
ipcRenderer.on(SESSION_REFRESH, listener);
return (): void => {
ipcRenderer.removeListener(SESSION_REFRESH, listener);
};
},

// Shell operations
openPath: (targetPath: string, projectRoot?: string) =>
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/api/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,11 @@ export class HttpAPIClient implements ElectronAPI {
onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) =>
this.addEventListener('todo-change', callback);

// No-op in browser mode — Ctrl+R refresh is Electron-only
onSessionRefresh = (_callback: () => void): (() => void) => {
return () => {};
};

// ---------------------------------------------------------------------------
// Shell operations (browser fallbacks)
// ---------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/components/chat/ChatHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,15 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
checkScrollButton();
}, [conversation, checkScrollButton]);

// Listen for session-refresh-scroll-bottom events (from Ctrl+R / refresh button)
useEffect(() => {
const handler = (): void => {
scrollToBottom('smooth');
};
window.addEventListener('session-refresh-scroll-bottom', handler);
return () => window.removeEventListener('session-refresh-scroll-bottom', handler);
}, [scrollToBottom]);

// Callback to register AI group refs (combines with visibility hook)
const registerAIGroupRefCombined = useCallback(
(groupId: string) => {
Expand Down
4 changes: 0 additions & 4 deletions src/renderer/components/chat/UserChatGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import { User } from 'lucide-react';
import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';

import { CopyButton } from '../common/CopyButton';

import {
createSearchContext,
highlightSearchInChildren,
Expand Down Expand Up @@ -439,8 +437,6 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
boxShadow: 'var(--chat-user-shadow)',
}}
>
<CopyButton text={textContent} bgColor="var(--chat-user-bg)" />

<div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={userMarkdownComponents}>
{displayText}
Expand Down
14 changes: 13 additions & 1 deletion src/renderer/components/chat/items/LinkedToolItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getToolContextTokens,
getToolStatus,
getToolSummary,
hasBashContent,
hasEditContent,
hasReadContent,
hasSkillInstructions,
Expand All @@ -31,6 +32,7 @@ import { Wrench } from 'lucide-react';
import { BaseItem, StatusDot } from './BaseItem';
import { formatDuration } from './baseItemHelpers';
import {
BashToolViewer,
DefaultToolViewer,
EditToolViewer,
ReadToolViewer,
Expand All @@ -39,6 +41,7 @@ import {
WriteToolViewer,
} from './linkedTool';

import type { StepVariant } from '@renderer/constants/stepVariants';
import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups';

interface LinkedToolItemProps {
Expand Down Expand Up @@ -139,7 +142,12 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
const useWriteViewer =
linkedTool.name === 'Write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError;
const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool);
const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer;
const useBashViewer = linkedTool.name === 'Bash' && hasBashContent(linkedTool);
const useDefaultViewer =
!useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer && !useBashViewer;

// Determine step variant for colored borders/icons
const toolVariant: StepVariant = status === 'error' ? 'tool-error' : 'tool';

// Check if we should show error display for Read/Write tools
const showReadError = linkedTool.name === 'Read' && linkedTool.result?.isError;
Expand All @@ -164,6 +172,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
highlightClasses={highlightClasses}
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
variant={toolVariant}
>
{/* Read tool with CodeBlockViewer */}
{useReadViewer && <ReadToolViewer linkedTool={linkedTool} />}
Expand All @@ -177,6 +186,9 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
{/* Skill tool with instructions */}
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}

{/* Bash tool with syntax-highlighted command */}
{useBashViewer && <BashToolViewer linkedTool={linkedTool} status={status} />}

{/* Default rendering for other tools */}
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}

Expand Down
52 changes: 52 additions & 0 deletions src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* BashToolViewer
*
* Renders Bash tool calls with syntax-highlighted command input
* via CodeBlockViewer and collapsible output section.
*/

import React from 'react';

import { CodeBlockViewer } from '@renderer/components/chat/viewers';

import { type ItemStatus } from '../BaseItem';

import { CollapsibleOutputSection } from './CollapsibleOutputSection';
import { renderOutput } from './renderHelpers';

import type { LinkedToolItem } from '@renderer/types/groups';
Comment on lines +12 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reorder imports to match the project’s import grouping rule.

Line 17 imports a path alias after relative imports. Move import type { LinkedToolItem } from '@renderer/types/groups'; above the ../ and ./ imports.

As per coding guidelines, **/*.{ts,tsx} requires import order: external packages, path aliases, then relative imports.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx` around
lines 12 - 17, The import ordering in BashToolViewer.tsx is wrong: move the
path-alias import "import type { LinkedToolItem } from
'@renderer/types/groups';" above the relative imports (e.g., above "import {
type ItemStatus } from '../BaseItem';", "import { CollapsibleOutputSection }
from './CollapsibleOutputSection';", and "import { renderOutput } from
'./renderHelpers';") so imports follow the project rule (external packages, then
path aliases, then relative imports); update the file to place the
LinkedToolItem import in the correct group.


interface BashToolViewerProps {
linkedTool: LinkedToolItem;
status: ItemStatus;
}

export const BashToolViewer: React.FC<BashToolViewerProps> = ({ linkedTool, status }) => {
const command = linkedTool.input.command as string;
const description = linkedTool.input.description as string | undefined;

// Use the description (truncated) as the file name label, or fallback to "bash"
const fileName = description
? description.length > 60
? description.slice(0, 57) + '...'
: description
: 'bash';

return (
<>
{/* Input Section — Syntax-highlighted command */}
<CodeBlockViewer
fileName={fileName}
content={command}
language="bash"
/>
Comment on lines +25 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -t f "BashToolViewer.tsx"

Repository: matt1398/claude-devtools

Length of output: 132


🏁 Script executed:

fd -t f "LinkedToolItem" --type f

Repository: matt1398/claude-devtools

Length of output: 121


🏁 Script executed:

fd -t f "toolContentChecks.ts"

Repository: matt1398/claude-devtools

Length of output: 121


🏁 Script executed:

cat -n src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx

Repository: matt1398/claude-devtools

Length of output: 1886


🏁 Script executed:

cat -n src/renderer/components/chat/items/LinkedToolItem.tsx | head -80

Repository: matt1398/claude-devtools

Length of output: 3032


🏁 Script executed:

cat -n src/renderer/utils/toolRendering/toolContentChecks.ts

Repository: matt1398/claude-devtools

Length of output: 2530


Guard tool input shape before rendering command content.

Lines 25-26 use unchecked casts (as string), and CodeBlockViewer is always rendered without validating that the command is a non-empty string. This can produce invalid input cards if the payload shape drifts. Add an isBashInput type guard and only render CodeBlockViewer when the command is a non-empty string.

Suggested patch
 interface BashToolViewerProps {
   linkedTool: LinkedToolItem;
   status: ItemStatus;
 }
 
+interface BashInput {
+  command: string;
+  description?: string;
+}
+
+function isBashInput(input: Record<string, unknown>): input is BashInput {
+  return (
+    typeof input.command === 'string' &&
+    (input.description === undefined || typeof input.description === 'string')
+  );
+}
+
 export const BashToolViewer: React.FC<BashToolViewerProps> = ({ linkedTool, status }) => {
-  const command = linkedTool.input.command as string;
-  const description = linkedTool.input.description as string | undefined;
+  if (!isBashInput(linkedTool.input) || linkedTool.input.command.trim().length === 0) {
+    return null;
+  }
+
+  const { command, description } = linkedTool.input;
 
   // Use the description (truncated) as the file name label, or fallback to "bash"
   const fileName = description

Per coding guidelines, **/*.{ts,tsx} requires: "Use TypeScript type guards with isXxx naming convention for runtime type checking".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx` around
lines 25 - 42, BashToolViewer currently casts
linkedTool.input.command/description unsafely and always renders
CodeBlockViewer; add a runtime type guard isBashInput(input: unknown): input is
{ command: string; description?: string } that verifies command is a non-empty
string and description (if present) is a string, then use this guard in
BashToolViewer to only render CodeBlockViewer when isBashInput(linkedTool.input)
is true; compute fileName from the validated description (truncate to 60 chars)
or fall back to "bash" and remove the unsafe `as string`/`as string | undefined`
casts.


{/* Output Section — Collapsible */}
{!linkedTool.isOrphaned && linkedTool.result && (
<CollapsibleOutputSection status={status}>
{renderOutput(linkedTool.result.content)}
</CollapsibleOutputSection>
)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* CollapsibleOutputSection
*
* Reusable component that wraps tool output in a collapsed-by-default section.
* Shows a clickable header with label, StatusDot, and chevron toggle.
*/

import React, { useState } from 'react';

import { ChevronDown, ChevronRight } from 'lucide-react';

import { type ItemStatus, StatusDot } from '../BaseItem';

interface CollapsibleOutputSectionProps {
status: ItemStatus;
children: React.ReactNode;
/** Label shown in the header (default: "Output") */
label?: string;
}

export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> = ({
status,
children,
label = 'Output',
}) => {
const [isExpanded, setIsExpanded] = useState(false);

return (
<div>
<button
type="button"
className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{label}
<StatusDot status={status} />
</button>
{isExpanded && (
<div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
color:
status === 'error'
? 'var(--tool-result-error-text)'
: 'var(--color-text-secondary)',
}}
>
{children}
</div>
)}
</div>
);
};
46 changes: 16 additions & 30 deletions src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

import React from 'react';

import { type ItemStatus, StatusDot } from '../BaseItem';
import { type ItemStatus } from '../BaseItem';

import { CollapsibleOutputSection } from './CollapsibleOutputSection';
import { renderInput, renderOutput } from './renderHelpers';

import type { LinkedToolItem } from '@renderer/types/groups';
Expand All @@ -18,50 +19,35 @@ interface DefaultToolViewerProps {
}

export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
const hasInput = Object.keys(linkedTool.input).length > 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing Object.keys(linkedTool.input) directly is risky if linkedTool.input could be null or undefined. Given that other parts of the codebase (like hasBashContent) use optional chaining for input, it's safer to verify its existence before calling Object.keys to avoid potential runtime errors.

Suggested change
const hasInput = Object.keys(linkedTool.input).length > 0;
const hasInput = linkedTool.input && Object.keys(linkedTool.input).length > 0;


return (
<>
{/* Input Section */}
<div>
<div className="mb-1 text-xs" style={{ color: 'var(--tool-item-muted)' }}>
Input
</div>
<div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
color: 'var(--color-text-secondary)',
}}
>
{renderInput(linkedTool.name, linkedTool.input)}
</div>
</div>

{/* Output Section */}
{!linkedTool.isOrphaned && linkedTool.result && (
{hasInput && (
<div>
<div
className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)' }}
>
Output
<StatusDot status={status} />
<div className="mb-1 text-xs" style={{ color: 'var(--tool-item-muted)' }}>
Input
</div>
<div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
color:
status === 'error'
? 'var(--tool-result-error-text)'
: 'var(--color-text-secondary)',
color: 'var(--color-text-secondary)',
}}
>
{renderOutput(linkedTool.result.content)}
{renderInput(linkedTool.name, linkedTool.input)}
</div>
</div>
)}

{/* Output Section — Collapsed by default */}
{!linkedTool.isOrphaned && linkedTool.result && (
<CollapsibleOutputSection status={status}>
{renderOutput(linkedTool.result.content)}
</CollapsibleOutputSection>
)}
</>
);
};
Loading