Skip to content

Commit 43d9296

Browse files
committed
shell-tab-activity-indicator (squashed)
1 parent 55603a6 commit 43d9296

9 files changed

Lines changed: 158 additions & 9 deletions

File tree

src/main/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ function registerIpcHandlers(): void {
845845
const win = BrowserWindow.fromWebContents(event.sender)
846846
if (!win) return
847847
const extraEnv = isClaude ? config.claudeEnvVars : undefined
848-
ptyManager.create(id, cwd, cmd, args, win, extraEnv)
848+
ptyManager.create(id, cwd, cmd, args, win, extraEnv, !isClaude)
849849
})
850850

851851
ipcMain.on('pty:write', (_, id: string, data: string) => {

src/main/pty-manager.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as pty from 'node-pty'
22
import { BrowserWindow } from 'electron'
3+
import { execFile } from 'child_process'
34
import { log } from './debug'
45
import { cleanupTerminalLog } from './hooks'
56

@@ -9,10 +10,17 @@ interface PtyInstance {
910
pty: pty.IPty
1011
status: PtyStatus
1112
windowId: number
13+
isShell: boolean
14+
activityActive: boolean
15+
activityProcess?: string
16+
hasBeenIdle: boolean
1217
}
1318

19+
const SHELL_ACTIVITY_POLL_MS = 1500
20+
1421
export class PtyManager {
1522
private ptys = new Map<string, PtyInstance>()
23+
private activityTimer: NodeJS.Timeout | null = null
1624

1725
hasTerminal(id: string): boolean {
1826
return this.ptys.has(id)
@@ -24,7 +32,8 @@ export class PtyManager {
2432
command: string,
2533
args: string[],
2634
window: BrowserWindow,
27-
extraEnv?: Record<string, string>
35+
extraEnv?: Record<string, string>,
36+
isShell: boolean = false
2837
): void {
2938
log('pty', `create id=${id} cmd=${command} args=${JSON.stringify(args)} cwd=${cwd}`)
3039
if (this.ptys.has(id)) {
@@ -57,7 +66,10 @@ export class PtyManager {
5766
const instance: PtyInstance = {
5867
pty: ptyProcess,
5968
status: 'processing',
60-
windowId: window.id
69+
windowId: window.id,
70+
isShell,
71+
activityActive: false,
72+
hasBeenIdle: false
6173
}
6274

6375
ptyProcess.onData((data: string) => {
@@ -80,6 +92,76 @@ export class PtyManager {
8092
})
8193

8294
this.ptys.set(id, instance)
95+
if (isShell) this.ensureActivityPoller()
96+
}
97+
98+
private ensureActivityPoller(): void {
99+
if (this.activityTimer) return
100+
this.activityTimer = setInterval(() => this.pollShellActivity(), SHELL_ACTIVITY_POLL_MS)
101+
}
102+
103+
private pollShellActivity(): void {
104+
const shellInstances: Array<[string, PtyInstance]> = []
105+
for (const entry of this.ptys) {
106+
if (entry[1].isShell) shellInstances.push(entry)
107+
}
108+
if (shellInstances.length === 0) {
109+
if (this.activityTimer) {
110+
clearInterval(this.activityTimer)
111+
this.activityTimer = null
112+
}
113+
return
114+
}
115+
116+
execFile('ps', ['-A', '-o', 'pid=,ppid=,comm='], (err, stdout) => {
117+
if (err) return
118+
// Build ppid → first child comm map (walk once)
119+
const childrenByPpid = new Map<number, string[]>()
120+
for (const line of stdout.split('\n')) {
121+
const trimmed = line.trim()
122+
if (!trimmed) continue
123+
const match = trimmed.match(/^(\d+)\s+(\d+)\s+(.+)$/)
124+
if (!match) continue
125+
const ppid = parseInt(match[2], 10)
126+
const comm = match[3].trim()
127+
const list = childrenByPpid.get(ppid)
128+
if (list) list.push(comm)
129+
else childrenByPpid.set(ppid, [comm])
130+
}
131+
132+
for (const [id, instance] of shellInstances) {
133+
const shellPid = instance.pty.pid
134+
if (!shellPid) continue
135+
136+
const directChildren = childrenByPpid.get(shellPid) || []
137+
const rawActive = directChildren.length > 0
138+
139+
// Arm detection only after we've seen the shell quiescent at least
140+
// once. This skips login-shell init (nvm, starship, git subprocs)
141+
// without needing a hardcoded timer.
142+
if (!instance.hasBeenIdle) {
143+
if (!rawActive) instance.hasBeenIdle = true
144+
continue
145+
}
146+
147+
const active = rawActive
148+
const processName = active ? directChildren[0] : undefined
149+
if (
150+
active !== instance.activityActive ||
151+
processName !== instance.activityProcess
152+
) {
153+
instance.activityActive = active
154+
instance.activityProcess = processName
155+
const win = BrowserWindow.fromId(instance.windowId)
156+
if (win && !win.isDestroyed()) {
157+
win.webContents.send('terminal:shell-activity', id, {
158+
active,
159+
processName
160+
})
161+
}
162+
}
163+
}
164+
})
83165
}
84166

85167
write(id: string, data: string): void {
@@ -129,6 +211,10 @@ export class PtyManager {
129211
for (const [id] of this.ptys) {
130212
this.kill(id, signal)
131213
}
214+
if (this.activityTimer) {
215+
clearInterval(this.activityTimer)
216+
this.activityTimer = null
217+
}
132218
}
133219

134220
/** Get the window that owns a terminal, for routing status updates */

src/preload/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,19 @@ contextBridge.exposeInMainWorld('api', {
296296
ipcRenderer.on('terminal:status', handler)
297297
return () => ipcRenderer.removeListener('terminal:status', handler)
298298
},
299+
onShellActivity: (
300+
callback: (id: string, payload: { active: boolean; processName?: string }) => void
301+
) => {
302+
const handler = (
303+
_event: Electron.IpcRendererEvent,
304+
id: string,
305+
payload: { active: boolean; processName?: string }
306+
): void => {
307+
callback(id, payload)
308+
}
309+
ipcRenderer.on('terminal:shell-activity', handler)
310+
return () => ipcRenderer.removeListener('terminal:shell-activity', handler)
311+
},
299312
// Activity log
300313
recordActivity: (worktreePath: string, state: string) => {
301314
ipcRenderer.send('activity:record', worktreePath, state)

src/renderer/App.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ export default function App(): JSX.Element {
7474
}, [panes, activePaneId])
7575
const [statuses, setStatuses] = useState<Record<string, PtyStatus>>({})
7676
const [pendingTools, setPendingTools] = useState<Record<string, PendingTool | null>>({})
77+
const [shellActivity, setShellActivity] = useState<
78+
Record<string, { active: boolean; processName?: string }>
79+
>({})
7780
const [prStatuses, setPrStatuses] = useState<Record<string, PRStatus | null>>({})
7881
const [mergedPaths, setMergedPaths] = useState<Record<string, boolean>>({})
7982
const [prLoading, setPrLoading] = useState(false)
@@ -578,6 +581,12 @@ const setQuestStep = useCallback((next: QuestStep) => {
578581
return cleanup
579582
}, [terminalToWorktree, markActive, fetchPRStatus])
580583

584+
useEffect(() => {
585+
return window.api.onShellActivity((id, payload) => {
586+
setShellActivity((prev) => ({ ...prev, [id]: payload }))
587+
})
588+
}, [])
589+
581590
// Auto-focus the active terminal when switching worktrees so the user can
582591
// start typing immediately. Deferred to the next frame so the xterm layer
583592
// is visible (TerminalPanels use display:none for inactive worktrees).
@@ -1478,6 +1487,14 @@ const setQuestStep = useCallback((next: QuestStep) => {
14781487
worktreePendingTools[wt.path] = pending
14791488
}
14801489

1490+
const worktreeShellActivity: Record<string, boolean> = {}
1491+
for (const wt of worktrees) {
1492+
const tabs = terminalTabs[wt.path] || []
1493+
worktreeShellActivity[wt.path] = tabs.some(
1494+
(tab) => tab.type === 'shell' && shellActivity[tab.id]?.active
1495+
)
1496+
}
1497+
14811498
// Record activity-log transitions whenever a worktree's effective state changes.
14821499
// Merged worktrees are terminal — once we've recorded 'merged' we stop
14831500
// overwriting with pty state so the timeline keeps the purple tail.
@@ -1632,6 +1649,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
16321649
activeWorktreeId={activeWorktreeId}
16331650
statuses={worktreeStatuses}
16341651
pendingTools={worktreePendingTools}
1652+
shellActivity={worktreeShellActivity}
16351653
prStatuses={prStatuses}
16361654
mergedPaths={mergedPaths}
16371655
prLoading={prLoading}
@@ -1690,6 +1708,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
16901708
panes={paneList}
16911709
focusedPaneId={activePaneId[wt.path] || paneList[0]?.id || ''}
16921710
statuses={statuses}
1711+
shellActivity={shellActivity}
16931712
visible={isVisible}
16941713
claudeCommand={claudeCommand}
16951714
nameClaudeSessions={nameClaudeSessions}

src/renderer/components/Sidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface SidebarProps {
1515
activeWorktreeId: string | null
1616
statuses: Record<string, PtyStatus>
1717
pendingTools: Record<string, PendingTool | null>
18+
shellActivity: Record<string, boolean>
1819
prStatuses: Record<string, PRStatus | null>
1920
mergedPaths?: Record<string, boolean>
2021
prLoading: boolean
@@ -50,6 +51,7 @@ export function Sidebar({
5051
activeWorktreeId,
5152
statuses,
5253
pendingTools,
54+
shellActivity,
5355
prStatuses,
5456
mergedPaths,
5557
prLoading,
@@ -279,6 +281,7 @@ export function Sidebar({
279281
isActive={wt.path === activeWorktreeId}
280282
status={statuses[wt.path] || 'idle'}
281283
pendingTool={pendingTools[wt.path] || null}
284+
shellActive={!!shellActivity[wt.path]}
282285
prStatus={prStatuses[wt.path]}
283286
isMerged={group.key === 'merged'}
284287
repoLabel={showRepoLabelsOnTabs ? repoLabelFor(wt.repoRoot) : undefined}

src/renderer/components/TerminalPanel.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef, useCallback } from 'react'
2-
import { X, Plus, Sparkles, Code2, SplitSquareHorizontal } from 'lucide-react'
2+
import { X, Plus, Sparkles, Code2, SplitSquareHorizontal, Loader2 } from 'lucide-react'
33
import {
44
SortableContext,
55
horizontalListSortingStrategy,
@@ -16,6 +16,7 @@ interface TerminalPanelProps {
1616
isFocused: boolean
1717
paneCount: number
1818
statuses: Record<string, PtyStatus>
19+
shellActivity: Record<string, { active: boolean; processName?: string }>
1920
repoLabel: string
2021
branch: string
2122
registerSlot: (paneId: string, el: HTMLDivElement | null) => void
@@ -37,12 +38,13 @@ interface SortableTabProps {
3738
tab: TerminalTab
3839
isActive: boolean
3940
status: PtyStatus
41+
shellActivity?: { active: boolean; processName?: string }
4042
showClose: boolean
4143
onSelect: () => void
4244
onClose: () => void
4345
}
4446

45-
function SortableTab({ tab, isActive, status, showClose, onSelect, onClose }: SortableTabProps): JSX.Element {
47+
function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect, onClose }: SortableTabProps): JSX.Element {
4648
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
4749
id: tab.id
4850
})
@@ -77,9 +79,19 @@ function SortableTab({ tab, isActive, status, showClose, onSelect, onClose }: So
7779
}`}
7880
onClick={onSelect}
7981
>
80-
{tab.type !== 'diff' && (
82+
{tab.type === 'shell' ? (
83+
shellActivity?.active ? (
84+
<Loader2
85+
size={10}
86+
className="animate-spin text-fg-bright"
87+
aria-label={`Running: ${shellActivity.processName || '?'}`}
88+
/>
89+
) : (
90+
<span className="w-1.5 h-1.5 rounded-full bg-faint" />
91+
)
92+
) : tab.type !== 'diff' && tab.type !== 'file' ? (
8193
<span className={`w-1.5 h-1.5 rounded-full ${TAB_STATUS_DOT[status]}`} />
82-
)}
94+
) : null}
8395
<span>{tab.label}</span>
8496
{showClose && (
8597
<Tooltip label="Close tab" action="closeTab">
@@ -104,6 +116,7 @@ export function TerminalPanel({
104116
pane,
105117
paneCount,
106118
statuses,
119+
shellActivity,
107120
repoLabel,
108121
branch,
109122
registerSlot,
@@ -146,6 +159,7 @@ export function TerminalPanel({
146159
tab={tab}
147160
isActive={tab.id === pane.activeTabId}
148161
status={statuses[tab.id] || 'idle'}
162+
shellActivity={shellActivity[tab.id]}
149163
showClose={pane.tabs.length > 1 || paneCount > 1}
150164
onSelect={() => onSelectTab(tab.id)}
151165
onClose={() => onCloseTab(tab.id)}

src/renderer/components/WorkspaceView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface WorkspaceViewProps {
2222
panes: WorkspacePane[]
2323
focusedPaneId: string
2424
statuses: Record<string, PtyStatus>
25+
shellActivity: Record<string, { active: boolean; processName?: string }>
2526
visible: boolean
2627
claudeCommand: string
2728
nameClaudeSessions: boolean
@@ -51,6 +52,7 @@ export function WorkspaceView({
5152
panes,
5253
focusedPaneId,
5354
statuses,
55+
shellActivity,
5456
visible,
5557
claudeCommand,
5658
nameClaudeSessions,
@@ -155,6 +157,7 @@ export function WorkspaceView({
155157
isFocused={pane.id === focusedPaneId}
156158
paneCount={panes.length}
157159
statuses={statuses}
160+
shellActivity={shellActivity}
158161
repoLabel={repoLabel}
159162
branch={branch}
160163
registerSlot={registerSlot}

src/renderer/components/WorktreeTab.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GitPullRequest, RotateCw, Trash2 } from 'lucide-react'
1+
import { GitPullRequest, RotateCw, Trash2, Loader2 } from 'lucide-react'
22
import type { Worktree, PtyStatus, PendingTool, PRStatus } from '../types'
33
import { Tooltip } from './Tooltip'
44
import { RepoIcon } from './RepoIcon'
@@ -12,6 +12,7 @@ interface WorktreeTabProps {
1212
isActive: boolean
1313
status: PtyStatus
1414
pendingTool?: PendingTool | null
15+
shellActive?: boolean
1516
prStatus?: PRStatus | null
1617
isMerged?: boolean
1718
/** When set, shows a small repo hint next to the branch name. Used in
@@ -55,7 +56,7 @@ const PR_STATE_COLOR: Record<string, string> = {
5556
closed: 'text-danger'
5657
}
5758

58-
export function WorktreeTab({ worktree, isActive, status, pendingTool, prStatus, isMerged, repoLabel, cmdOrdinal, onClick, onDelete, onContinue }: WorktreeTabProps): JSX.Element {
59+
export function WorktreeTab({ worktree, isActive, status, pendingTool, shellActive, prStatus, isMerged, repoLabel, cmdOrdinal, onClick, onDelete, onContinue }: WorktreeTabProps): JSX.Element {
5960
const metaHeld = useMetaHeld()
6061
const displayStatus: PtyStatus | 'merged' = isMerged ? 'merged' : status
6162
const showPendingTool = displayStatus === 'needs-approval' && pendingTool
@@ -90,6 +91,13 @@ export function WorktreeTab({ worktree, isActive, status, pendingTool, prStatus,
9091
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_COLORS[displayStatus]}`}
9192
title={STATUS_LABELS[displayStatus]}
9293
/>
94+
{shellActive && (
95+
<Loader2
96+
size={11}
97+
className="animate-spin text-fg-bright shrink-0"
98+
aria-label="Shell activity"
99+
/>
100+
)}
93101
{prStatus && (
94102
<span className="relative shrink-0">
95103
<GitPullRequest

src/renderer/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ export interface ElectronAPI {
330330
onStatusChange(
331331
callback: (id: string, status: PtyStatus, pendingTool: PendingTool | null) => void
332332
): () => void
333+
onShellActivity(
334+
callback: (id: string, payload: { active: boolean; processName?: string }) => void
335+
): () => void
333336
onTerminalExit(callback: (id: string, exitCode: number) => void): () => void
334337

335338
recordActivity(worktreePath: string, state: string): void

0 commit comments

Comments
 (0)