package tui import ( "context" "errors" "strings" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" ) 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 selectedTargetID string dashboardFocus dashboardPane showUnavailable bool detailsOffset int driftResolutionTargetID string form formState confirm bool 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 } 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, } 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 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.operation.unexpectedClose() return m, m.operation.deferredQuit() } m.snapshot = progress.update.Snapshot m.retainTargetSelection() 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 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.operation.confirmation != nil { switch key.String() { case "y": confirmed, ok := m.operation.confirmedIntent() if !ok { return m, nil } m.status = "" return m, m.startOperation(confirmed) case "enter", "n", "esc": m.operation.cancelConfirmation() } return m, nil } if m.driftResolutionTargetID != "" { switch key.String() { case "enter", "esc", "n": 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 opening Profile management"} } else { m.page = profilesPage m.selected = 0 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.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.confirm { switch key.String() { case "y": if selected, ok := m.selectedProfile(); ok { m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true}) } m.confirm = false case "n", "esc", "q": m.confirm = false } return m, nil } if m.busy() { if key.String() == "q" || key.String() == "esc" { m.page = mainPage m.selected = 0 m.status = "" } return m, nil } switch key.String() { case "q", "esc": m.page = mainPage m.selected = 0 m.status = "" case "up", "k": if m.selected > 0 { m.selected-- } case "down", "j": if m.selected+1 < len(m.snapshot.Profiles) { m.selected++ } case "n": m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}} m.page = formPage m.status = "" case "e": if selected, ok := m.selectedProfile(); ok { m.form = formState{originalName: selected.Name, profile: selected} m.page = formPage m.status = "" } case "a", "enter": if selected, ok := m.selectedProfile(); ok { return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name}) } case "d": if _, ok := m.selectedProfile(); ok { m.confirm = true } } return m, nil } 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.application.Dispatch(intent) m.snapshot = result.Snapshot if result.Err != nil { m.status = "Error: " + result.Err.Error() m.form.probeGen++ // discard any in-flight probe result while staying on the form m.form.probing = false return m, nil } m.status = result.Notice m.page = profilesPage m.selectName(intent.Profile.Name) 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 m.selected = 0 return m, m.startOperation(intent) } func (m *Model) apply(intent app.Intent) { result := m.application.Dispatch(intent) m.snapshot = result.Snapshot if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 { m.selected-- } if result.Err != nil { m.status = "Error: " + result.Err.Error() } else { m.status = result.Notice } } // 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) { return profileAt(m.snapshot, m.selected) } func (m *Model) selectName(name string) { for i := range m.snapshot.Profiles { if m.snapshot.Profiles[i].Name == name { m.selected = i return } } } 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, selectedTargetID: m.selectedTargetID, dashboardFocus: m.dashboardFocus, showUnavailable: m.showUnavailable, detailsOffset: m.detailsOffset, driftResolutionTargetID: m.driftResolutionTargetID, form: m.form, confirm: m.confirm, confirmation: m.operation.confirmation, 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} } }