package tui import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "proxyctl/internal/app" "proxyctl/internal/profile" ) // helpDialogState is an overlay, not a page. Keeping only its viewport state // here means opening Help cannot replace the page, selection, focus, or the // viewport beneath it. type helpDialogState struct { open bool offset int confirmationAbove bool } func (h *helpDialogState) show() { h.open = true h.offset = 0 // An explicitly opened Help dialog is newer than any visible confirmation. h.confirmationAbove = false } func (h *helpDialogState) close() { *h = helpDialogState{} } func (h helpDialogState) top(confirmation *app.Confirmation) bool { return h.open && (confirmation == nil || !h.confirmationAbove) } func (h *helpDialogState) update(key string) { switch key { case "up", "k": h.offset = max(0, h.offset-1) case "down", "j": h.offset++ case "pgup": h.offset = max(0, h.offset-5) case "pgdown": h.offset += 5 case "home": h.offset = 0 case "end": h.offset = 1 << 20 // the viewport clamps this to the final content line case "q", "esc": h.close() } } func (m Model) dashboardQuitAvailable() bool { return m.page == mainPage && !m.help.open && !m.picker.open && m.driftResolutionTargetID == "" && m.operation.confirmation == nil } func (p terminalPresentation) helpDialogView(model presentationState) terminalPage { content := lipgloss.NewStyle().Width(max(1, model.terminal.width)).Render(protectRenderedText(p.contextualHelp(model))) exitHint := "Ctrl+C: exit" if model.operation.active { exitHint = "Ctrl+C: defer safe exit" } if model.operation.exitDeferred { exitHint = "Exit pending until safe completion" } return terminalPage{ primary: content, footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint, focusLine: -1, primaryOffset: model.help.dialog.offset, } } func (p terminalPresentation) contextualHelp(model presentationState) string { dashboard, operation := model.dashboard, model.operation profiles, form := model.profile, model.form.state var body strings.Builder body.WriteString(p.theme.section.Render("Contextual Help")) body.WriteString("\nThis describes the page or dialog beneath Help. While Help is open, only its scrolling and closing keys plus global Ctrl+C are accepted; the underlying state remains unchanged.\n\n") switch { case operation.confirmation != nil: body.WriteString(p.theme.section.Render("Safety confirmation")) fmt.Fprintf(&body, "\n%s\n", operation.confirmation.Title) if operation.confirmation.Summary != "" { fmt.Fprintf(&body, "%s\n", operation.confirmation.Summary) } body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ") if operation.confirmationDialog != nil { body.WriteByte('\n') body.WriteString(confirmationFocusDescription(*operation.confirmationDialog)) } body.WriteByte('\n') case profiles.deletion != nil: body.WriteString(p.theme.section.Render("Delete confirmation")) fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", profiles.deletion.name) body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ") body.WriteByte('\n') body.WriteString(confirmationFocusDescription(profiles.deletion.dialog)) body.WriteByte('\n') case dashboard.picker.open: body.WriteString(p.theme.section.Render("Active Profile picker")) body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down or Ctrl+P/Ctrl+N selects, and PgUp/PgDn moves by a page. Enter activates the selection. Escape cancels.\n") case dashboard.driftResolutionTargetID != "": body.WriteString(p.theme.section.Render("Drift Resolution dialog")) body.WriteString("\nr reapplies the Active Profile. o restores original configuration when ownership evidence permits. Enter, q, or Escape cancels.\n") if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok { for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} { action, found := driftResolutionAction(selected, kind) if found && action.Available { fmt.Fprintf(&body, "%s: available.\n", targetActionLabel(kind)) } else { reason := action.DisabledReason if reason == "" { reason = "unavailable" } fmt.Fprintf(&body, "%s: disabled — %s.\n", targetActionLabel(kind), reason) } } } case model.page == profilesPage: body.WriteString(p.theme.section.Render("Profile management")) if selected, ok := profileAt(model.snapshot, profiles.selected); ok { fmt.Fprintf(&body, "\nSelected Profile: %s.\n", selected.Name) body.WriteString("Up/Down or j/k selects a Profile. PgUp/PgDn scrolls its details. n creates, e or Enter edits, and d opens the delete confirmation. q or Escape returns to the dashboard.\n") if operation.active || operation.exitDeferred { body.WriteString("Profile modifications are disabled until the current mutation completes.\n") } else { body.WriteString("Create, Edit selected Profile, and Delete selected Profile are available.\n") } } else { body.WriteString("\nNo Proxy Profiles are saved. n creates one. q or Escape returns to the dashboard.\n") } case model.page == formPage: body.WriteString(p.theme.section.Render("Profile form")) if form.field >= 0 && form.field < len(form.inputs) { fmt.Fprintf(&body, "\nFocus: %s field.\n", form.inputs[form.field].label) } body.WriteString("Type or paste to insert at the cursor. Left/Right moves the cursor; Home/End moves to the field boundary; Backspace/Delete removes text. Long inputs scroll horizontally. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode while preserving Profile values. Ctrl+S validates and saves. q or Escape cancels and returns to Profile management.\n") if form.currentField() == profile.BypassField { body.WriteString("PgUp/PgDn scrolls the normalized Bypass preview while the Bypass field is focused.\n") } if form.profile.Mode == profile.SingleMode { switch { case form.probe.active: body.WriteString("Protocol Probe is in progress for the Single proxy URL field. Its progress remains attached to that field; Ctrl+D does not start a duplicate probe while that field is focused.\n") case form.currentField() == profile.SingleURLField: body.WriteString("Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field. It never runs on form entry, focus changes, or save.\n") default: body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n") } } else if form.hasProbeSourceAttachment() { body.WriteString("The Protocol Probe result remains attached to its read-only initiating Single proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from that probe.\n") } default: body.WriteString(p.theme.section.Render("Dashboard")) focus := "Targets focus" if dashboard.focus == targetDetailsPane { focus = "Target details focus" } fmt.Fprintf(&body, "\nContext: %s.\n", focus) catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID) if selected, ok := catalog.selected(dashboard.selectedTargetID); ok { fmt.Fprintf(&body, "Selected Target: %s. State: %s.\n", selected.Description.Name, targetStateLabel(selected.State)) availability := targetMutationAvailability(selected, true, operation.active || operation.exitDeferred) label := targetActionLabel(selected.PrimaryAction.Kind) if availability.available { fmt.Fprintf(&body, "Primary action: %s — available with Space.\n", label) } else { fmt.Fprintf(&body, "Primary action: %s — disabled: %s.\n", label, availability.reason) } } if dashboard.focus == targetDetailsPane { body.WriteString("Up/Down, j/k, and PgUp/PgDn scroll Target details. Tab focuses Targets.\n") } else { body.WriteString("Up/Down or j/k selects a Target. Tab focuses Target details.\n") } if operation.active || operation.exitDeferred { body.WriteString("u still shows or hides unavailable Targets. p, m, r, and Space modifications are disabled until the current mutation completes.\n") } else { body.WriteString("u shows or hides unavailable Targets. p opens the Active Profile picker. m opens Profile management. r refreshes Targets.\n") } } body.WriteString("\n") body.WriteString(p.theme.section.Render("Operation state")) switch { case operation.exitDeferred: body.WriteString("\nExit is pending. Read-only browsing and Help remain available; all new work is disabled until the mutation completes safely.\n") case operation.active: body.WriteString("\nMutation in progress. Target selection, details scrolling, and Help remain available.\nConflicting mutations are disabled until completion. Ctrl+C requests a deferred safe exit.\n") default: body.WriteString("\nNo mutation is in progress. Ctrl+C exits immediately.\n") } if operation.currentError != nil && model.page == mainPage && operation.confirmation == nil && !dashboard.picker.open && dashboard.driftResolutionTargetID == "" { body.WriteString("e expands or collapses the current error; x acknowledges it.\n") } if model.page == mainPage && profiles.deletion == nil { body.WriteString("\n") body.WriteString(p.theme.section.Render("Built-in Target reference")) body.WriteString("\n") for _, item := range model.snapshot.Targets { fmt.Fprintf(&body, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details) if item.Available { fmt.Fprintf(&body, "Detected: %s\n\n", item.AvailabilityEvidence) } else { fmt.Fprintf(&body, "Not detected: %s\n\n", item.AvailabilityEvidence) } } } body.WriteString("\n") body.WriteString(p.theme.section.Render("Dialog and page navigation")) body.WriteString("\nEscape closes the top dialog. With no dialog open, Escape returns one page level. q closes Help and confirmations, returns from child pages, and exits only from the unobscured dashboard. Picker search accepts q as text.\n") body.WriteString("Inputs are delivered only to the top dialog; they cannot activate content behind it. A newly requested safety confirmation appears above older Help.\n") body.WriteString("\n") body.WriteString(p.theme.section.Render("Global exit safety")) body.WriteString("\nCtrl+C requests exit from every page and dialog. During a system mutation, proxyctl waits for safe completion and rejects new work before exiting.\n") return body.String() } func confirmationFocusDescription(dialog confirmationDialog) string { label := dialog.request.CancelLabel if label == "" { label = "Cancel" } if dialog.confirmFocused { label = dialog.request.ConfirmLabel if label == "" { label = "Confirm" } } return label + " is focused." }