package tui import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "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 } type Model struct { application app.Module snapshot app.Snapshot page page selected int form formState confirm bool targetConfirm *app.Intent profileConfirm *app.Intent pendingActivation app.Intent status string applying bool refreshing bool profileTransition bool progressTarget string } type targetOperationDone struct { result app.Result } type refreshDone struct { result app.Result } type profileOperationProgress struct { result app.Result updates <-chan app.Result } func waitForProfileProgress(updates <-chan app.Result) tea.Cmd { return func() tea.Msg { result, ok := <-updates if !ok { return profileOperationProgress{result: app.Result{Complete: true}, updates: updates} } return profileOperationProgress{result: result, updates: updates} } } func NewModel(application app.Module) Model { return Model{application: application, snapshot: application.Snapshot()} } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if completed, ok := message.(refreshDone); ok { m.refreshing = false m.snapshot = completed.result.Snapshot if completed.result.Err != nil { m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m" } else { m.status = completed.result.Notice } if m.selected >= len(m.availableTargets()) && m.selected > 0 { m.selected-- } return m, nil } if progress, ok := message.(profileOperationProgress); ok { m.snapshot = progress.result.Snapshot m.progressTarget = "" if len(progress.result.Progress) > 0 { item := progress.result.Progress[len(progress.result.Progress)-1] if item.Applying { m.progressTarget = item.TargetID } } if progress.result.Confirmation != "" { m.applying = false m.profileTransition = false m.page = profilesPage intent := m.pendingActivation m.profileConfirm = &intent m.status = progress.result.Confirmation return m, nil } if progress.result.Err != nil { m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m" } else if progress.result.Notice != "" { m.status = progress.result.Notice } if progress.result.Complete { m.applying = false m.profileTransition = false m.progressTarget = "" m.pendingActivation = app.Intent{} return m, nil } return m, waitForProfileProgress(progress.updates) } if completed, ok := message.(targetOperationDone); ok { m.applying = false m.snapshot = completed.result.Snapshot if completed.result.Confirmation != "" { intent := app.Intent{Kind: app.SetTargetDesired} if m.targetConfirm != nil { intent = *m.targetConfirm } m.targetConfirm = &intent m.status = completed.result.Confirmation } else if completed.result.Err != nil { m.targetConfirm = nil m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m" } else { m.targetConfirm = nil m.status = completed.result.Notice } 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) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.targetConfirm != nil && !m.applying { switch key.String() { case "y": intent := *m.targetConfirm intent.Confirm = true m.targetConfirm = nil m.applying = true m.status = "" return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} } case "n", "esc", "q": m.targetConfirm = nil m.status = "" } return m, nil } targets := m.availableTargets() switch key.String() { case "p": m.page = profilesPage m.selected = 0 m.status = "" case "?": m.page = helpPage m.status = "" case "r": if !m.applying && !m.refreshing { m.refreshing = true m.status = "Refreshing Target availability and state…" return m, func() tea.Msg { return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})} } } case "up", "k": if !m.applying && m.selected > 0 { m.selected-- } case "down", "j": if !m.applying && m.selected+1 < len(targets) { m.selected++ } case " ": if !m.applying && m.selected >= 0 && m.selected < len(targets) { selected := targets[m.selected] desired := selected.State == target.Disabled || selected.State == target.External m.applying = true m.status = "" intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired} m.targetConfirm = &intent return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} } } case "q", "esc", "ctrl+c": return m, tea.Quit } return m, nil } func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.profileConfirm != nil { switch key.String() { case "y": intent := *m.profileConfirm intent.Confirm = true m.profileConfirm = nil return m.beginProfileActivation(intent) case "n", "esc", "q": m.profileConfirm = nil m.status = "" } return m, nil } 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 } 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 = "" 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() return m, nil } m.status = result.Notice m.page = profilesPage m.selectName(intent.Profile.Name) return m, nil 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 progressive, ok := m.application.(app.ProgressiveModule); ok { m.applying = true m.profileTransition = true m.pendingActivation = intent m.page = mainPage m.selected = 0 m.status = "Changing Active Profile…" updates := progressive.DispatchProgress(intent) return m, waitForProfileProgress(updates) } m.apply(intent) return m, nil } func (m *Model) apply(intent app.Intent) { result := m.application.Dispatch(intent) m.snapshot = result.Snapshot if result.Confirmation != "" { m.profileConfirm = &intent m.status = result.Confirmation return } 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 } } func (m Model) selectedProfile() (profile.Profile, bool) { if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) { return profile.Profile{}, false } return m.snapshot.Profiles[m.selected], true } 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 { switch m.page { case profilesPage: return m.profilesView() case formPage: return m.formView() case helpPage: return m.helpView() default: return m.mainView() } } func (m Model) availableTargets() []app.TargetSnapshot { var result []app.TargetSnapshot for _, item := range m.snapshot.Targets { if item.Available { result = append(result, item) } } return result } func (m Model) mainView() string { var b strings.Builder b.WriteString("proxyctl — Target panel\n\n") if m.snapshot.ActiveProfile == "" { b.WriteString("No Active Profile\n") b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n") } else { fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile) } targets := m.availableTargets() if len(targets) == 0 { b.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.\n") } else { b.WriteString("\nTargets\n") for i, item := range targets { cursor := " " if i == m.selected { cursor = "> " } state := string(item.State) if (m.progressTarget != "" && item.ID == m.progressTarget) || (!m.profileTransition && m.progressTarget == "" && m.applying && i == m.selected) { state = "Applying…" } fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name) } if m.selected >= 0 && m.selected < len(targets) { selected := targets[m.selected] fmt.Fprintf(&b, "\n%s\n%s\n%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence) } } if m.targetConfirm != nil && !m.applying { b.WriteString("\ny: confirm n/Esc: cancel\n") } else { b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles r: refresh ?: help q/Esc: quit\n") } if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } return b.String() } func (m Model) helpView() string { var b strings.Builder b.WriteString("proxyctl Help — built-in Targets\n\n") for _, item := range m.snapshot.Targets { fmt.Fprintf(&b, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details) if item.Available { fmt.Fprintf(&b, "Detected: %s\n\n", item.AvailabilityEvidence) } else { fmt.Fprintf(&b, "Not detected: %s\n\n", item.AvailabilityEvidence) } } b.WriteString("Keys: ↑/k and ↓/j navigate; Space toggles; p manages Proxy Profiles; r refreshes; ? opens help; q/Escape returns or exits.\n") return b.String() } func (m Model) profilesView() string { var b strings.Builder b.WriteString("Proxy Profiles\n\n") if len(m.snapshot.Profiles) == 0 { b.WriteString("No Proxy Profiles. Press n to create one.\n") } for i, item := range m.snapshot.Profiles { cursor, active := " ", " " if i == m.selected { cursor = "> " } if item.Name == m.snapshot.ActiveProfile { active = "*" } fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name) } if selected, ok := m.selectedProfile(); ok { b.WriteString("\nSelected Profile\n") fmt.Fprintf(&b, "Mode: %s\n", selected.Mode) if selected.Mode == profile.SingleMode { fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL)) } else { fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n", profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL)) } fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", ")) } if m.profileConfirm != nil { b.WriteString("\ny: confirm n/Esc: cancel\n") } else if m.confirm { if len(m.snapshot.Profiles) == 1 { b.WriteString("\nDelete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted. y: confirm n/Esc: cancel\n") } else { b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n") } } else { b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n") } if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } return b.String() } func (m Model) formView() string { var b strings.Builder action := "Create" if m.form.originalName != "" { action = "Edit" } fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode) labels := m.form.labels() for i, label := range labels { cursor := " " if i == m.form.field { cursor = "> " } value := m.form.valueAt(i) if label != "Name" && label != "Bypass" { value = profile.ObscureURL(value) } fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value) } b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Esc: cancel\n") if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } return b.String() } 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} } }