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
+1 -1
View File
@@ -213,7 +213,7 @@ func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFai
close(updates)
model, _ = applyOperationCommand(t, model, command)
view := model.View()
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") {
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") {
t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
}
}
+17
View File
@@ -38,6 +38,23 @@ func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t
}
}
func TestCompleteModelFailedProgressSupplementsTruthfulTargetState(t *testing.T) {
for _, state := range []target.State{target.Enabled, target.Disabled, target.External, target.Drifted} {
t.Run(string(state), func(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{
ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: state,
}}}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model.operation.progress["shell"] = app.TargetProgressFailed
view := model.View()
wantState := targetStateLabel(state)
if !strings.Contains(view, "> "+wantState+" ! Failed Shell") {
t.Fatalf("failed progress replaced truthful %s state instead of supplementing it:\n%s", state, view)
}
})
}
}
func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled, Evidence: "fragment absent"},
+22 -14
View File
@@ -17,10 +17,13 @@ type dashboardRenderState struct {
focus dashboardPane
detailsOffset int
operationProgress map[string]app.TargetProgressPhase
applyingFrame string
terminalWidth int
terminalHeight int
}
func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string {
if width >= wideWidth && height >= 12 {
if state.terminalWidth >= wideWidth && state.terminalHeight >= wideHeight {
leftWidth := (width - 1) * 2 / 5
rightWidth := width - leftWidth - 1
left := p.renderTargetListPane(state, leftWidth, height)
@@ -49,7 +52,7 @@ func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, w
if item.ID == state.selectedTargetID {
cursor = "> "
}
badge := p.targetBadge(item, state.operationProgress[item.ID])
badge := p.targetBadge(item, state.operationProgress[item.ID], state.applyingFrame)
lines = append(lines, cursor+badge+" "+item.Description.Name)
}
if len(lines) == 0 {
@@ -70,7 +73,7 @@ func (p terminalPresentation) renderTargetDetailsPane(state dashboardRenderState
selected, ok := state.catalog.selected(state.selectedTargetID)
content := "Select a Target to review its details."
if ok {
content = p.targetDetails(selected)
content = p.targetDetails(selected, state.operationProgress[selected.ID], state.applyingFrame)
}
body := fixedViewport(content, width, bodyHeight, state.detailsOffset, true)
return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height)
@@ -101,27 +104,32 @@ func fixedViewport(content string, width, height, offset int, wrap bool) string
return view.View()
}
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase) string {
switch progress {
case app.TargetProgressRunning:
return p.theme.renderTargetState(item.State, true)
case app.TargetProgressFailed:
return p.theme.checkFailed.Render("! Failed")
}
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string {
switch item.DetectionPhase {
case app.TargetChecking:
return p.theme.checking.Render("… Checking")
case app.TargetDetectionFailed:
return p.theme.checkFailed.Render("! Check failed")
return p.theme.checkFailed.Render("! Failed")
}
if !item.Available {
return p.theme.unavailable.Render("○ Unavailable")
}
return p.theme.renderTargetState(item.State, false)
actual := p.theme.renderTargetState(item.State, false)
switch progress {
case app.TargetProgressRunning:
if applyingFrame == "" {
applyingFrame = "|"
}
return p.theme.applying.Render(applyingFrame + " Applying")
case app.TargetProgressFailed:
return actual + " " + p.theme.checkFailed.Render("! Failed")
default:
return actual
}
}
func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
state := p.targetBadge(item, 0)
func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string {
state := p.targetBadge(item, progress, applyingFrame)
evidence := item.Evidence
switch item.DetectionPhase {
case app.TargetChecking:
+31
View File
@@ -46,6 +46,37 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
})
}
func TestCompleteModelWideLayoutUsesOriginalTerminalBoundary(t *testing.T) {
module := responsiveLayoutModule()
for _, test := range []struct {
name string
width int
height int
wantWide bool
expandError bool
}{
{name: "one row below height boundary", width: 100, height: 27},
{name: "exact boundary", width: 100, height: 28, wantWide: true},
{name: "expanded error does not change exact boundary", width: 100, height: 28, wantWide: true, expandError: true},
} {
t.Run(test.name, func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, test.width, test.height)
if test.expandError {
model.operation.currentError = &operationError{
status: app.OperationStatus{Kind: app.StatusError, Summary: "Target update failed"},
detail: strings.Repeat("expanded diagnostic line\n", 20),
}
model.operation.errorExpanded = true
}
view := model.View()
if got := sameLine(view, "Targets", "Target details"); got != test.wantWide {
t.Fatalf("side-by-side = %v, want %v at %dx%d (expanded=%v):\n%s", got, test.wantWide, test.width, test.height, test.expandError, view)
}
})
}
}
func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
module := responsiveLayoutModule()
model := resizedModel(t, module, themeModeNoColor, 120, 36)
+37 -35
View File
@@ -57,54 +57,56 @@ func (m Model) dashboardQuitAvailable() bool {
}
func (p terminalPresentation) helpDialogView(model presentationState) terminalPage {
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(protectRenderedText(p.contextualHelp(model)))
content := lipgloss.NewStyle().Width(max(1, model.terminal.width)).Render(protectRenderedText(p.contextualHelp(model)))
exitHint := "Ctrl+C: exit"
if model.operationActive {
if model.operation.active {
exitHint = "Ctrl+C: defer safe exit"
}
if model.exitDeferred {
if model.operation.exitDeferred {
exitHint = "Exit pending until safe completion"
}
return terminalPage{
primary: content,
footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint,
focusLine: -1,
primaryOffset: model.help.offset,
primaryOffset: model.help.dialog.offset,
}
}
func (p terminalPresentation) contextualHelp(model presentationState) string {
dashboard, operation := model.dashboard, model.operation
profiles, form := model.profile, model.form.state
var body strings.Builder
body.WriteString(p.theme.section.Render("Contextual Help"))
body.WriteString("\nThis describes the page or dialog beneath Help. While Help is open, only its scrolling and closing keys plus global Ctrl+C are accepted; the underlying state remains unchanged.\n\n")
switch {
case model.confirmation != nil:
case operation.confirmation != nil:
body.WriteString(p.theme.section.Render("Safety confirmation"))
fmt.Fprintf(&body, "\n%s\n", model.confirmation.Title)
if model.confirmation.Summary != "" {
fmt.Fprintf(&body, "%s\n", model.confirmation.Summary)
fmt.Fprintf(&body, "\n%s\n", operation.confirmation.Title)
if operation.confirmation.Summary != "" {
fmt.Fprintf(&body, "%s\n", operation.confirmation.Summary)
}
body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ")
if model.confirmationDialog != nil {
if operation.confirmationDialog != nil {
body.WriteByte('\n')
body.WriteString(confirmationFocusDescription(*model.confirmationDialog))
body.WriteString(confirmationFocusDescription(*operation.confirmationDialog))
}
body.WriteByte('\n')
case model.profileDelete != nil:
case profiles.deletion != nil:
body.WriteString(p.theme.section.Render("Delete confirmation"))
fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", model.profileDelete.name)
fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", profiles.deletion.name)
body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ")
body.WriteByte('\n')
body.WriteString(confirmationFocusDescription(model.profileDelete.dialog))
body.WriteString(confirmationFocusDescription(profiles.deletion.dialog))
body.WriteByte('\n')
case model.picker.open:
case dashboard.picker.open:
body.WriteString(p.theme.section.Render("Active Profile picker"))
body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down or Ctrl+P/Ctrl+N selects, and PgUp/PgDn moves by a page. Enter activates the selection. Escape cancels.\n")
case model.driftResolutionTargetID != "":
case dashboard.driftResolutionTargetID != "":
body.WriteString(p.theme.section.Render("Drift Resolution dialog"))
body.WriteString("\nr reapplies the Active Profile. o restores original configuration when ownership evidence permits. Enter, q, or Escape cancels.\n")
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok {
for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} {
action, found := driftResolutionAction(selected, kind)
if found && action.Available {
@@ -120,10 +122,10 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
}
case model.page == profilesPage:
body.WriteString(p.theme.section.Render("Profile management"))
if selected, ok := profileAt(model.snapshot, model.selected); ok {
if selected, ok := profileAt(model.snapshot, profiles.selected); ok {
fmt.Fprintf(&body, "\nSelected Profile: %s.\n", selected.Name)
body.WriteString("Up/Down or j/k selects a Profile. PgUp/PgDn scrolls its details. n creates, e or Enter edits, and d opens the delete confirmation. q or Escape returns to the dashboard.\n")
if model.operationActive || model.exitDeferred {
if operation.active || operation.exitDeferred {
body.WriteString("Profile modifications are disabled until the current mutation completes.\n")
} else {
body.WriteString("Create, Edit selected Profile, and Delete selected Profile are available.\n")
@@ -133,36 +135,36 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
}
case model.page == formPage:
body.WriteString(p.theme.section.Render("Profile form"))
if model.form.field >= 0 && model.form.field < len(model.form.inputs) {
fmt.Fprintf(&body, "\nFocus: %s field.\n", model.form.inputs[model.form.field].label)
if form.field >= 0 && form.field < len(form.inputs) {
fmt.Fprintf(&body, "\nFocus: %s field.\n", form.inputs[form.field].label)
}
body.WriteString("Type or paste to insert at the cursor. Left/Right moves the cursor; Home/End moves to the field boundary; Backspace/Delete removes text. Long inputs scroll horizontally. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode while preserving Profile values. Ctrl+S validates and saves. q or Escape cancels and returns to Profile management.\n")
if model.form.currentField() == profile.BypassField {
if form.currentField() == profile.BypassField {
body.WriteString("PgUp/PgDn scrolls the normalized Bypass preview while the Bypass field is focused.\n")
}
if model.form.profile.Mode == profile.SingleMode {
if form.profile.Mode == profile.SingleMode {
switch {
case model.form.probe.active:
case form.probe.active:
body.WriteString("Protocol Probe is in progress for the Single proxy URL field. Its progress remains attached to that field; Ctrl+D does not start a duplicate probe while that field is focused.\n")
case model.form.currentField() == profile.SingleURLField:
case form.currentField() == profile.SingleURLField:
body.WriteString("Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field. It never runs on form entry, focus changes, or save.\n")
default:
body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n")
}
} else if model.form.hasProbeSourceAttachment() {
} else if form.hasProbeSourceAttachment() {
body.WriteString("The Protocol Probe result remains attached to its read-only initiating Single proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from that probe.\n")
}
default:
body.WriteString(p.theme.section.Render("Dashboard"))
focus := "Targets focus"
if model.dashboardFocus == targetDetailsPane {
if dashboard.focus == targetDetailsPane {
focus = "Target details focus"
}
fmt.Fprintf(&body, "\nContext: %s.\n", focus)
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID)
if selected, ok := catalog.selected(model.selectedTargetID); ok {
catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
if selected, ok := catalog.selected(dashboard.selectedTargetID); ok {
fmt.Fprintf(&body, "Selected Target: %s. State: %s.\n", selected.Description.Name, targetStateLabel(selected.State))
availability := targetMutationAvailability(selected, true, model.operationActive || model.exitDeferred)
availability := targetMutationAvailability(selected, true, operation.active || operation.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available {
fmt.Fprintf(&body, "Primary action: %s — available with Space.\n", label)
@@ -170,12 +172,12 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
fmt.Fprintf(&body, "Primary action: %s — disabled: %s.\n", label, availability.reason)
}
}
if model.dashboardFocus == targetDetailsPane {
if dashboard.focus == targetDetailsPane {
body.WriteString("Up/Down, j/k, and PgUp/PgDn scroll Target details. Tab focuses Targets.\n")
} else {
body.WriteString("Up/Down or j/k selects a Target. Tab focuses Target details.\n")
}
if model.operationActive || model.exitDeferred {
if operation.active || operation.exitDeferred {
body.WriteString("u still shows or hides unavailable Targets. p, m, r, and Space modifications are disabled until the current mutation completes.\n")
} else {
body.WriteString("u shows or hides unavailable Targets. p opens the Active Profile picker. m opens Profile management. r refreshes Targets.\n")
@@ -185,18 +187,18 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
body.WriteString("\n")
body.WriteString(p.theme.section.Render("Operation state"))
switch {
case model.exitDeferred:
case operation.exitDeferred:
body.WriteString("\nExit is pending. Read-only browsing and Help remain available; all new work is disabled until the mutation completes safely.\n")
case model.operationActive:
case operation.active:
body.WriteString("\nMutation in progress. Target selection, details scrolling, and Help remain available.\nConflicting mutations are disabled until completion. Ctrl+C requests a deferred safe exit.\n")
default:
body.WriteString("\nNo mutation is in progress. Ctrl+C exits immediately.\n")
}
if model.currentError != nil && model.page == mainPage && model.confirmation == nil && !model.picker.open && model.driftResolutionTargetID == "" {
if operation.currentError != nil && model.page == mainPage && operation.confirmation == nil && !dashboard.picker.open && dashboard.driftResolutionTargetID == "" {
body.WriteString("e expands or collapses the current error; x acknowledges it.\n")
}
if model.page == mainPage && model.profileDelete == nil {
if model.page == mainPage && profiles.deletion == nil {
body.WriteString("\n")
body.WriteString(p.theme.section.Render("Built-in Target reference"))
body.WriteString("\n")
+58 -71
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
@@ -91,12 +92,7 @@ func targetsChecking(snapshot app.Snapshot) bool {
return false
}
func (m Model) Init() tea.Cmd {
if m.operation.updates != nil {
return waitForOperation(m.operation.updates)
}
return nil
}
func (m Model) Init() tea.Cmd { return m.operation.wait() }
// 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
@@ -105,12 +101,6 @@ func (m Model) busy() bool { return m.operation.busy() }
func (m Model) operationInFlight() bool { return m.operation.active }
func (m Model) dashboardNavigationAvailable() bool {
// A deferred exit rejects new work, not read-only browsing. Selection,
// details, and Help remain useful until the current mutation is safe.
return true
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width
@@ -141,6 +131,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
if tick, ok := message.(spinner.TickMsg); ok {
return m, m.operation.tick(tick)
}
if progress, ok := message.(operationProgress); ok {
if !m.operation.active || progress.updates != m.operation.updates {
return m, nil
@@ -170,7 +163,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if progress.update.IsTerminal() {
return m, m.operation.deferredQuit()
}
return m, waitForOperation(progress.updates)
return m, m.operation.wait()
}
if done, ok := message.(probeDone); ok {
return m, m.form.applyProbeResult(done.token, done.caps, done.err)
@@ -346,44 +339,36 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
}
case "tab":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
m.dashboardFocus = targetListPane
}
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
m.dashboardFocus = targetListPane
}
case "u":
if m.dashboardNavigationAvailable() {
formerIndex := catalog.index(m.selectedTargetID)
m.showUnavailable = !m.showUnavailable
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
formerIndex := catalog.index(m.selectedTargetID)
m.showUnavailable = !m.showUnavailable
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
m.detailsOffset = 0
case "up", "k":
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0
}
case "up", "k":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0
}
}
case "down", "j":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.detailsOffset = 0
}
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.detailsOffset = 0
}
case "pgup":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5)
}
case "pgdown":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset += 5
}
case " ":
@@ -641,39 +626,41 @@ func (m *Model) ensureProfileSelectionVisible() {
func (m Model) View() string {
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
profileOffset: m.profileViewportOffset,
profileDetails: m.profileDetailsOffset,
profileDelete: m.profileDelete,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
driftResolutionTargetID: m.driftResolutionTargetID,
picker: m.picker,
form: m.form,
help: m.help,
confirmation: m.operation.confirmation,
confirmationDialog: &m.operation.confirmationDialog,
status: m.status,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
snapshot: m.snapshot,
page: m.page,
terminal: terminalDimensions{width: m.width, height: m.height},
dashboard: dashboardPresentationState{
selectedTargetID: m.selectedTargetID,
focus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
driftResolutionTargetID: m.driftResolutionTargetID,
picker: m.picker,
},
profile: profilePresentationState{
selected: m.selected,
listOffset: m.profileViewportOffset,
detailsOffset: m.profileDetailsOffset,
deletion: m.profileDelete,
status: m.status,
},
form: formPresentationState{state: m.form},
help: helpPresentationState{dialog: m.help},
operation: operationPresentationState{
confirmation: m.operation.confirmation,
confirmationDialog: &m.operation.confirmationDialog,
status: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
active: m.operation.active,
progress: m.operation.progress,
spinnerFrame: m.operation.spinner.View(),
},
})
}
func (m Model) availableTargets() []app.TargetSnapshot {
return availableTargets(m.snapshot)
}
func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
+82 -3
View File
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
@@ -50,6 +51,35 @@ func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, t
return updated.(Model), next
}
func TestCompleteModelUsesAnimatedBubblesSpinnerOnlyWhileOperationRuns(t *testing.T) {
updates := make(chan app.OperationUpdate)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil || !strings.Contains(model.View(), "| Applying") {
t.Fatalf("operation did not start the Bubbles spinner:\n%s", model.View())
}
updated, next := model.Update(spinner.TickMsg{ID: model.operation.spinner.ID()})
model = updated.(Model)
if next == nil || !strings.Contains(model.View(), "/ Applying") {
t.Fatalf("spinner tick did not animate the in-row Applying marker:\n%s", model.View())
}
terminal := app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
updated, _ = model.Update(operationProgress{update: terminal, updates: updates})
model = updated.(Model)
terminalView := model.View()
updated, stale := model.Update(spinner.TickMsg{ID: model.operation.spinner.ID()})
model = updated.(Model)
if stale != nil || model.View() != terminalView || strings.Contains(model.View(), "Applying") {
t.Fatalf("spinner tick changed terminal operation state:\n%s", model.View())
}
}
func TestCompleteModelKeepsDashboardAndHelpBrowsableWhileMutationIsSerialized(t *testing.T) {
updates := make(chan app.OperationUpdate, 2)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
@@ -129,7 +159,32 @@ func TestCompleteModelPreservesExpandableCurrentErrorAndFocusesFirstFailedTarget
}
}
func TestCompleteModelClearsCurrentErrorForDirectlyRelatedSuccessfulDetection(t *testing.T) {
func TestCompleteModelReadOnlyRefreshDoesNotClearTargetMutationErrorForSameTarget(t *testing.T) {
failure := make(chan app.OperationUpdate, 1)
refresh := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, refresh}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
failure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target update failed", TargetID: "shell"}, nil, "shell", errors.New("permission denied"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
refresh <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(refresh)
model, _ = applyOperationCommand(t, model, command)
if !strings.Contains(model.View(), "Current error: Shell Target update failed") {
t.Fatalf("read-only Refresh Targets cleared an unresolved mutation error:\n%s", model.View())
}
}
func TestCompleteModelRelatedTargetMutationClearsErrorForSameTarget(t *testing.T) {
failure := make(chan app.OperationUpdate, 1)
resolution := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}}
@@ -145,12 +200,36 @@ func TestCompleteModelClearsCurrentErrorForDirectlyRelatedSuccessfulDetection(t
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
resolution <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"},
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(resolution)
model, _ = applyOperationCommand(t, model, command)
if strings.Contains(model.View(), "Current error:") {
t.Fatalf("explicit related resolution did not clear the current error:\n%s", model.View())
t.Fatalf("explicit related Target resolution did not clear the current error:\n%s", model.View())
}
}
func TestCompleteModelSuccessfulRefreshClearsOnlyRefreshError(t *testing.T) {
failure := make(chan app.OperationUpdate, 1)
resolution := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
failure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Some Target checks failed", TargetID: "shell"}, nil, "shell", errors.New("inspection failed"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
resolution <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Target availability and state checks completed"}, nil)
close(resolution)
model, _ = applyOperationCommand(t, model, command)
if strings.Contains(model.View(), "Current error:") {
t.Fatalf("successful Refresh Targets did not clear its own error:\n%s", model.View())
}
}
+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()
}
+61 -54
View File
@@ -9,6 +9,7 @@ import (
)
func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage {
dashboard, operation := model.dashboard, model.operation
activeProfile := model.snapshot.ActiveProfile
if activeProfile == "" {
activeProfile = "No Active Profile"
@@ -20,11 +21,11 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot))
}
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID)
catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
footer := p.dashboardFooter(model, catalog)
if model.currentError != nil {
if operation.currentError != nil {
footer += "\ne: expand/collapse current error x: acknowledge error"
if model.errorExpanded {
if operation.errorExpanded {
footer += " PgUp/PgDn: scroll error details"
}
}
@@ -33,22 +34,25 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
footer: footer,
dashboard: &dashboardRenderState{
catalog: catalog,
selectedTargetID: model.selectedTargetID,
focus: model.dashboardFocus,
detailsOffset: model.detailsOffset,
operationProgress: model.operationProgress,
selectedTargetID: dashboard.selectedTargetID,
focus: dashboard.focus,
detailsOffset: dashboard.detailsOffset,
operationProgress: operation.progress,
applyingFrame: operation.spinnerFrame,
terminalWidth: width,
terminalHeight: height,
},
}
if model.picker.open {
if dashboard.picker.open {
page.dashboard = nil
page.primary = model.picker.view(model.snapshot, width, max(5, height-9))
page.primary = dashboard.picker.view(model.snapshot, width, max(5, height-9))
page.footer = "Type to search ↑/↓ PgUp/PgDn: select Enter: activate\nEsc: cancel ?: help"
if width < 70 {
page.footer = "Type: search ↑/↓: select Enter: activate\nEsc: cancel ?: help"
}
}
if model.driftResolutionTargetID != "" && model.confirmation == nil {
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
if dashboard.driftResolutionTargetID != "" && operation.confirmation == nil {
if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok {
page.dashboard = nil
page.primary, page.context = p.driftResolutionView(selected)
page.wrapPrimary = true
@@ -56,10 +60,10 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/q/Esc: cancel ?: help"
}
}
if model.confirmation != nil {
dialog := newConfirmationDialog(*model.confirmation)
if model.confirmationDialog != nil {
dialog = *model.confirmationDialog
if operation.confirmation != nil {
dialog := newConfirmationDialog(*operation.confirmation)
if operation.confirmationDialog != nil {
dialog = *operation.confirmationDialog
}
page.dashboard = nil
page.primary = dialog.view()
@@ -93,71 +97,73 @@ func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (
}
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
dashboard, operation, width := model.dashboard, model.operation, model.terminal.width
visibility := "u: show unavailable"
if model.showUnavailable {
if dashboard.showUnavailable {
visibility = "u: hide unavailable"
}
if model.dashboardFocus == targetDetailsPane {
if model.exitDeferred {
if dashboard.focus == targetDetailsPane {
if operation.exitDeferred {
return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion"
}
if model.width < wideWidth {
if model.operationActive {
if width < wideWidth {
if operation.active {
return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets\n?: help q/Ctrl+C: defer exit; mutations disabled"
}
if model.width < 70 {
if width < 70 {
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Profile m: manage ?: help q: quit"
}
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit"
}
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: switch Active Profile m: manage Proxy Profiles ?: help q/Ctrl+C: quit"
if model.operationActive {
if operation.active {
footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + " ?: help\nq/Ctrl+C: defer safe exit; mutations disabled"
}
return footer
}
actions := "↑/k ↓/j: select Tab: focus details " + visibility
if model.exitDeferred {
if operation.exitDeferred {
return actions + " ?: help\nExit pending; new work disabled until safe completion"
}
selected, ok := catalog.selected(model.selectedTargetID)
availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred)
selected, ok := catalog.selected(dashboard.selectedTargetID)
availability := targetMutationAvailability(selected, ok, operation.active || operation.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available {
actions = "Space: " + label + " " + actions
} else {
actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions
}
if model.width < wideWidth {
if width < wideWidth {
if availability.available {
actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details"
} else {
actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: focus details"
}
if model.operationActive {
if operation.active {
return actions + "\n?: help q/Ctrl+C: defer exit; mutations disabled"
}
if model.width < 70 {
if width < 70 {
return actions + "\np: switch Profile m: manage ?: help q: quit"
}
return actions + "\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit"
}
footer := actions + "\np: switch Active Profile m: manage Proxy Profiles r: refresh ?: help q/Ctrl+C: quit"
if model.operationActive {
if operation.active {
footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
}
return footer
}
func (p terminalPresentation) profilesView(model presentationState) terminalPage {
state := model.profile
var list strings.Builder
if len(model.snapshot.Profiles) == 0 {
list.WriteString("No Proxy Profiles saved. Press n to create one.")
}
for i, item := range model.snapshot.Profiles {
cursor, active := " ", " "
if i == model.selected {
if i == state.selected {
cursor = "> "
}
if item.Name == model.snapshot.ActiveProfile {
@@ -171,17 +177,17 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
context := selectedProfileDetails(p, model)
footer := "n: create e: edit Enter: edit d: delete PgUp/PgDn: scroll details\n↑/k ↓/j: select ?: help q/Esc: back"
if model.width < 80 {
if model.terminal.width < 80 {
footer = "↑/k ↓/j: select PgUp/PgDn: details n: create\ne/Enter: edit d: delete ?: help q/Esc: back"
}
if len(model.snapshot.Profiles) == 0 {
footer = "n: create a Proxy Profile ?: help q/Esc: back"
}
if model.profileDelete != nil {
if state.deletion != nil {
return terminalPage{
header: p.theme.title.Render("Proxy Profile management"),
primary: model.profileDelete.dialog.view(),
primaryOffset: model.profileDelete.dialog.offset,
primary: state.deletion.dialog.view(),
primaryOffset: state.deletion.dialog.offset,
wrapPrimary: true,
footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help",
focusLine: -1,
@@ -192,9 +198,9 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
primary: list.String(),
context: context,
footer: footer,
focusLine: model.selected,
primaryOffset: model.profileOffset,
contextOffset: model.profileDetails,
focusLine: state.selected,
primaryOffset: state.listOffset,
contextOffset: state.detailsOffset,
wrapContext: true,
}
}
@@ -204,7 +210,7 @@ func sanitizeFormText(value string) string {
}
func selectedProfileDetails(p terminalPresentation, model presentationState) string {
selected, ok := profileAt(model.snapshot, model.selected)
selected, ok := profileAt(model.snapshot, model.profile.selected)
if !ok {
return ""
}
@@ -233,36 +239,37 @@ func selectedProfileDetails(p terminalPresentation, model presentationState) str
}
func (p terminalPresentation) formView(model presentationState) terminalPage {
form, width := model.form.state, model.terminal.width
action := "Create"
if model.form.originalName != "" {
if form.originalName != "" {
action = "Edit"
}
var fields, bypassPreview strings.Builder
focusLine, currentLine := 0, 0
for i, item := range model.form.inputs {
for i, item := range form.inputs {
if fields.Len() > 0 {
fields.WriteByte('\n')
currentLine++
}
cursor, focus := " ", ""
if i == model.form.field {
if i == form.field {
cursor, focus = "> ", " [focused]"
focusLine = currentLine
}
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, model.form.renderedInput(i, model.width))
if probeText := model.form.probeText(item.field); probeText != "" {
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, form.renderedInput(i, width))
if probeText := form.probeText(item.field); probeText != "" {
fmt.Fprintf(&fields, "\n %s", probeText)
currentLine++
}
if item.field == profile.NameField && model.form.hasProbeSourceAttachment() {
fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(model.form.probe.sourceValue))
fmt.Fprintf(&fields, "\n %s", model.form.probe.feedback)
if item.field == profile.NameField && form.hasProbeSourceAttachment() {
fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(form.probe.sourceValue))
fmt.Fprintf(&fields, "\n %s", form.probe.feedback)
fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:")
currentLine += 3
}
validation := model.form.validationText(i)
validation := form.validationText(i)
if item.field == profile.NameField {
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
if conflict := form.nameConflict(model.snapshot.Profiles); conflict != "" {
validation = conflict
}
}
@@ -272,7 +279,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
currentLine++
}
if item.field == profile.BypassField {
analysis := model.form.bypassAnalysis()
analysis := form.bypassAnalysis()
bypassPreview.WriteString("Normalized Bypass preview:")
if len(analysis) == 0 {
bypassPreview.WriteString(" (none)")
@@ -284,24 +291,24 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
}
}
secondaryHints := "?: help q/Esc: cancel"
if model.form.currentField() == profile.BypassField {
if form.currentField() == profile.BypassField {
secondaryHints = "PgUp/PgDn: scroll form preview " + secondaryHints
}
if model.form.profile.Mode == profile.SingleMode && model.form.currentField() == profile.SingleURLField {
if form.profile.Mode == profile.SingleMode && form.currentField() == profile.SingleURLField {
probeHint := "Ctrl+D: probe"
if model.form.probe.active {
if form.probe.active {
probeHint = "Protocol Probe in progress"
}
secondaryHints = probeHint + " " + secondaryHints
}
footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints
return terminalPage{
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)),
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, form.profile.Mode)),
primary: fields.String(),
context: bypassPreview.String(),
footer: footer,
focusLine: focusLine,
contextAnchor: model.form.bypassPreviewLine,
contextAnchor: form.bypassPreviewLine,
anchorContext: true,
}
}
+79 -53
View File
@@ -56,32 +56,57 @@ func defaultModelOptions() modelOptions {
}
type presentationState struct {
snapshot app.Snapshot
page page
width int
height int
selected int
profileOffset int
profileDetails int
profileDelete *profileDeleteConfirmation
snapshot app.Snapshot
page page
terminal terminalDimensions
dashboard dashboardPresentationState
profile profilePresentationState
form formPresentationState
help helpPresentationState
operation operationPresentationState
}
type terminalDimensions struct {
width int
height int
}
type dashboardPresentationState struct {
selectedTargetID string
dashboardFocus dashboardPane
focus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
picker profilePickerState
form formState
help helpDialogState
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
status string
operationStatus app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
operationActive bool
operationProgress map[string]app.TargetProgressPhase
}
type profilePresentationState struct {
selected int
listOffset int
detailsOffset int
deletion *profileDeleteConfirmation
status string
}
type formPresentationState struct {
state formState
}
type helpPresentationState struct {
dialog helpDialogState
}
type operationPresentationState struct {
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
status app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
active bool
progress map[string]app.TargetProgressPhase
spinnerFrame string
}
// terminalPresentation is the terminal-presentation Module. Application
@@ -116,7 +141,7 @@ func (p terminalPresentation) Render(state presentationState) string {
// semanticTheme style is applied; otherwise an attacker could inject one of
// the same allowlisted SGR sequences that the theme legitimately emits.
state = sanitizePresentationState(state)
width, height := state.width, state.height
width, height := state.terminal.width, state.terminal.height
if width <= 0 {
width = 80
}
@@ -138,7 +163,7 @@ func (p terminalPresentation) Render(state presentationState) string {
default:
page = p.mainView(state, width, height)
}
if state.help.top(state.confirmation) {
if state.help.dialog.top(state.operation.confirmation) {
help := p.helpDialogView(state)
// Retain the underlying page header so Help is visibly contextual and
// modal rather than a replacement navigation destination.
@@ -151,8 +176,8 @@ func (p terminalPresentation) Render(state presentationState) string {
page.dashboard = nil
}
page.status = renderStatus(state)
page.statusExpanded = state.errorExpanded && state.currentError != nil
page.statusOffset = state.errorOffset
page.statusExpanded = state.operation.errorExpanded && state.operation.currentError != nil
page.statusOffset = state.operation.errorOffset
page = redactTerminalPage(page)
// Untrusted values were redacted and stripped of controls before semantic
// styling. The final pass therefore removes unexpected terminal controls
@@ -162,17 +187,18 @@ func (p terminalPresentation) Render(state presentationState) string {
func renderStatus(state presentationState) string {
var lines []string
if state.exitDeferred {
operation := state.operation
if operation.exitDeferred {
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
} else if state.operationStatus.Summary != "" {
lines = append(lines, state.operationStatus.Summary)
} else if state.status != "" {
lines = append(lines, state.status)
} else if operation.status.Summary != "" {
lines = append(lines, operation.status.Summary)
} else if state.profile.status != "" {
lines = append(lines, state.profile.status)
}
if state.currentError != nil {
errorText := "Current error: " + state.currentError.status.Summary
if state.errorExpanded && state.currentError.detail != "" {
errorText += "\n" + state.currentError.detail
if operation.currentError != nil {
errorText := "Current error: " + operation.currentError.status.Summary
if operation.errorExpanded && operation.currentError.detail != "" {
errorText += "\n" + operation.currentError.detail
}
lines = append(lines, errorText)
}
@@ -236,35 +262,35 @@ func sanitizePresentationState(state presentationState) presentationState {
// Apply that stronger policy to every status/error ingress before styling.
sanitizeStatus = sanitizeUntrustedUserinfoText
}
state.status = sanitizeStatus(state.status)
state.operationStatus.Summary = sanitizeStatus(state.operationStatus.Summary)
if state.currentError != nil {
current := *state.currentError
state.profile.status = sanitizeStatus(state.profile.status)
state.operation.status.Summary = sanitizeStatus(state.operation.status.Summary)
if state.operation.currentError != nil {
current := *state.operation.currentError
current.status.Summary = sanitizeStatus(current.status.Summary)
current.detail = sanitizeStatus(current.detail)
state.currentError = &current
state.operation.currentError = &current
}
if state.confirmation != nil {
confirmation := sanitizePresentationConfirmation(*state.confirmation)
state.confirmation = &confirmation
if state.operation.confirmation != nil {
confirmation := sanitizePresentationConfirmation(*state.operation.confirmation)
state.operation.confirmation = &confirmation
}
if state.confirmationDialog != nil {
dialog := *state.confirmationDialog
if state.operation.confirmationDialog != nil {
dialog := *state.operation.confirmationDialog
dialog.request = sanitizePresentationConfirmation(dialog.request)
state.confirmationDialog = &dialog
state.operation.confirmationDialog = &dialog
}
if state.profileDelete != nil {
confirmation := *state.profileDelete
if state.profile.deletion != nil {
confirmation := *state.profile.deletion
confirmation.name = sanitizeUntrustedText(confirmation.name)
confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request)
state.profileDelete = &confirmation
state.profile.deletion = &confirmation
}
state.picker.selectedName = sanitizeUntrustedText(state.picker.selectedName)
searchPosition := state.picker.search.Position()
state.picker.search.SetValue(sanitizeUntrustedText(state.picker.search.Value()))
state.picker.search.SetCursor(searchPosition)
state.form = sanitizePresentationForm(state.form)
state.dashboard.picker.selectedName = sanitizeUntrustedText(state.dashboard.picker.selectedName)
searchPosition := state.dashboard.picker.search.Position()
state.dashboard.picker.search.SetValue(sanitizeUntrustedText(state.dashboard.picker.search.Value()))
state.dashboard.picker.search.SetCursor(searchPosition)
state.form.state = sanitizePresentationForm(state.form.state)
return state
}
-10
View File
@@ -5,16 +5,6 @@ import (
"proxyctl/internal/profile"
)
func availableTargets(snapshot app.Snapshot) []app.TargetSnapshot {
result := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
for _, item := range snapshot.Targets {
if item.Available {
result = append(result, item)
}
}
return result
}
func profileAt(snapshot app.Snapshot, index int) (profile.Profile, bool) {
if index < 0 || index >= len(snapshot.Profiles) {
return profile.Profile{}, false