pi-agent: Fix final integrated review

This commit is contained in:
proxyctl coordinator
2026-07-18 02:37:54 +08:00
parent ed8b527f1f
commit 652d7eba2b
18 changed files with 566 additions and 393 deletions
+94 -42
View File
@@ -1,8 +1,9 @@
package tui
import (
"errors"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
@@ -11,11 +12,12 @@ import (
type operationError struct {
status app.OperationStatus
detail string
intent app.Intent
}
// operationCoordinator owns the complete interactive operation lifecycle. The
// root Model delegates stream consumption, progress, confirmation, current
// error, and deferred-exit state here instead of mirroring those states.
// error, spinner, and deferred-exit state here instead of mirroring them.
type operationCoordinator struct {
active bool
intent app.Intent
@@ -29,6 +31,7 @@ type operationCoordinator struct {
confirmationDialog confirmationDialog
confirmationIntent *app.Intent
quitRequested bool
spinner spinner.Model
}
type operationProgress struct {
@@ -37,20 +40,50 @@ type operationProgress struct {
closed bool
}
func newOperationCoordinator() operationCoordinator {
return operationCoordinator{progress: make(map[string]app.TargetProgressPhase)}
var applyingSpinner = spinner.Spinner{
Frames: []string{"|", "/", "-", "\\"},
FPS: 90 * time.Millisecond,
}
func waitForOperation(updates <-chan app.OperationUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
func newOperationCoordinator() operationCoordinator {
return operationCoordinator{
progress: make(map[string]app.TargetProgressPhase),
spinner: spinner.New(spinner.WithSpinner(applyingSpinner)),
}
}
// waitForOperation races the stream against the real Bubbles tick command.
// Only the stream is read directly, so a losing tick cannot consume an update.
func waitForOperation(updates <-chan app.OperationUpdate, tick tea.Cmd) tea.Cmd {
return func() tea.Msg {
if tick == nil {
update, ok := <-updates
if !ok {
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
}
ticks := make(chan tea.Msg, 1)
go func() { ticks <- tick() }()
select {
case update, ok := <-updates:
if !ok {
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
case message := <-ticks:
return message
}
}
}
func (o operationCoordinator) wait() tea.Cmd {
if !o.active || o.updates == nil {
return nil
}
return waitForOperation(o.updates, o.spinner.Tick)
}
func (o *operationCoordinator) start(application app.InteractiveModule, intent app.Intent) (app.Snapshot, tea.Cmd, bool) {
if o.quitRequested || o.active || o.confirmation != nil {
o.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current operation to finish before starting another modification"}
@@ -61,11 +94,12 @@ func (o *operationCoordinator) start(application app.InteractiveModule, intent a
o.intent = intent
o.updates = stream.Updates
o.progress = make(map[string]app.TargetProgressPhase)
o.spinner = spinner.New(spinner.WithSpinner(applyingSpinner))
if intent.TargetID != "" {
o.progress[intent.TargetID] = app.TargetProgressRunning
}
o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
return stream.Snapshot, waitForOperation(stream.Updates), true
return stream.Snapshot, o.wait(), true
}
func operationStartingSummary(kind app.IntentKind) string {
@@ -79,34 +113,38 @@ func operationStartingSummary(kind app.IntentKind) string {
}
}
func (o *operationCoordinator) tick(message spinner.TickMsg) tea.Cmd {
if !o.active {
return nil
}
updated, nextTick := o.spinner.Update(message)
o.spinner = updated
return waitForOperation(o.updates, nextTick)
}
func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTargetID string) {
status := update.Status()
progress := update.Progress()
for _, item := range progress {
decoded := update.Decode()
for _, item := range decoded.Progress {
o.progress[item.TargetID] = item.Phase
if item.Phase == app.TargetProgressSucceeded {
o.resolveTargetError(item.TargetID)
}
}
if status.Summary != "" {
o.status = status
if decoded.Status.Summary != "" {
o.status = decoded.Status
}
if update.IsSucceeded() {
o.resolveTargetError(status.TargetID)
}
if update.IsFailed() {
o.currentError = &operationError{status: status, detail: errorDetail(update.Err())}
switch decoded.Kind {
case app.OperationSucceeded:
o.resolveError(o.intent)
case app.OperationFailed:
o.currentError = &operationError{status: decoded.Status, detail: errorDetail(decoded.Err), intent: o.intent}
o.errorExpanded = false
o.errorOffset = 0
firstFailedTargetID = update.FirstFailedTargetID()
}
if request := update.Confirmation(); request != nil {
firstFailedTargetID = decoded.FirstFailedTargetID
case app.OperationConfirmation:
intent := o.intent
o.confirmation = request
o.confirmationDialog = newConfirmationDialog(*request)
o.confirmation = decoded.Confirmation
o.confirmationDialog = newConfirmationDialog(*decoded.Confirmation)
o.confirmationIntent = &intent
}
if update.IsTerminal() {
if decoded.IsTerminal() {
o.finish()
}
return firstFailedTargetID
@@ -119,8 +157,8 @@ func errorDetail(err error) string {
return err.Error()
}
func (o *operationCoordinator) resolveTargetError(targetID string) {
if targetID == "" || o.currentError == nil || o.currentError.status.TargetID != targetID {
func (o *operationCoordinator) resolveError(success app.Intent) {
if o.currentError == nil || !relatedResolution(o.currentError.intent, success) {
return
}
o.currentError = nil
@@ -128,6 +166,28 @@ func (o *operationCoordinator) resolveTargetError(targetID string) {
o.errorOffset = 0
}
func relatedResolution(failed, success app.Intent) bool {
switch {
case failed.Kind == app.RefreshTargets:
return success.Kind == app.RefreshTargets
case failed.Kind == app.ActivateProfile:
return success.Kind == app.ActivateProfile && failed.Name == success.Name
case isTargetMutation(failed.Kind):
return isTargetMutation(success.Kind) && failed.TargetID != "" && failed.TargetID == success.TargetID
default:
return false
}
}
func isTargetMutation(kind app.IntentKind) bool {
switch kind {
case app.EnableTarget, app.DisableTarget, app.ApplyProfileToTarget, app.ReapplyActiveProfile, app.RestoreOriginal:
return true
default:
return false
}
}
func (o *operationCoordinator) finish() {
o.active = false
o.intent = app.Intent{}
@@ -139,6 +199,7 @@ func (o *operationCoordinator) unexpectedClose() {
o.currentError = &operationError{
status: o.status,
detail: "The Application operation stream closed before publishing a terminal result.",
intent: o.intent,
}
o.errorExpanded = false
o.errorOffset = 0
@@ -191,12 +252,3 @@ func (o *operationCoordinator) requestQuit() tea.Cmd {
}
return tea.Quit
}
func (o *operationCoordinator) fail(err error, summary string) {
if err == nil {
err = errors.New(summary)
}
o.status = app.OperationStatus{Kind: app.StatusError, Summary: summary}
o.currentError = &operationError{status: o.status, detail: err.Error()}
o.finish()
}