package tui import ( "context" "errors" "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 selectedTargetID string dashboardFocus dashboardPane showUnavailable bool detailsOffset int 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 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 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 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.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.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, 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} } }