Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"superpowers@superpowers-marketplace": true
}
}
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o
- We fix the system over the symptom.

## Repo Rules
- Always check for an use applicable skills
- Always check for and use applicable skills
- This project uses the [superpowers](https://github.com/obra/superpowers) plugin (configured in `.claude/settings.json`). Skills like `superpowers:executing-plans` are referenced throughout plan docs in `docs/plans/`.
- Always work in a worktree (in \.worktrees\)
- Specific user instructions override ALL other instructions, including the above, and including superpowers or skills
- Server uses NodeNext/ESM; relative imports must include `.js` extensions
Expand Down
4 changes: 4 additions & 0 deletions server/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type AppSettings = {
| 'one-light'
| 'solarized-light'
| 'github-light'
warnExternalLinks: boolean
}
defaultCwd?: string
logging: {
Expand All @@ -53,6 +54,7 @@ export type AppSettings = {
panes: {
defaultNewPane: 'ask' | 'shell' | 'browser' | 'editor'
snapThreshold: number // 0-8, % of container's smallest dimension; 0 = off
tabAttentionStyle: 'highlight' | 'pulse' | 'darken' | 'none'
}
sidebar: {
sortMode: 'recency' | 'activity' | 'project'
Expand Down Expand Up @@ -110,6 +112,7 @@ export const defaultSettings: AppSettings = {
cursorBlink: true,
scrollback: 5000,
theme: 'auto',
warnExternalLinks: true,
},
defaultCwd: undefined,
logging: {
Expand Down Expand Up @@ -289,6 +292,7 @@ function mergeSettings(base: AppSettings, patch: Partial<AppSettings>): AppSetti
cursorBlink: terminalPatch.cursorBlink,
scrollback: terminalPatch.scrollback,
theme: terminalPatch.theme,
warnExternalLinks: terminalPatch.warnExternalLinks,
}
return {
...base,
Expand Down
26 changes: 26 additions & 0 deletions src/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,22 @@ export default function SettingsView() {
}}
/>
</SettingsRow>

<SettingsRow label="Tab completion indicator">
<SegmentedControl
value={settings.panes?.tabAttentionStyle ?? 'highlight'}
options={[
{ value: 'highlight', label: 'Highlight' },
{ value: 'pulse', label: 'Pulse' },
{ value: 'darken', label: 'Darken' },
{ value: 'none', label: 'None' },
]}
onChange={(v: string) => {
dispatch(updateSettingsLocal({ panes: { tabAttentionStyle: v } } as any))
scheduleSave({ panes: { tabAttentionStyle: v } })
}}
/>
</SettingsRow>
</SettingsSection>

{/* Terminal */}
Expand Down Expand Up @@ -691,6 +707,16 @@ export default function SettingsView() {
/>
</SettingsRow>

<SettingsRow label="Warn on external links">
<Toggle
checked={settings.terminal.warnExternalLinks}
onChange={(checked) => {
dispatch(updateSettingsLocal({ terminal: { warnExternalLinks: checked } } as any))
scheduleSave({ terminal: { warnExternalLinks: checked } })
}}
/>
</SettingsRow>

<SettingsRow label="Font family">
<select
value={isSelectedFontAvailable ? settings.terminal.fontFamily : fallbackFontFamily}
Expand Down
6 changes: 6 additions & 0 deletions src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface SortableTabProps {
renameValue: string
paneContents?: PaneContent[]
iconsOnTabs?: boolean
tabAttentionStyle?: string
onRenameChange: (value: string) => void
onRenameBlur: () => void
onRenameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
Expand All @@ -58,6 +59,7 @@ function SortableTab({
renameValue,
paneContents,
iconsOnTabs,
tabAttentionStyle,
onRenameChange,
onRenameBlur,
onRenameKeyDown,
Expand Down Expand Up @@ -95,6 +97,7 @@ function SortableTab({
renameValue={renameValue}
paneContents={paneContents}
iconsOnTabs={iconsOnTabs}
tabAttentionStyle={tabAttentionStyle}
onRenameChange={onRenameChange}
onRenameBlur={onRenameBlur}
onRenameKeyDown={onRenameKeyDown}
Expand All @@ -121,6 +124,7 @@ export default function TabBar() {
const paneLayouts = useAppSelector((s) => s.panes?.layouts) ?? EMPTY_LAYOUTS
const attentionByTab = useAppSelector((s) => s.turnCompletion?.attentionByTab) ?? EMPTY_ATTENTION
const iconsOnTabs = useAppSelector((s) => s.settings?.settings?.panes?.iconsOnTabs ?? true)
const tabAttentionStyle = useAppSelector((s) => s.settings?.settings?.panes?.tabAttentionStyle ?? 'highlight')

const ws = useMemo(() => getWsClient(), [])

Expand Down Expand Up @@ -257,6 +261,7 @@ export default function TabBar() {
renameValue={renameValue}
paneContents={getPaneContents(tab)}
iconsOnTabs={iconsOnTabs}
tabAttentionStyle={tabAttentionStyle}
onRenameChange={setRenameValue}
onRenameBlur={() => {
dispatch(
Expand Down Expand Up @@ -333,6 +338,7 @@ export default function TabBar() {
renameValue=""
paneContents={getPaneContents(activeTab)}
iconsOnTabs={iconsOnTabs}
tabAttentionStyle={tabAttentionStyle}
onRenameChange={() => {}}
onRenameBlur={() => {}}
onRenameKeyDown={() => {}}
Expand Down
25 changes: 22 additions & 3 deletions src/components/TabItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface TabItemProps {
renameValue: string
paneContents?: PaneContent[]
iconsOnTabs?: boolean
tabAttentionStyle?: string
onRenameChange: (value: string) => void
onRenameBlur: () => void
onRenameKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void
Expand All @@ -59,6 +60,7 @@ export default function TabItem({
renameValue,
paneContents,
iconsOnTabs = true,
tabAttentionStyle = 'highlight',
onRenameChange,
onRenameBlur,
onRenameKeyDown,
Expand Down Expand Up @@ -106,12 +108,29 @@ export default function TabItem({
className={cn(
'group relative flex items-center gap-2 h-8 px-3 rounded-t-md border-x border-t border-muted-foreground/45 text-sm cursor-pointer transition-colors',
isActive
? "z-30 -mb-px border-b border-b-background bg-background text-foreground after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-px after:h-[2px] after:bg-background after:content-['']"
: needsAttention
? 'border-b border-muted-foreground/45 bg-emerald-100 text-emerald-900 hover:bg-emerald-200 mt-1 dark:bg-emerald-900/40 dark:text-emerald-100 dark:hover:bg-emerald-900/55'
? cn(
"z-30 -mb-px border-b border-b-background bg-background text-foreground after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-px after:h-[2px] after:bg-background after:content-['']",
needsAttention && tabAttentionStyle !== 'none' && tabAttentionStyle === 'pulse' && 'animate-pulse'
)
: needsAttention && tabAttentionStyle !== 'none'
? tabAttentionStyle === 'darken'
? 'border-b border-muted-foreground/45 bg-foreground/15 text-foreground hover:bg-foreground/20 mt-1 dark:bg-foreground/20 dark:text-foreground dark:hover:bg-foreground/25'
: cn(
'border-b border-muted-foreground/45 bg-emerald-100 text-emerald-900 hover:bg-emerald-200 mt-1 dark:bg-emerald-900/40 dark:text-emerald-100 dark:hover:bg-emerald-900/55',
tabAttentionStyle === 'pulse' && 'animate-pulse'
)
: 'border-b border-muted-foreground/45 bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/90 mt-1',
isDragging && 'opacity-50'
)}
style={isActive && needsAttention && tabAttentionStyle !== 'none' ? {
borderTopWidth: '3px',
borderTopStyle: 'solid',
borderTopColor: tabAttentionStyle === 'darken' ? '#666' : '#059669',
backgroundColor: tabAttentionStyle === 'darken' ? 'rgba(0,0,0,0.15)' : 'rgba(16,185,129,0.25)',
boxShadow: tabAttentionStyle === 'darken'
? 'inset 0 4px 8px rgba(0,0,0,0.15)'
: 'inset 0 4px 8px rgba(16,185,129,0.3)',
} : undefined}
role="button"
tabIndex={0}
aria-label={tab.title}
Expand Down
17 changes: 15 additions & 2 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useAppDispatch, useAppSelector } from '@/store/hooks'
import { updateTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice'
import { updatePaneContent, updatePaneTitle } from '@/store/panesSlice'
import { updateSessionActivity } from '@/store/sessionActivitySlice'
import { recordTurnComplete } from '@/store/turnCompletionSlice'
import { recordTurnComplete, clearTabAttention } from '@/store/turnCompletionSlice'
import { getWsClient } from '@/lib/ws-client'
import { getTerminalTheme } from '@/lib/terminal-themes'
import { getResumeSessionIdFromRef } from '@/components/terminal-view-utils'
Expand Down Expand Up @@ -152,8 +152,10 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
const sendInput = useCallback((data: string) => {
const tid = terminalIdRef.current
if (!tid) return
// Clear attention indicator when user starts typing
dispatch(clearTabAttention({ tabId }))
ws.send({ type: 'terminal.input', terminalId: tid, data })
}, [ws])
}, [dispatch, tabId, ws])

// Init xterm once
useEffect(() => {
Expand All @@ -176,6 +178,17 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
lineHeight: settings.terminal.lineHeight,
scrollback: settings.terminal.scrollback,
theme: getTerminalTheme(settings.terminal.theme, settings.theme),
linkHandler: {
activate: (_event: MouseEvent, uri: string) => {
if (settings.terminal.warnExternalLinks !== false) {
if (confirm(`Do you want to navigate to ${uri}?\n\nWARNING: This link could potentially be dangerous`)) {
Comment on lines +181 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebind link handler when warning preference changes

The linkHandler is created in the one-time terminal initialization path and closes over settings.terminal.warnExternalLinks, so existing terminals keep the value from mount time. If a user toggles “Warn on external links” in Settings (or has a persisted false loaded after the terminal mounts), link clicks in already-open tabs continue to use the old behavior until that terminal is remounted, which makes the new setting ineffective in normal use.

Useful? React with 👍 / 👎.

window.open(uri, '_blank')
}
} else {
window.open(uri, '_blank')
}
},
},
})
const fit = new FitAddon()
term.loadAddon(fit)
Expand Down
28 changes: 5 additions & 23 deletions src/hooks/useTurnCompletionNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { useAppDispatch, useAppSelector } from '@/store/hooks'
import {
clearTabAttention,
consumeTurnCompleteEvents,
markTabAttention,
type TurnCompleteEvent,
Expand All @@ -21,24 +20,8 @@ export function useTurnCompletionNotifications() {
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
const pendingEvents = useAppSelector((state) => state.turnCompletion?.pendingEvents ?? EMPTY_PENDING_EVENTS)
const { play } = useNotificationSound()
const [focused, setFocused] = useState(() => isWindowFocused())
const lastHandledSeqRef = useRef(0)

useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return

const updateFocus = () => setFocused(isWindowFocused())
window.addEventListener('focus', updateFocus)
window.addEventListener('blur', updateFocus)
document.addEventListener('visibilitychange', updateFocus)

return () => {
window.removeEventListener('focus', updateFocus)
window.removeEventListener('blur', updateFocus)
document.removeEventListener('visibilitychange', updateFocus)
}
}, [])

useEffect(() => {
if (pendingEvents.length === 0) return

Expand All @@ -49,10 +32,10 @@ export function useTurnCompletionNotifications() {
for (const event of pendingEvents) {
if (event.seq <= lastHandledSeqRef.current) continue
highestHandledSeq = Math.max(highestHandledSeq, event.seq)
dispatch(markTabAttention({ tabId: event.tabId }))
if (windowFocused && activeTabId === event.tabId) {
continue
}
dispatch(markTabAttention({ tabId: event.tabId }))
shouldPlay = true
}

Expand All @@ -66,8 +49,7 @@ export function useTurnCompletionNotifications() {
}
}, [activeTabId, dispatch, pendingEvents, play])

useEffect(() => {
if (!focused || !activeTabId) return
dispatch(clearTabAttention({ tabId: activeTabId }))
}, [activeTabId, dispatch, focused])
// Attention is cleared by TerminalView when the user sends input,
// not by a timer. This keeps the indicator visible until the user
// actually engages with the tab.
}
2 changes: 2 additions & 0 deletions src/store/settingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const defaultSettings: AppSettings = {
cursorBlink: true,
scrollback: 5000,
theme: 'auto',
warnExternalLinks: true,
},
defaultCwd: undefined,
logging: {
Expand All @@ -36,6 +37,7 @@ export const defaultSettings: AppSettings = {
defaultNewPane: 'ask' as const,
snapThreshold: 2,
iconsOnTabs: true,
tabAttentionStyle: 'highlight' as const,
},
codingCli: {
enabledProviders: ['claude', 'codex'],
Expand Down
4 changes: 4 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export type SidebarSortMode = 'recency' | 'recency-pinned' | 'activity' | 'proje

export type DefaultNewPane = 'ask' | 'shell' | 'browser' | 'editor'

export type TabAttentionStyle = 'highlight' | 'pulse' | 'darken' | 'none'

export type TerminalTheme =
| 'auto' // Follow app theme (dark/light)
| 'dracula'
Expand Down Expand Up @@ -123,6 +125,7 @@ export interface AppSettings {
cursorBlink: boolean
scrollback: number
theme: TerminalTheme
warnExternalLinks: boolean
}
defaultCwd?: string
logging: {
Expand All @@ -145,5 +148,6 @@ export interface AppSettings {
defaultNewPane: DefaultNewPane
snapThreshold: number // 0-8, % of container's smallest dimension; 0 = off
iconsOnTabs: boolean
tabAttentionStyle: TabAttentionStyle
}
}
13 changes: 8 additions & 5 deletions test/e2e/turn-complete-notification-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ describe('turn complete notification flow (e2e)', () => {
}
})

it('bells and highlights on background completion, then clears when selected and focused', async () => {
it('bells and highlights on background completion, attention persists until user input', async () => {
const store = createStore()

render(
Expand Down Expand Up @@ -292,18 +292,21 @@ describe('turn complete notification flow (e2e)', () => {
expect(store.getState().tabs.activeTabId).toBe('tab-2')
})

// Attention persists after switching tab and regaining focus;
// it's now cleared by TerminalView on user input, not by focus events
expect(store.getState().turnCompletion.attentionByTab['tab-2']).toBe(true)

act(() => {
hasFocus = true
window.dispatchEvent(new Event('focus'))
})

await waitFor(() => {
expect(store.getState().turnCompletion.attentionByTab['tab-2']).toBeUndefined()
})
// Attention still persists — no auto-clear on focus
expect(store.getState().turnCompletion.attentionByTab['tab-2']).toBe(true)

// Active tab with attention uses inline styles (colored top border + bg)
// instead of the bg-emerald-100 class used on inactive tabs
const backgroundTabAfter = screen.getByText('Background').closest('div[class*="group"]')
expect(backgroundTabAfter?.className).not.toContain('bg-emerald-100')
expect(backgroundTabAfter?.style.borderTopColor).toBe('rgb(5, 150, 105)')
})
})
1 change: 1 addition & 0 deletions test/integration/server/settings-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ describe('Settings API Integration', () => {
cursorBlink: false,
scrollback: 3000,
theme: 'light',
warnExternalLinks: true,
})
expect(res.body.terminal).not.toHaveProperty('fontFamily')
})
Expand Down
Loading