diff --git a/src/main/index.ts b/src/main/index.ts
index 81a33cf..fafda8f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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;
}
diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts
index 408e904..68de22b 100644
--- a/src/preload/constants/ipcChannels.ts
+++ b/src/preload/constants/ipcChannels.ts
@@ -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';
diff --git a/src/preload/index.ts b/src/preload/index.ts
index e9c32ae..e5d7064 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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,
@@ -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),
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index 9686867..4ce15d6 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -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)
// ---------------------------------------------------------------------------
diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx
index 867df2a..c25983c 100644
--- a/src/renderer/components/chat/ChatHistory.tsx
+++ b/src/renderer/components/chat/ChatHistory.tsx
@@ -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) => {
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 670b405..b97773b 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -32,6 +32,7 @@ import {
highlightSearchInChildren,
type SearchContext,
} from '../searchHighlightUtils';
+import { highlightLine } from '../viewers/syntaxHighlighter';
// =============================================================================
// Types
@@ -154,9 +155,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
const isBlock = (hasLanguage ?? false) || isMultiLine;
if (isBlock) {
+ const lang = codeClassName?.replace('language-', '') ?? '';
+ const raw = typeof children === 'string' ? children : '';
+ const text = raw.replace(/\n$/, '');
+ const lines = text.split('\n');
return (
- {hl(children)}
+ {lines.map((line, i) => (
+
+ {hl(highlightLine(line, lang))}
+ {i < lines.length - 1 ? '\n' : null}
+
+ ))}
);
}
diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts
index 2271d08..e6e2f6b 100644
--- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts
+++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts
@@ -208,6 +208,200 @@ const KEYWORDS: Record> = {
'true',
'false',
]),
+ r: new Set([
+ 'if',
+ 'else',
+ 'for',
+ 'while',
+ 'repeat',
+ 'function',
+ 'return',
+ 'next',
+ 'break',
+ 'in',
+ 'library',
+ 'require',
+ 'source',
+ 'TRUE',
+ 'FALSE',
+ 'NULL',
+ 'NA',
+ 'Inf',
+ 'NaN',
+ 'NA_integer_',
+ 'NA_real_',
+ 'NA_complex_',
+ 'NA_character_',
+ ]),
+ ruby: new Set([
+ 'def',
+ 'class',
+ 'module',
+ 'end',
+ 'do',
+ 'if',
+ 'elsif',
+ 'else',
+ 'unless',
+ 'while',
+ 'until',
+ 'for',
+ 'in',
+ 'begin',
+ 'rescue',
+ 'ensure',
+ 'raise',
+ 'return',
+ 'yield',
+ 'block_given?',
+ 'require',
+ 'require_relative',
+ 'include',
+ 'extend',
+ 'attr_accessor',
+ 'attr_reader',
+ 'attr_writer',
+ 'self',
+ 'super',
+ 'nil',
+ 'true',
+ 'false',
+ 'and',
+ 'or',
+ 'not',
+ 'then',
+ 'when',
+ 'case',
+ 'lambda',
+ 'proc',
+ 'puts',
+ 'print',
+ ]),
+ php: new Set([
+ 'function',
+ 'class',
+ 'interface',
+ 'trait',
+ 'extends',
+ 'implements',
+ 'namespace',
+ 'use',
+ 'public',
+ 'private',
+ 'protected',
+ 'static',
+ 'abstract',
+ 'final',
+ 'const',
+ 'var',
+ 'new',
+ 'return',
+ 'if',
+ 'elseif',
+ 'else',
+ 'for',
+ 'foreach',
+ 'while',
+ 'do',
+ 'switch',
+ 'case',
+ 'break',
+ 'continue',
+ 'default',
+ 'try',
+ 'catch',
+ 'finally',
+ 'throw',
+ 'as',
+ 'echo',
+ 'print',
+ 'require',
+ 'require_once',
+ 'include',
+ 'include_once',
+ 'true',
+ 'false',
+ 'null',
+ 'array',
+ 'isset',
+ 'unset',
+ 'empty',
+ 'self',
+ 'this',
+ ]),
+ sql: new Set([
+ 'SELECT',
+ 'FROM',
+ 'WHERE',
+ 'INSERT',
+ 'INTO',
+ 'UPDATE',
+ 'SET',
+ 'DELETE',
+ 'CREATE',
+ 'ALTER',
+ 'DROP',
+ 'TABLE',
+ 'INDEX',
+ 'VIEW',
+ 'DATABASE',
+ 'JOIN',
+ 'INNER',
+ 'LEFT',
+ 'RIGHT',
+ 'OUTER',
+ 'FULL',
+ 'CROSS',
+ 'ON',
+ 'AND',
+ 'OR',
+ 'NOT',
+ 'IN',
+ 'EXISTS',
+ 'BETWEEN',
+ 'LIKE',
+ 'IS',
+ 'NULL',
+ 'AS',
+ 'ORDER',
+ 'BY',
+ 'GROUP',
+ 'HAVING',
+ 'LIMIT',
+ 'OFFSET',
+ 'UNION',
+ 'ALL',
+ 'DISTINCT',
+ 'COUNT',
+ 'SUM',
+ 'AVG',
+ 'MIN',
+ 'MAX',
+ 'CASE',
+ 'WHEN',
+ 'THEN',
+ 'ELSE',
+ 'END',
+ 'BEGIN',
+ 'COMMIT',
+ 'ROLLBACK',
+ 'TRANSACTION',
+ 'PRIMARY',
+ 'KEY',
+ 'FOREIGN',
+ 'REFERENCES',
+ 'CONSTRAINT',
+ 'DEFAULT',
+ 'VALUES',
+ 'TRUE',
+ 'FALSE',
+ 'INTEGER',
+ 'VARCHAR',
+ 'TEXT',
+ 'BOOLEAN',
+ 'DATE',
+ 'TIMESTAMP',
+ ]),
};
// Extend tsx/jsx to use typescript/javascript keywords
@@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[]
break;
}
- // Check for comment (# style for Python/Shell)
- if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) {
+ // Check for comment (# style for Python/Shell/R/Ruby/PHP)
+ if (
+ (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') &&
+ remaining.startsWith('#')
+ ) {
+ segments.push(
+ React.createElement(
+ 'span',
+ { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } },
+ remaining
+ )
+ );
+ break;
+ }
+
+ // Check for comment (-- style for SQL)
+ if (language === 'sql' && remaining.startsWith('--')) {
segments.push(
React.createElement(
'span',
@@ -326,7 +535,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[]
const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining);
if (wordMatch) {
const word = wordMatch[1];
- if (keywords.has(word)) {
+ // SQL keywords are case-insensitive
+ if (keywords.has(word) || (language === 'sql' && keywords.has(word.toUpperCase()))) {
segments.push(
React.createElement(
'span',
diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx
index 8ce99f6..09ae142 100644
--- a/src/renderer/components/layout/TabBar.tsx
+++ b/src/renderer/components/layout/TabBar.tsx
@@ -39,7 +39,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
setSelectedTabIds,
clearTabSelection,
openDashboard,
- fetchSessionDetail,
+ refreshSessionInPlace,
fetchSessions,
unreadCount,
openNotificationsTab,
@@ -64,7 +64,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
setSelectedTabIds: s.setSelectedTabIds,
clearTabSelection: s.clearTabSelection,
openDashboard: s.openDashboard,
- fetchSessionDetail: s.fetchSessionDetail,
+ refreshSessionInPlace: s.refreshSessionInPlace,
fetchSessions: s.fetchSessions,
unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab,
@@ -215,9 +215,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const handleRefresh = async (): Promise => {
if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) {
await Promise.all([
- fetchSessionDetail(activeTab.projectId, activeTab.sessionId, activeTabId ?? undefined),
+ refreshSessionInPlace(activeTab.projectId, activeTab.sessionId),
fetchSessions(activeTab.projectId),
]);
+ window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom'));
}
};
diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts
index 3706c89..73c8999 100644
--- a/src/renderer/hooks/useKeyboardShortcuts.ts
+++ b/src/renderer/hooks/useKeyboardShortcuts.ts
@@ -29,7 +29,7 @@ export function useKeyboardShortcuts(): void {
getActiveTab,
selectedProjectId,
selectedSessionId,
- fetchSessionDetail,
+ refreshSessionInPlace,
fetchSessions,
openCommandPalette,
openSettingsTab,
@@ -56,7 +56,7 @@ export function useKeyboardShortcuts(): void {
getActiveTab: s.getActiveTab,
selectedProjectId: s.selectedProjectId,
selectedSessionId: s.selectedSessionId,
- fetchSessionDetail: s.fetchSessionDetail,
+ refreshSessionInPlace: s.refreshSessionInPlace,
fetchSessions: s.fetchSessions,
openCommandPalette: s.openCommandPalette,
openSettingsTab: s.openSettingsTab,
@@ -261,9 +261,11 @@ export function useKeyboardShortcuts(): void {
event.preventDefault();
if (selectedProjectId && selectedSessionId) {
void Promise.all([
- fetchSessionDetail(selectedProjectId, selectedSessionId),
+ refreshSessionInPlace(selectedProjectId, selectedSessionId),
fetchSessions(selectedProjectId),
- ]);
+ ]).then(() => {
+ window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom'));
+ });
}
return;
}
@@ -290,7 +292,7 @@ export function useKeyboardShortcuts(): void {
getActiveTab,
selectedProjectId,
selectedSessionId,
- fetchSessionDetail,
+ refreshSessionInPlace,
fetchSessions,
openCommandPalette,
openSettingsTab,
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts
index 0111428..c302294 100644
--- a/src/renderer/store/index.ts
+++ b/src/renderer/store/index.ts
@@ -268,6 +268,26 @@ export function initializeNotificationListeners(): () => void {
}
}
+ // Listen for Ctrl+R / Cmd+R session refresh from main process (fixes #85)
+ if (api.onSessionRefresh) {
+ const cleanup = api.onSessionRefresh(() => {
+ const state = useStore.getState();
+ const activeTabId = state.activeTabId;
+ const activeTab = activeTabId ? state.openTabs.find((t) => t.id === activeTabId) : null;
+ if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) {
+ void Promise.all([
+ state.refreshSessionInPlace(activeTab.projectId, activeTab.sessionId),
+ state.fetchSessions(activeTab.projectId),
+ ]).then(() => {
+ window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom'));
+ });
+ }
+ });
+ if (typeof cleanup === 'function') {
+ cleanupFns.push(cleanup);
+ }
+ }
+
// Listen for updater status events from main process
if (api.updater?.onStatus) {
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index b318daa..9822c14 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -395,6 +395,9 @@ export interface ElectronAPI {
onFileChange: (callback: (event: FileChangeEvent) => void) => () => void;
onTodoChange: (callback: (event: FileChangeEvent) => void) => () => void;
+ // Session refresh (Ctrl+R / Cmd+R intercepted by main process)
+ onSessionRefresh: (callback: () => void) => () => void;
+
// Shell operations
openPath: (
targetPath: string,