291 lines
9.2 KiB
TypeScript
291 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import type { RuntimeTerminalListResult } from '../../../shared/runtime-types'
|
|
import {
|
|
AGENT_STATUS_STALE_AFTER_MS,
|
|
type AgentStatusEntry
|
|
} from '../../../shared/agent-status-types'
|
|
import type { AppState } from '@/store/types'
|
|
import { useAppStore } from '@/store'
|
|
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
|
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
|
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
|
|
import type { TerminalLayoutSnapshot } from '../../../shared/types'
|
|
import {
|
|
detectAgentStatusFromTitle,
|
|
getAgentLabel,
|
|
isExplicitAgentStatusFresh
|
|
} from './agent-status'
|
|
import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id'
|
|
|
|
const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000
|
|
const ACTIVE_AGENT_TERMINAL_LIST_LIMIT = 200
|
|
|
|
export type ActiveTerminalNoteTarget = {
|
|
tabId: string
|
|
leafId: string
|
|
}
|
|
|
|
export type ActiveTerminalNoteTargetState = {
|
|
activeWorktreeId: AppState['activeWorktreeId']
|
|
activeTabType: AppState['activeTabType']
|
|
activeTabId: AppState['activeTabId']
|
|
activeTabIdByWorktree: AppState['activeTabIdByWorktree']
|
|
tabsByWorktree: Record<
|
|
string,
|
|
readonly { id: string; title?: string; launchAgent?: unknown }[] | undefined
|
|
>
|
|
ptyIdsByTabId?: Record<string, readonly string[] | undefined>
|
|
terminalLayoutsByTabId: Record<
|
|
string,
|
|
| {
|
|
activeLeafId: string | null
|
|
root?: TerminalLayoutSnapshot['root']
|
|
ptyIdsByLeafId?: Record<string, string | undefined>
|
|
}
|
|
| undefined
|
|
>
|
|
runtimePaneTitlesByTabId?: Record<string, Record<number, string> | undefined>
|
|
agentStatusByPaneKey?: Record<string, AgentStatusEntry | undefined>
|
|
settings: Parameters<typeof getActiveRuntimeTarget>[0]
|
|
}
|
|
|
|
type ActiveAgentRuntimeProbeDescriptor = {
|
|
key: string
|
|
worktreeId: string
|
|
runtimeTarget: ReturnType<typeof getActiveRuntimeTarget>
|
|
noteTarget: ActiveTerminalNoteTarget
|
|
}
|
|
|
|
export function getActiveTerminalNoteTarget(
|
|
state: ActiveTerminalNoteTargetState,
|
|
worktreeId: string
|
|
): ActiveTerminalNoteTarget | null {
|
|
if (state.activeWorktreeId !== worktreeId) {
|
|
return null
|
|
}
|
|
|
|
const tabId =
|
|
state.activeTabType === 'terminal'
|
|
? (state.activeTabId ?? state.activeTabIdByWorktree[worktreeId])
|
|
: state.activeTabIdByWorktree[worktreeId]
|
|
if (!tabId || !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)) {
|
|
return null
|
|
}
|
|
|
|
const leafId = state.terminalLayoutsByTabId[tabId]?.activeLeafId
|
|
return leafId ? { tabId, leafId } : null
|
|
}
|
|
|
|
export function useCanSendNotesToActiveTerminal(worktreeId: string): boolean {
|
|
const canSendFromRendererState = useAppStore(
|
|
(state) => getActiveAgentNoteTarget(state, worktreeId) !== null
|
|
)
|
|
const probeKey = useAppStore(
|
|
(state) => getActiveAgentRuntimeProbeDescriptor(state, worktreeId)?.key ?? null
|
|
)
|
|
const [runtimeProbe, setRuntimeProbe] = useState<{ key: string; canSend: boolean } | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (canSendFromRendererState || !probeKey) {
|
|
return
|
|
}
|
|
const probeDescriptor = getActiveAgentRuntimeProbeDescriptor(useAppStore.getState(), worktreeId)
|
|
if (!probeDescriptor || probeDescriptor.key !== probeKey) {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
void probeActiveAgentNoteTarget(probeDescriptor)
|
|
.then((canSend) => {
|
|
if (!cancelled) {
|
|
setRuntimeProbe({ key: probeKey, canSend })
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setRuntimeProbe({ key: probeKey, canSend: false })
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [canSendFromRendererState, probeKey, worktreeId])
|
|
|
|
return (
|
|
canSendFromRendererState ||
|
|
(runtimeProbe !== null && runtimeProbe.key === probeKey && runtimeProbe.canSend)
|
|
)
|
|
}
|
|
|
|
export function getActiveAgentNoteTarget(
|
|
state: ActiveTerminalNoteTargetState,
|
|
worktreeId: string,
|
|
now = Date.now()
|
|
): ActiveTerminalNoteTarget | null {
|
|
const noteTarget = getActiveTerminalNoteTarget(state, worktreeId)
|
|
if (!noteTarget || !isTerminalLeafId(noteTarget.leafId)) {
|
|
return null
|
|
}
|
|
|
|
const activePtyId = getActivePanePtyId(state, noteTarget)
|
|
if (!activePtyId) {
|
|
return null
|
|
}
|
|
|
|
const entry = state.agentStatusByPaneKey?.[makePaneKey(noteTarget.tabId, noteTarget.leafId)]
|
|
if (entry && isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
|
|
return noteTarget
|
|
}
|
|
// Why: freshly opened agents can be idle before their first hook event. Use
|
|
// renderer title/launch hints only to show the option; runtime still verifies
|
|
// the focused terminal is an idle agent before sending Enter.
|
|
if (!hasFocusedPaneAgentHint(state, worktreeId, noteTarget)) {
|
|
return null
|
|
}
|
|
|
|
return noteTarget
|
|
}
|
|
|
|
export function getActiveAgentRuntimeProbeDescriptor(
|
|
state: ActiveTerminalNoteTargetState,
|
|
worktreeId: string
|
|
): ActiveAgentRuntimeProbeDescriptor | null {
|
|
const noteTarget = getActiveTerminalNoteTarget(state, worktreeId)
|
|
if (!noteTarget || !isTerminalLeafId(noteTarget.leafId)) {
|
|
return null
|
|
}
|
|
const activePtyId = getActivePanePtyId(state, noteTarget)
|
|
if (!activePtyId) {
|
|
return null
|
|
}
|
|
const runtimeTarget = getActiveRuntimeTarget(state.settings)
|
|
const runtimeKey =
|
|
runtimeTarget.kind === 'environment' ? `env:${runtimeTarget.environmentId}` : 'local'
|
|
return {
|
|
key: `${runtimeKey}:${worktreeId}:${noteTarget.tabId}:${noteTarget.leafId}:${activePtyId}`,
|
|
worktreeId,
|
|
runtimeTarget,
|
|
noteTarget
|
|
}
|
|
}
|
|
|
|
export async function probeActiveAgentNoteTarget({
|
|
worktreeId,
|
|
runtimeTarget,
|
|
noteTarget
|
|
}: ActiveAgentRuntimeProbeDescriptor): Promise<boolean> {
|
|
const terminal = await findActiveRuntimeTerminal(
|
|
runtimeTarget,
|
|
worktreeId,
|
|
noteTarget,
|
|
ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS
|
|
)
|
|
if (!terminal) {
|
|
return false
|
|
}
|
|
const agentCheck = await callRuntimeRpc<{ isRunningAgent: boolean }>(
|
|
runtimeTarget,
|
|
'terminal.isRunningAgent',
|
|
{ terminal: terminal.handle },
|
|
{ timeoutMs: ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS }
|
|
)
|
|
return agentCheck.isRunningAgent
|
|
}
|
|
|
|
export async function findActiveRuntimeTerminal(
|
|
runtimeTarget: ReturnType<typeof getActiveRuntimeTarget>,
|
|
worktreeId: string,
|
|
noteTarget: ActiveTerminalNoteTarget,
|
|
timeoutMs: number
|
|
): Promise<RuntimeTerminalListResult['terminals'][number] | null> {
|
|
const { terminals } = await callRuntimeRpc<RuntimeTerminalListResult>(
|
|
runtimeTarget,
|
|
'terminal.list',
|
|
// Why: worktree ids can look like branch names or paths; keep the lookup unambiguous.
|
|
{ worktree: toRuntimeWorktreeSelector(worktreeId), limit: ACTIVE_AGENT_TERMINAL_LIST_LIMIT },
|
|
{ timeoutMs }
|
|
)
|
|
return (
|
|
terminals.find(
|
|
(terminal) => terminal.tabId === noteTarget.tabId && terminal.leafId === noteTarget.leafId
|
|
) ?? null
|
|
)
|
|
}
|
|
|
|
function getActivePanePtyId(
|
|
state: ActiveTerminalNoteTargetState,
|
|
noteTarget: ActiveTerminalNoteTarget
|
|
): string | null {
|
|
const livePtyIds = state.ptyIdsByTabId?.[noteTarget.tabId] ?? []
|
|
if (livePtyIds.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const ptyIdsByLeafId = state.terminalLayoutsByTabId[noteTarget.tabId]?.ptyIdsByLeafId
|
|
if (ptyIdsByLeafId && Object.keys(ptyIdsByLeafId).length > 0) {
|
|
const activeLeafPtyId = ptyIdsByLeafId[noteTarget.leafId]
|
|
// Why: layout maps can survive sleep/reconnect; ptyIdsByTabId is the live
|
|
// PTY source of truth for whether submitting with Enter is currently safe.
|
|
return activeLeafPtyId && livePtyIds.includes(activeLeafPtyId) ? activeLeafPtyId : null
|
|
}
|
|
return livePtyIds[0] ?? null
|
|
}
|
|
|
|
function hasFocusedPaneAgentHint(
|
|
state: ActiveTerminalNoteTargetState,
|
|
worktreeId: string,
|
|
noteTarget: ActiveTerminalNoteTarget
|
|
): boolean {
|
|
const tab = (state.tabsByWorktree[worktreeId] ?? []).find(
|
|
(entry) => entry.id === noteTarget.tabId
|
|
)
|
|
const runtimeTitle = getFocusedRuntimePaneTitle(state, noteTarget)
|
|
if (runtimeTitle !== null) {
|
|
return isRecognizedAgentTitle(runtimeTitle)
|
|
}
|
|
if (tab?.launchAgent) {
|
|
return true
|
|
}
|
|
|
|
return tab?.title ? isRecognizedAgentTitle(tab.title) : false
|
|
}
|
|
|
|
function getFocusedRuntimePaneTitle(
|
|
state: ActiveTerminalNoteTargetState,
|
|
noteTarget: ActiveTerminalNoteTarget
|
|
): string | null {
|
|
const paneTitles = state.runtimePaneTitlesByTabId?.[noteTarget.tabId]
|
|
if (!paneTitles || Object.keys(paneTitles).length === 0) {
|
|
return null
|
|
}
|
|
|
|
const layout = state.terminalLayoutsByTabId[noteTarget.tabId]
|
|
const titleEntries = Object.entries(paneTitles)
|
|
if (layout?.root) {
|
|
// Why: split-pane title maps can be sparse; a lone background title must not
|
|
// enable "send to active agent" for the focused shell pane.
|
|
for (const [runtimePaneId, title] of titleEntries) {
|
|
if (resolveRuntimePaneTitleLeafId(layout, runtimePaneId) === noteTarget.leafId) {
|
|
return title
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
if (titleEntries.length === 1) {
|
|
return titleEntries[0][1]
|
|
}
|
|
|
|
for (const [runtimePaneId, title] of titleEntries) {
|
|
if (resolveRuntimePaneTitleLeafId(layout, runtimePaneId) === noteTarget.leafId) {
|
|
return title
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isRecognizedAgentTitle(title: string): boolean {
|
|
return detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null
|
|
}
|