package tui import ( "context" "errors" "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" "proxyctl/internal/target" ) type page int const ( mainPage page = iota profilesPage formPage helpPage ) type formState struct { originalName string profile profile.Profile field int probing bool probeGen uint64 } 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 form formState status string operation operationCoordinator detect func(context.Context, string) (probe.Capabilities, error) } // probeDone carries one finished Protocol Probe back to the form. address is // always bare host:port so status text can never leak credentials. gen matches // the probe generation stored in formState so late results can be ignored after // the user leaves the form. type probeDone struct { address string caps probe.Capabilities err error gen uint64 } 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(), 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 { // Dashboard q always means exit, even when confirmation is visible. // Escape remains the deterministic cancel/back key. exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q") if exitRequest { return m, m.operation.requestQuit() } } 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 progress.update.IsTerminal() { return m, m.operation.deferredQuit() } return m, waitForOperation(progress.updates) } if done, ok := message.(probeDone); ok { if !m.form.probing || done.gen != m.form.probeGen { return m, nil } m.form.probing = false switch { case done.err != nil: m.status = "Protocol Probe: no proxy detected at " + done.address + ": " + done.err.Error() case !done.caps.HTTP && !done.caps.SOCKS5: m.status = "Protocol Probe: unrecognized protocol at " + done.address default: raw := m.form.profile.SingleURL if done.caps.HTTP && done.caps.SOCKS5 { httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true}) if err != nil { m.status = "Protocol Probe: " + err.Error() return m, nil } socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true}) if err != nil { m.status = "Protocol Probe: " + err.Error() return m, nil } m.form.profile.Mode = profile.AdvancedMode m.form.profile.HTTPURL = httpURL m.form.profile.HTTPSURL = httpURL m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1) m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address return m, nil } rewritten, err := probe.URL(raw, done.caps) if err != nil { m.status = "Protocol Probe: " + err.Error() return m, nil } m.form.profile.SingleURL = rewritten kind := "SOCKS5" if done.caps.HTTP { kind = "HTTP" } m.status = "Protocol Probe: detected " + kind + " proxy at " + done.address } return m, nil } key, ok := message.(tea.KeyMsg) if !ok { return m, nil } switch m.page { case mainPage: return m.updateMain(key) case profilesPage: return m.updateProfiles(key) case formPage: return m.updateForm(key) case helpPage: if key.String() == "q" || key.String() == "esc" { m.page = mainPage } return m, nil 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 is the sole policy used by both input handling // and rendering, so the displayed disabled reason cannot drift from behavior. func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, activeProfile string, operationBlocked bool) mutationAvailability { switch { case operationBlocked: return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"} case !selectedOK: return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"} case selected.DetectionPhase == app.TargetChecking: return mutationAvailability{reason: "Target mutation is disabled while this Target is still being checked", compactReason: "checking Target"} case selected.DetectionPhase == app.TargetDetectionFailed: return mutationAvailability{reason: "Target mutation is disabled because this Target check failed; refresh Targets to retry", compactReason: "Target check failed"} case !selected.Available: return mutationAvailability{reason: "Target mutation is disabled because this Target is unavailable", compactReason: "Target unavailable"} case (selected.State == target.Disabled || selected.State == target.External) && activeProfile == "": return mutationAvailability{reason: "Target mutation is disabled because there is no Active Profile; open Profile management first", compactReason: "no Active Profile"} default: return mutationAvailability{available: true} } } func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { 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 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: "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 "?": m.page = helpPage 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.snapshot.ActiveProfile, m.operation.active || m.operation.quitRequested) if !availability.available { m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason} break } desired := selected.State == target.Disabled || selected.State == target.External intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired} 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.form = formState{profile: 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.form = formState{originalName: selected.Name, profile: 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) { switch key.String() { case "esc": m.page = profilesPage m.status = "" m.form = formState{} return m, nil case "ctrl+s": kind := app.CreateProfile if m.form.originalName != "" { kind = app.UpdateProfile } intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile} result := m.applyProfileMutation(intent, intent.Profile.Name) if result.Err != nil { m.form.probeGen++ // discard any in-flight probe result while staying on the form m.form.probing = false return m, nil } m.page = profilesPage m.form = formState{} return m, nil case "ctrl+d": if m.form.profile.Mode != profile.SingleMode { m.status = "Protocol Probe: available in Single mode only" return m, nil } if m.form.field != 1 || m.form.probing { return m, nil } address, err := probeAddress(m.form.profile.SingleURL) if err != nil { m.status = "Protocol Probe: " + err.Error() return m, nil } m.form.probing = true m.form.probeGen++ m.status = "Protocol Probe: checking " + address + "…" detect := m.detect gen := m.form.probeGen return m, func() tea.Msg { caps, err := detect(context.Background(), address) return probeDone{address: address, caps: caps, err: err, gen: gen} } case "ctrl+t": if m.form.profile.Mode == profile.SingleMode { m.form.profile.Mode = profile.AdvancedMode m.form.profile.HTTPURL = m.form.profile.SingleURL m.form.profile.HTTPSURL = m.form.profile.SingleURL m.form.profile.AllURL = m.form.profile.SingleURL } else { m.form.profile.Mode = profile.SingleMode } m.form.field = 0 return m, nil case "tab", "down": m.form.field = (m.form.field + 1) % m.form.fieldCount() return m, nil case "shift+tab", "up": m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount() return m, nil case "backspace": value := []rune(m.form.value()) if len(value) > 0 { m.form.setValue(string(value[:len(value)-1])) } return m, nil } if key.Type == tea.KeyRunes { m.form.setValue(m.form.value() + string(key.Runes)) } return m, nil } 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 } // probeAddress extracts a bare host:port from the typed endpoint for dialing // and status text, dropping any userinfo so credentials are never displayed // or sent anywhere. func probeAddress(raw string) (string, error) { u, err := profile.ParseProxyURL(raw) if err != nil { return "", errors.New("Single proxy URL must include host:port") } return u.Host, nil } 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, form: m.form, 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)) } func (f formState) labels() []string { if f.profile.Mode == profile.AdvancedMode { return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"} } return []string{"Name", "Single proxy URL", "Bypass"} } func (f formState) fieldCount() int { return len(f.labels()) } func (f formState) value() string { return f.valueAt(f.field) } func (f formState) valueAt(index int) string { if index == 0 { return f.profile.Name } if f.profile.Mode == profile.AdvancedMode { switch index { case 1: return f.profile.HTTPURL case 2: return f.profile.HTTPSURL case 3: return f.profile.AllURL default: return strings.Join(f.profile.Bypass, ", ") } } if index == 1 { return f.profile.SingleURL } return strings.Join(f.profile.Bypass, ", ") } func (f *formState) setValue(value string) { if f.field == 0 { f.profile.Name = value return } if f.profile.Mode == profile.AdvancedMode { switch f.field { case 1: f.profile.HTTPURL = value case 2: f.profile.HTTPSURL = value case 3: f.profile.AllURL = value default: f.profile.Bypass = []string{value} } return } if f.field == 1 { f.profile.SingleURL = value } else { f.profile.Bypass = []string{value} } }