package tui import ( "context" "fmt" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" ) type page int const ( mainPage page = iota profilesPage formPage ) type Model struct { application app.InteractiveModule presentation terminalPresentation snapshot app.Snapshot page page width int height int selected int selectedProfileName string profileViewportOffset int profileDetailsOffset int profileDelete *profileDeleteConfirmation selectedTargetID string dashboardFocus dashboardPane showUnavailable bool detailsOffset int driftResolutionTargetID string picker profilePickerState form formState formSequence uint64 help helpDialogState status string operation operationCoordinator detect func(context.Context, string) (probe.Capabilities, error) } // probeDone carries only detector output and formState's opaque typed token. // Source data and all validation/application rules remain owned by formState. type probeDone struct { caps probe.Capabilities err error token probeToken } type profileDeleteConfirmation struct { name string dialog confirmationDialog } func NewModel(application app.InteractiveModule, configure ...ModelOption) Model { options := defaultModelOptions() for _, option := range configure { option(&options) } snapshot := application.Snapshot() model := Model{ application: application, presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)}, snapshot: snapshot, selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(), width: 80, height: 24, operation: newOperationCoordinator(), picker: newProfilePickerState(), detect: probe.Detect, } model.retainProfileSelection("", 0) if targetsChecking(snapshot) { model.startOperation(app.Intent{Kind: app.RefreshTargets}) } return model } func targetsChecking(snapshot app.Snapshot) bool { for _, item := range snapshot.Targets { if item.DetectionPhase == app.TargetChecking { return true } } return false } func (m Model) Init() tea.Cmd { if m.operation.updates != nil { return waitForOperation(m.operation.updates) } 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 // operation rather than racing another operation for shared model state. 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 m.height = size.Height m.ensureProfileSelectionVisible() return m, nil } // A deferred tea.Quit is queued only after the in-flight completion has // been consumed. Until Bubble Tea processes that QuitMsg, keep the model // terminal so intervening input cannot cancel a confirmation or start a // second mutation. if m.operation.quitRequested && !m.operationInFlight() { return m, nil } if key, ok := message.(tea.KeyMsg); ok { // Ctrl+C is global. q exits only the dashboard when no dialog obscures it; // otherwise the top dialog or child page owns q. if key.String() == "ctrl+c" || (key.String() == "q" && m.dashboardQuitAvailable()) { return m, m.operation.requestQuit() } // Help is available from every page and dialog. Opening it explicitly // places it above the current dialog without changing state underneath. if key.String() == "?" { if !m.help.top(m.operation.confirmation) { m.help.show() } return m, nil } } if progress, ok := message.(operationProgress); ok { if !m.operation.active || progress.updates != m.operation.updates { return m, nil } if progress.closed { m.snapshot = m.application.Snapshot() m.retainTargetSelection() m.retainProfileSelection(m.selectedProfileName, m.selected) m.operation.unexpectedClose() return m, m.operation.deferredQuit() } m.snapshot = progress.update.Snapshot m.retainTargetSelection() m.retainProfileSelection(m.selectedProfileName, m.selected) if firstFailed := m.operation.apply(progress.update); firstFailed != "" { m.selectedTargetID = firstFailed m.detailsOffset = 0 } if m.operation.confirmation != nil { m.page = mainPage if m.help.open { // A safety confirmation requested after Help was opened is the new // top dialog. Cancelling it reveals the unchanged Help beneath. m.help.confirmationAbove = true } } if progress.update.IsTerminal() { return m, m.operation.deferredQuit() } return m, waitForOperation(progress.updates) } if done, ok := message.(probeDone); ok { return m, m.form.applyProbeResult(done.token, done.caps, done.err) } key, ok := message.(tea.KeyMsg) if !ok { return m, nil } if m.help.top(m.operation.confirmation) { m.help.update(key.String()) return m, nil } switch m.page { case mainPage: return m.updateMain(key) case profilesPage: return m.updateProfiles(key) case formPage: return m.updateForm(key) default: return m, nil } } func (m *Model) startOperation(intent app.Intent) tea.Cmd { snapshot, command, started := m.operation.start(m.application, intent) if started { m.snapshot = snapshot m.retainTargetSelection() } return command } type mutationAvailability struct { available bool reason string compactReason string } // targetMutationAvailability consumes the Application's structured action // policy. It adds only Model-owned serialization state and never derives an // action from Target state or display text. func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, operationBlocked bool) mutationAvailability { switch { case !selectedOK: return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"} case !selected.PrimaryAction.Available: reason := selected.PrimaryAction.DisabledReason if reason == "" { reason = "This Target action is unavailable" } return mutationAvailability{reason: reason, compactReason: "unavailable"} case operationBlocked: return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"} default: return mutationAvailability{available: true} } } func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) { for _, action := range selected.DriftResolutionActions { if action.Kind == kind { return action, true } } return app.TargetAction{Kind: kind}, false } func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.picker.open { switch key.String() { case "esc": m.picker.close() return m, nil case "enter": selected, ok := m.picker.selected(m.snapshot) if !ok { return m, nil } m.picker.close() return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name}) default: return m, m.picker.update(key, m.snapshot) } } if m.operation.confirmation != nil { switch m.operation.confirmationDialog.update(key.String()) { case confirmationAccepted: confirmed, ok := m.operation.confirmedIntent() if !ok { return m, nil } m.status = "" return m, m.startOperation(confirmed) case confirmationCancelled: m.operation.cancelConfirmation() } return m, nil } if m.driftResolutionTargetID != "" { switch key.String() { case "enter", "esc", "n", "q": m.driftResolutionTargetID = "" return m, nil case "r", "o": kind := app.ReapplyActiveProfileAction if key.String() == "o" { kind = app.RestoreOriginalAction } selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID) if !ok { m.driftResolutionTargetID = "" m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"} return m, nil } action, ok := driftResolutionAction(selected, kind) if !ok || !action.Available { reason := action.DisabledReason if reason == "" { reason = "This Drift Resolution action is unavailable" } m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason} return m, nil } intent, _ := kind.Intent(selected.ID) m.driftResolutionTargetID = "" return m, m.startOperation(intent) } return m, nil } if key.String() == "e" && m.operation.currentError != nil { m.operation.errorExpanded = !m.operation.errorExpanded m.operation.errorOffset = 0 return m, nil } if key.String() == "x" && m.operation.currentError != nil { m.operation.acknowledgeError() return m, nil } if m.operation.errorExpanded { switch key.String() { case "pgup": m.operation.errorOffset = max(0, m.operation.errorOffset-5) return m, nil case "pgdown": m.operation.errorOffset += 5 return m, nil } } catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID) switch key.String() { case "p": if m.operation.active || m.operation.quitRequested { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"} } else { return m, m.picker.show(m.snapshot) } case "m": if m.operation.active || m.operation.quitRequested { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Profile management disabled: wait for the current mutation to finish"} } else { m.page = profilesPage m.retainProfileSelection(m.selectedProfileName, m.selected) m.status = "" if m.operation.status.Kind != app.StatusError { m.operation.status = app.OperationStatus{} } } case "r": if m.operation.active || m.operation.quitRequested { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"} } else { 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 } } 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) 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 } } case "pgup": if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { m.detailsOffset = max(0, m.detailsOffset-5) } case "pgdown": if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { m.detailsOffset += 5 } case " ": selected, ok := catalog.selected(m.selectedTargetID) availability := targetMutationAvailability(selected, ok, m.operation.active || m.operation.quitRequested) if !availability.available { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason} break } if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction { m.driftResolutionTargetID = selected.ID break } intent, ok := selected.PrimaryAction.Kind.Intent(selected.ID) if !ok { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"} break } return m, m.startOperation(intent) } return m, nil } func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.profileDelete != nil { switch m.profileDelete.dialog.update(key.String()) { case confirmationAccepted: name := m.profileDelete.name m.profileDelete = nil m.applyProfileMutation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true}, "") case confirmationCancelled: m.profileDelete = nil } return m, nil } switch key.String() { case "q", "esc": m.page = mainPage m.status = "" case "up", "k": if m.selected > 0 { m.selected-- m.selectedProfileName = m.snapshot.Profiles[m.selected].Name m.profileDetailsOffset = 0 m.ensureProfileSelectionVisible() } case "down", "j": if m.selected+1 < len(m.snapshot.Profiles) { m.selected++ m.selectedProfileName = m.snapshot.Profiles[m.selected].Name m.profileDetailsOffset = 0 m.ensureProfileSelectionVisible() } case "pgup": m.profileDetailsOffset = max(0, m.profileDetailsOffset-5) case "pgdown": m.profileDetailsOffset += 5 case "n": if !m.profileMutationAvailable("creating a Proxy Profile") { break } m.formSequence++ m.form = newFormState(m.formSequence, "", profile.Profile{Mode: profile.SingleMode}) m.page = formPage m.status = "" case "e", "enter": if !m.profileMutationAvailable("editing a Proxy Profile") { break } if selected, ok := m.selectedProfile(); ok { m.formSequence++ m.form = newFormState(m.formSequence, selected.Name, selected) m.page = formPage m.status = "" } case "d": if !m.profileMutationAvailable("deleting a Proxy Profile") { break } if selected, ok := m.selectedProfile(); ok { m.profileDelete = newProfileDeleteConfirmation(selected, len(m.snapshot.Profiles) == 1, selected.Name == m.snapshot.ActiveProfile) } } return m, nil } func (m *Model) profileMutationAvailable(action string) bool { if !m.busy() { return true } m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Cannot continue " + action + "; wait for the current mutation to finish"} return false } func newProfileDeleteConfirmation(selected profile.Profile, final, active bool) *profileDeleteConfirmation { details := []string{"The Proxy Profile will be removed permanently."} if active { details = append(details, "This is the Active Profile; deleting it leaves no Active Profile selected.") } if final { details = append(details, "This is the final Proxy Profile.", "Existing managed configuration remains in place.", "Affected Targets become Drifted.") } request := app.Confirmation{ Title: fmt.Sprintf("Delete Proxy Profile ā€œ%sā€?", selected.Name), Summary: "Deletion cannot be undone.", Details: details, ConfirmLabel: "Delete", CancelLabel: "Cancel", HighImpact: true, } return &profileDeleteConfirmation{name: selected.Name, dialog: newConfirmationDialog(request)} } func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.form.currentField() == profile.BypassField { switch key.String() { case "pgup": m.form.scrollBypassPreview(-5) return m, nil case "pgdown": m.form.scrollBypassPreview(5) return m, nil } } if key.String() == "q" || key.Type == tea.KeyEsc { m.page = profilesPage m.status = "" m.form = formState{} return m, nil } switch key.Type { case tea.KeyCtrlS: m.form.invalidateProbe() m.form.syncFocusedValue() m.form.refreshValidation() if err := m.form.validation.Err(); err != nil || m.form.nameConflict(m.snapshot.Profiles) != "" { m.status = "Fix the highlighted Profile fields before saving" return m, nil } kind := app.CreateProfile if m.form.originalName != "" { kind = app.UpdateProfile } normalized := m.form.validation.Normalized intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized} result := m.applyProfileMutation(intent, normalized.Name) if result.Err != nil { return m, nil } m.page = profilesPage m.form = formState{} return m, nil case tea.KeyCtrlD: address, token, started := m.form.startProbe() if !started { return m, nil } detect := m.detect return m, func() tea.Msg { caps, err := detect(context.Background(), address) return probeDone{caps: caps, err: err, token: token} } case tea.KeyCtrlT: return m, m.form.toggleMode() case tea.KeyTab, tea.KeyDown: return m, m.form.move(1) case tea.KeyShiftTab, tea.KeyUp: return m, m.form.move(-1) default: return m, m.form.updateInput(key) } } func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) { if m.busy() { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"} return m, nil } m.page = mainPage return m, m.startOperation(intent) } func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) app.Result { formerName, formerIndex := m.selectedProfileName, m.selected result := m.application.Dispatch(intent) m.snapshot = result.Snapshot if preferredName == "" { preferredName = formerName } m.retainProfileSelection(preferredName, formerIndex) if result.Err != nil { m.status = "Error: " + result.Err.Error() } else { m.status = result.Notice } return result } func (m Model) selectedProfile() (profile.Profile, bool) { if m.selectedProfileName != "" { for i := range m.snapshot.Profiles { if m.snapshot.Profiles[i].Name == m.selectedProfileName { return m.snapshot.Profiles[i], true } } } return profileAt(m.snapshot, m.selected) } func (m *Model) retainProfileSelection(name string, fallbackIndex int) { if len(m.snapshot.Profiles) == 0 { m.selected = 0 m.selectedProfileName = "" m.profileViewportOffset = 0 m.profileDetailsOffset = 0 return } for i := range m.snapshot.Profiles { if name != "" && m.snapshot.Profiles[i].Name == name { m.selected = i m.selectedProfileName = name m.ensureProfileSelectionVisible() return } } m.selected = min(max(0, fallbackIndex), len(m.snapshot.Profiles)-1) m.selectedProfileName = m.snapshot.Profiles[m.selected].Name m.profileDetailsOffset = 0 m.ensureProfileSelectionVisible() } func (m *Model) ensureProfileSelectionVisible() { if len(m.snapshot.Profiles) == 0 { m.profileViewportOffset = 0 return } // The details pane and footer consume variable height. A conservative // capacity keeps the selected identity visible without coupling Model state // to Lip Gloss measurements, while retaining the viewport top across resize. capacity := max(1, m.height/2) if m.selected < m.profileViewportOffset { m.profileViewportOffset = m.selected } if m.selected >= m.profileViewportOffset+capacity { m.profileViewportOffset = m.selected - capacity + 1 } maximum := max(0, len(m.snapshot.Profiles)-capacity) m.profileViewportOffset = min(maximum, max(0, m.profileViewportOffset)) } 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, }) } 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)) }