Skip to content

Commit 0aa9164

Browse files
committed
display-pending-tool-call (squashed)
1 parent 4c18415 commit 0aa9164

9 files changed

Lines changed: 186 additions & 34 deletions

File tree

src/main/hooks.ts

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import { log } from './debug'
1818
const STATUS_DIR = '/tmp/harness-status'
1919
const HARNESS_HOOK_MARKER = '__claude_harness__'
2020
// Bump this when the hook format changes to force reinstallation
21-
const HARNESS_HOOK_VERSION = 7
21+
const HARNESS_HOOK_VERSION = 8
22+
23+
export interface PendingTool {
24+
name: string
25+
input: Record<string, unknown>
26+
}
2227

2328
// Emit a bash command that appends one NDJSON line per event to the
2429
// terminal's log file at /tmp/harness-status/<id>.ndjson. The line wraps
@@ -142,18 +147,49 @@ interface HookEvent {
142147
payload: Record<string, unknown> | null
143148
}
144149

145-
function deriveStatus(ev: HookEvent): PtyStatus | null {
150+
// Most recently seen PreToolUse tool info per terminal. Used as a fallback
151+
// source of `tool_name`/`tool_input` when a permission_prompt Notification
152+
// arrives without those fields embedded in its own payload.
153+
const lastPreTool = new Map<string, PendingTool>()
154+
155+
interface StatusUpdate {
156+
status: PtyStatus
157+
pendingTool: PendingTool | null
158+
}
159+
160+
function readPendingTool(p: Record<string, unknown> | null): PendingTool | null {
161+
if (!p) return null
162+
const name = p.tool_name
163+
if (typeof name !== 'string' || !name) return null
164+
const rawInput = p.tool_input
165+
const input =
166+
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
167+
? (rawInput as Record<string, unknown>)
168+
: {}
169+
return { name, input }
170+
}
171+
172+
function deriveStatus(terminalId: string, ev: HookEvent): StatusUpdate | null {
146173
switch (ev.event) {
174+
case 'PreToolUse': {
175+
const tool = readPendingTool(ev.payload)
176+
if (tool) lastPreTool.set(terminalId, tool)
177+
return { status: 'processing', pendingTool: null }
178+
}
147179
case 'UserPromptSubmit':
148-
case 'PreToolUse':
149180
case 'PostToolUse':
150-
return 'processing'
181+
return { status: 'processing', pendingTool: null }
151182
case 'Stop':
152-
return 'waiting'
183+
return { status: 'waiting', pendingTool: null }
153184
case 'Notification': {
154-
const t = (ev.payload as Record<string, unknown> | null)?.notification_type
155-
if (t === 'permission_prompt' || t === 'elicitation_dialog') return 'needs-approval'
156-
if (t === 'idle_prompt') return 'waiting'
185+
const p = ev.payload as Record<string, unknown> | null
186+
const t = p?.notification_type
187+
if (t === 'permission_prompt' || t === 'elicitation_dialog') {
188+
const fromNotification = readPendingTool(p)
189+
const pendingTool = fromNotification ?? lastPreTool.get(terminalId) ?? null
190+
return { status: 'needs-approval', pendingTool }
191+
}
192+
if (t === 'idle_prompt') return { status: 'waiting', pendingTool: null }
157193
return null
158194
}
159195
default:
@@ -207,9 +243,14 @@ function tailLog(terminalId: string, win: BrowserWindow): void {
207243
continue
208244
}
209245
log('hooks', `event terminal=${terminalId} event=${ev.event}`)
210-
const status = deriveStatus(ev)
211-
if (status) {
212-
win.webContents.send('terminal:status', terminalId, status)
246+
const update = deriveStatus(terminalId, ev)
247+
if (update) {
248+
win.webContents.send(
249+
'terminal:status',
250+
terminalId,
251+
update.status,
252+
update.pendingTool
253+
)
213254
}
214255
}
215256
} finally {
@@ -246,6 +287,7 @@ export function watchStatusDir(
246287
export function cleanupTerminalLog(terminalId: string): void {
247288
offsets.delete(terminalId)
248289
residual.delete(terminalId)
290+
lastPreTool.delete(terminalId)
249291
try {
250292
unlinkSync(join(STATUS_DIR, `${terminalId}.ndjson`))
251293
} catch {

src/main/pty-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class PtyManager {
5050
log('pty', `spawn failed id=${id}`, err instanceof Error ? err.message : err)
5151
const msg = `\r\n\x1b[31mFailed to spawn "${shell}": ${err instanceof Error ? err.message : err}\x1b[0m\r\n`
5252
window.webContents.send('terminal:data', id, msg)
53-
window.webContents.send('terminal:status', id, 'idle')
53+
window.webContents.send('terminal:status', id, 'idle', null)
5454
return
5555
}
5656

@@ -72,7 +72,7 @@ export class PtyManager {
7272
log('pty', `exit id=${id} code=${exitCode}`)
7373
const win = BrowserWindow.fromId(instance.windowId)
7474
if (win && !win.isDestroyed()) {
75-
win.webContents.send('terminal:status', id, 'idle')
75+
win.webContents.send('terminal:status', id, 'idle', null)
7676
win.webContents.send('terminal:exit', id, exitCode)
7777
}
7878
this.ptys.delete(id)

src/preload/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { contextBridge, ipcRenderer, webUtils } from 'electron'
22

3-
type StatusCallback = (id: string, status: string) => void
3+
interface PendingToolShape {
4+
name: string
5+
input: Record<string, unknown>
6+
}
7+
type StatusCallback = (
8+
id: string,
9+
status: string,
10+
pendingTool: PendingToolShape | null
11+
) => void
412
type DataCallback = (id: string, data: string) => void
513
type ExitCallback = (id: string, exitCode: number) => void
614

@@ -277,8 +285,13 @@ contextBridge.exposeInMainWorld('api', {
277285
return () => ipcRenderer.removeListener('terminal:data', handler)
278286
},
279287
onStatusChange: (callback: StatusCallback) => {
280-
const handler = (_event: Electron.IpcRendererEvent, id: string, status: string): void => {
281-
callback(id, status)
288+
const handler = (
289+
_event: Electron.IpcRendererEvent,
290+
id: string,
291+
status: string,
292+
pendingTool?: PendingToolShape | null
293+
): void => {
294+
callback(id, status, pendingTool ?? null)
282295
}
283296
ipcRenderer.on('terminal:status', handler)
284297
return () => ipcRenderer.removeListener('terminal:status', handler)

src/renderer/App.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
2-
import type { Worktree, TerminalTab, PtyStatus, PRStatus, QuestStep, WorkspacePane, PendingWorktree, UpdaterStatus } from './types'
2+
import type { Worktree, TerminalTab, PtyStatus, PendingTool, PRStatus, QuestStep, WorkspacePane, PendingWorktree, UpdaterStatus } from './types'
33
import type { Action } from './hotkeys'
44
import { resolveHotkeys } from './hotkeys'
55
import { HotkeysProvider } from './components/Tooltip'
@@ -73,6 +73,7 @@ export default function App(): JSX.Element {
7373
return out
7474
}, [panes, activePaneId])
7575
const [statuses, setStatuses] = useState<Record<string, PtyStatus>>({})
76+
const [pendingTools, setPendingTools] = useState<Record<string, PendingTool | null>>({})
7677
const [prStatuses, setPrStatuses] = useState<Record<string, PRStatus | null>>({})
7778
const [mergedPaths, setMergedPaths] = useState<Record<string, boolean>>({})
7879
const [prLoading, setPrLoading] = useState(false)
@@ -534,9 +535,14 @@ const setQuestStep = useCallback((next: QuestStep) => {
534535

535536
// Listen for status changes from main process
536537
useEffect(() => {
537-
const cleanup = window.api.onStatusChange((id, status) => {
538+
const cleanup = window.api.onStatusChange((id, status, pendingTool) => {
538539
console.log(`[status] received: id=${id} status=${status}`)
539540
setStatuses((prev) => ({ ...prev, [id]: status as PtyStatus }))
541+
setPendingTools((prev) => {
542+
const next = status === 'needs-approval' ? pendingTool : null
543+
if (prev[id] === next) return prev
544+
return { ...prev, [id]: next }
545+
})
540546
const wtPath = terminalToWorktree(id)
541547
if (wtPath) {
542548
markActive(wtPath)
@@ -1428,19 +1434,23 @@ const setQuestStep = useCallback((next: QuestStep) => {
14281434

14291435
// Compute aggregate status per worktree (worst status wins)
14301436
const worktreeStatuses: Record<string, PtyStatus> = {}
1437+
const worktreePendingTools: Record<string, PendingTool | null> = {}
14311438
for (const wt of worktrees) {
14321439
const tabs = terminalTabs[wt.path] || []
14331440
let worstStatus: PtyStatus = 'idle'
1441+
let pending: PendingTool | null = null
14341442
for (const tab of tabs) {
14351443
const s = statuses[tab.id]
14361444
if (s === 'needs-approval') {
14371445
worstStatus = 'needs-approval'
1446+
pending = pendingTools[tab.id] || null
14381447
break
14391448
}
14401449
if (s === 'waiting' && worstStatus !== 'needs-approval') worstStatus = 'waiting'
14411450
if (s === 'processing' && worstStatus === 'idle') worstStatus = 'processing'
14421451
}
14431452
worktreeStatuses[wt.path] = worstStatus
1453+
worktreePendingTools[wt.path] = pending
14441454
}
14451455

14461456
// Record activity-log transitions whenever a worktree's effective state changes.
@@ -1596,6 +1606,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
15961606
pendingWorktrees={pendingWorktrees}
15971607
activeWorktreeId={activeWorktreeId}
15981608
statuses={worktreeStatuses}
1609+
pendingTools={worktreePendingTools}
15991610
prStatuses={prStatuses}
16001611
mergedPaths={mergedPaths}
16011612
prLoading={prLoading}
@@ -1704,6 +1715,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
17041715
<CommandCenter
17051716
worktrees={worktrees}
17061717
worktreeStatuses={worktreeStatuses}
1718+
worktreePendingTools={worktreePendingTools}
17071719
prStatuses={prStatuses}
17081720
mergedPaths={mergedPaths}
17091721
lastActive={lastActive}

src/renderer/components/CommandCenter.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { X, GitPullRequest, ChevronDown, ChevronRight, Layers, Rows3 } from 'luc
33
import type {
44
Worktree,
55
PtyStatus,
6+
PendingTool,
67
PRStatus,
78
TerminalTab,
89
ActivityLog,
@@ -11,10 +12,12 @@ import type {
1112
import { eventsToSegments, STATE_COLOR } from './Activity'
1213
import { groupWorktrees, type GroupKey } from '../worktree-sort'
1314
import { RepoIcon } from './RepoIcon'
15+
import { formatPendingTool } from '../pending-tool'
1416

1517
interface CommandCenterProps {
1618
worktrees: Worktree[]
1719
worktreeStatuses: Record<string, PtyStatus>
20+
worktreePendingTools: Record<string, PendingTool | null>
1821
prStatuses: Record<string, PRStatus | null>
1922
mergedPaths: Record<string, boolean>
2023
lastActive: Record<string, number>
@@ -85,6 +88,7 @@ function relTime(ms: number | undefined): string {
8588
export function CommandCenter({
8689
worktrees,
8790
worktreeStatuses,
91+
worktreePendingTools,
8892
prStatuses,
8993
mergedPaths,
9094
lastActive,
@@ -417,6 +421,14 @@ export function CommandCenter({
417421
{wt.branch}
418422
</span>
419423
</div>
424+
{display === 'needs-approval' && worktreePendingTools[wt.path] && (
425+
<div className="flex items-center gap-1.5 min-w-0 text-[11px] text-danger">
426+
<span className="font-semibold shrink-0">Waiting on:</span>
427+
<span className="truncate font-mono">
428+
{formatPendingTool(worktreePendingTools[wt.path] as PendingTool)}
429+
</span>
430+
</div>
431+
)}
420432
<div className="flex items-center gap-2 min-w-0 text-[11px]">
421433
<span
422434
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[display]}`}

src/renderer/components/Sidebar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useCallback, useMemo } from 'react'
22
import { ChevronDown, ChevronRight, Plus, RefreshCw, FolderOpen, Loader2, Settings as SettingsIcon, Sparkles, BarChart3, Trash2, LayoutGrid, X, Layers, Rows3, AlertCircle } from 'lucide-react'
33
import { Tooltip } from './Tooltip'
4-
import type { Worktree, PtyStatus, PRStatus, PendingWorktree } from '../types'
4+
import type { Worktree, PtyStatus, PendingTool, PRStatus, PendingWorktree } from '../types'
55
import type { GroupKey } from '../worktree-sort'
66
import { groupWorktrees } from '../worktree-sort'
77
import { WorktreeTab } from './WorktreeTab'
@@ -12,6 +12,7 @@ interface SidebarProps {
1212
pendingWorktrees: PendingWorktree[]
1313
activeWorktreeId: string | null
1414
statuses: Record<string, PtyStatus>
15+
pendingTools: Record<string, PendingTool | null>
1516
prStatuses: Record<string, PRStatus | null>
1617
mergedPaths?: Record<string, boolean>
1718
prLoading: boolean
@@ -46,6 +47,7 @@ export function Sidebar({
4647
pendingWorktrees,
4748
activeWorktreeId,
4849
statuses,
50+
pendingTools,
4951
prStatuses,
5052
mergedPaths,
5153
prLoading,
@@ -245,6 +247,7 @@ export function Sidebar({
245247
worktree={wt}
246248
isActive={wt.path === activeWorktreeId}
247249
status={statuses[wt.path] || 'idle'}
250+
pendingTool={pendingTools[wt.path] || null}
248251
prStatus={prStatuses[wt.path]}
249252
isMerged={group.key === 'merged'}
250253
repoLabel={showRepoLabelsOnTabs ? repoLabelFor(wt.repoRoot) : undefined}

src/renderer/components/WorktreeTab.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { GitPullRequest, RotateCw, Trash2 } from 'lucide-react'
2-
import type { Worktree, PtyStatus, PRStatus } from '../types'
2+
import type { Worktree, PtyStatus, PendingTool, PRStatus } from '../types'
33
import { Tooltip } from './Tooltip'
44
import { RepoIcon } from './RepoIcon'
5+
import { formatPendingTool } from '../pending-tool'
56

67
interface WorktreeTabProps {
78
worktree: Worktree
89
isActive: boolean
910
status: PtyStatus
11+
pendingTool?: PendingTool | null
1012
prStatus?: PRStatus | null
1113
isMerged?: boolean
1214
/** When set, shows a small repo hint next to the branch name. Used in
@@ -47,8 +49,9 @@ const PR_STATE_COLOR: Record<string, string> = {
4749
closed: 'text-danger'
4850
}
4951

50-
export function WorktreeTab({ worktree, isActive, status, prStatus, isMerged, repoLabel, onClick, onDelete, onContinue }: WorktreeTabProps): JSX.Element {
52+
export function WorktreeTab({ worktree, isActive, status, pendingTool, prStatus, isMerged, repoLabel, onClick, onDelete, onContinue }: WorktreeTabProps): JSX.Element {
5153
const displayStatus: PtyStatus | 'merged' = isMerged ? 'merged' : status
54+
const showPendingTool = displayStatus === 'needs-approval' && pendingTool
5255
const canContinue = !!onContinue && (prStatus?.state === 'merged' || prStatus?.state === 'closed')
5356
// Priority: merged/closed state always wins, then merge conflict, then check
5457
// status, then PR state
@@ -97,18 +100,24 @@ export function WorktreeTab({ worktree, isActive, status, prStatus, isMerged, re
97100
)}
98101
<div className="min-w-0 flex-1">
99102
<div className="text-sm font-medium truncate">{worktree.branch}</div>
100-
<div className="text-xs text-faint truncate">
101-
{repoLabel ? (
102-
<span className="inline-flex items-center gap-1">
103-
<RepoIcon repoName={repoLabel} size={11} />
104-
<span className="text-dim">{repoLabel}</span>
105-
<span className="mx-0.5">·</span>
106-
{worktree.path.split('/').pop()}
107-
</span>
108-
) : (
109-
worktree.path.split('/').slice(-2).join('/')
110-
)}
111-
</div>
103+
{showPendingTool ? (
104+
<div className="text-xs text-danger truncate font-mono" title={formatPendingTool(pendingTool!)}>
105+
{formatPendingTool(pendingTool!)}
106+
</div>
107+
) : (
108+
<div className="text-xs text-faint truncate">
109+
{repoLabel ? (
110+
<span className="inline-flex items-center gap-1">
111+
<RepoIcon repoName={repoLabel} size={11} />
112+
<span className="text-dim">{repoLabel}</span>
113+
<span className="mx-0.5">·</span>
114+
{worktree.path.split('/').pop()}
115+
</span>
116+
) : (
117+
worktree.path.split('/').slice(-2).join('/')
118+
)}
119+
</div>
120+
)}
112121
</div>
113122
{canContinue && (
114123
<Tooltip label="Continue on a new branch off main" side="left">

0 commit comments

Comments
 (0)