pi-agent: Implement progressive Target detection

This commit is contained in:
proxyctl coordinator
2026-07-17 21:01:35 +08:00
parent fbb81ffb40
commit ad651402fd
15 changed files with 558 additions and 31 deletions
+77 -8
View File
@@ -50,6 +50,7 @@ type Model struct {
status string
applying bool
refreshing bool
detectionUpdates <-chan app.TargetDetectionUpdate
profileTransition bool
progressTarget string
quitRequested bool
@@ -64,6 +65,11 @@ type refreshDone struct {
result app.Result
}
type targetDetectionProgress struct {
update app.TargetDetectionUpdate
updates <-chan app.TargetDetectionUpdate
}
type profileOperationProgress struct {
result app.Result
updates <-chan app.Result
@@ -80,6 +86,16 @@ type probeDone struct {
gen uint64
}
func waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return targetDetectionProgress{update: app.TargetDetectionUpdate{Complete: true}, updates: updates}
}
return targetDetectionProgress{update: update, updates: updates}
}
}
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
return func() tea.Msg {
result, ok := <-updates
@@ -96,6 +112,10 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
option(&options)
}
snapshot := application.Snapshot()
var detectionUpdates <-chan app.TargetDetectionUpdate
if progressive, ok := application.(app.ProgressiveDetectionModule); ok {
snapshot, detectionUpdates = progressive.BeginTargetDetection()
}
return Model{
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
@@ -103,11 +123,18 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
selectedTargetID: newTargetCatalog(snapshot, false).firstID(),
width: 80,
height: 24,
refreshing: detectionUpdates != nil,
detectionUpdates: detectionUpdates,
detect: probe.Detect,
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Init() tea.Cmd {
if m.detectionUpdates != nil {
return waitForTargetDetection(m.detectionUpdates)
}
return nil
}
// busy is the single-operation guard for asynchronous Application work. Every
// operation start checks it, so a completion can only belong to the one current
@@ -120,6 +147,10 @@ func (m Model) operationInFlight() bool {
return m.applying || m.refreshing || m.profileTransition
}
func (m Model) dashboardNavigationAvailable() bool {
return !m.quitRequested && !m.applying && !m.profileTransition
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width
@@ -138,6 +169,21 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.quitRequested = true
return m, nil
}
if progress, ok := message.(targetDetectionProgress); ok {
if !m.refreshing || progress.updates != m.detectionUpdates {
return m, nil
}
if progress.update.Result.TargetID != "" {
m.applyTargetDetection(progress.update.Result)
}
if progress.update.Complete {
m.refreshing = false
m.detectionUpdates = nil
m.status = "Target availability and state checks completed"
return m, m.deferredQuit()
}
return m, waitForTargetDetection(progress.updates)
}
if completed, ok := message.(refreshDone); ok {
if !m.refreshing {
return m, nil
@@ -327,12 +373,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.busy() {
m.refreshing = true
m.status = "Refreshing Target availability and state…"
if progressive, ok := m.application.(app.ProgressiveDetectionModule); ok {
m.snapshot, m.detectionUpdates = progressive.BeginTargetDetection()
m.retainTargetSelection()
return m, waitForTargetDetection(m.detectionUpdates)
}
return m, func() tea.Msg {
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
}
}
case "tab":
if !m.busy() {
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
@@ -340,14 +391,14 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case "u":
if !m.busy() {
if m.dashboardNavigationAvailable() {
formerIndex := catalog.index(m.selectedTargetID)
m.showUnavailable = !m.showUnavailable
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
m.detailsOffset = 0
}
case "up", "k":
if !m.busy() {
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
@@ -356,7 +407,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case "down", "j":
if !m.busy() {
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
@@ -365,17 +416,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case "pgup":
if !m.busy() && m.dashboardFocus == targetDetailsPane {
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5)
}
case "pgdown":
if !m.busy() && m.dashboardFocus == targetDetailsPane {
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset += 5
}
case " ":
if !m.busy() {
selected, ok := catalog.selected(m.selectedTargetID)
if !ok || !selected.Available {
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
break
}
desired := selected.State == target.Disabled || selected.State == target.External
@@ -634,6 +685,24 @@ func (m *Model) retainTargetSelection() {
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
}
func (m *Model) applyTargetDetection(result app.TargetDetectionResult) {
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable)
formerIndex := formerCatalog.index(m.selectedTargetID)
for i := range m.snapshot.Targets {
if m.snapshot.Targets[i].ID != result.TargetID {
continue
}
m.snapshot.Targets[i].DetectionPhase = result.Phase
m.snapshot.Targets[i].DetectionErr = result.Err
m.snapshot.Targets[i].Available = result.Availability.Available
m.snapshot.Targets[i].AvailabilityEvidence = result.Availability.Evidence
m.snapshot.Targets[i].State = result.Inspection.State
m.snapshot.Targets[i].Evidence = result.Inspection.Evidence
break
}
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
}
func (f formState) labels() []string {
if f.profile.Mode == profile.AdvancedMode {
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}