package tui import ( "errors" "fmt" "io" "os" "regexp" "strings" "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" "proxyctl/internal/app" "proxyctl/internal/profile" ) const ( minimumWidth = 60 minimumHeight = 16 wideWidth = 100 wideHeight = 28 ) type themeMode uint8 const ( themeModeAuto themeMode = iota themeModeDark themeModeLight themeModeNoColor ) type modelOptions struct { theme themeMode output io.Writer } // ModelOption configures terminal capabilities used by the complete TUI Model. type ModelOption func(*modelOptions) // WithOutput makes presentation capability detection use the same writer that // Bubble Tea renders to. Production composition should pass tea.WithOutput's // writer here as well. func WithOutput(output io.Writer) ModelOption { return func(options *modelOptions) { options.output = output } } func withTheme(mode themeMode) ModelOption { return func(options *modelOptions) { options.theme = mode } } func defaultModelOptions() modelOptions { return modelOptions{output: os.Stdout} } type presentationState struct { snapshot app.Snapshot page page terminal terminalDimensions dashboard dashboardPresentationState profile profilePresentationState form formPresentationState help helpPresentationState operation operationPresentationState } type terminalDimensions struct { width int height int } type dashboardPresentationState struct { selectedTargetID string focus dashboardPane showUnavailable bool detailsOffset int driftResolutionTargetID string picker profilePickerState } type profilePresentationState struct { selected int listOffset int detailsOffset int deletion *profileDeleteConfirmation status string } type formPresentationState struct { state formState } type helpPresentationState struct { dialog helpDialogState } type operationPresentationState struct { confirmation *app.Confirmation confirmationDialog *confirmationDialog status app.OperationStatus currentError *operationError errorExpanded bool errorOffset int exitDeferred bool active bool progress map[string]app.TargetProgressPhase spinnerFrame string } // terminalPresentation is the terminal-presentation Module. Application // coordination supplies one immutable state and calls its single Render // operation; responsive layout, theming, clipping, and final redaction stay // inside the module. type terminalPresentation struct { theme semanticTheme } type terminalPage struct { header string primary string context string footer string status string statusExpanded bool statusOffset int focusLine int primaryOffset int contextOffset int contextAnchor int anchorContext bool wrapPrimary bool wrapContext bool dashboard *dashboardRenderState } func (p terminalPresentation) Render(state presentationState) string { // Application snapshots, adapter diagnostics, operation updates, and form // input are all untrusted presentation data. Strip their controls before any // semanticTheme style is applied; otherwise an attacker could inject one of // the same allowlisted SGR sequences that the theme legitimately emits. state = sanitizePresentationState(state) width, height := state.terminal.width, state.terminal.height if width <= 0 { width = 80 } if height <= 0 { height = 24 } if width < minimumWidth || height < minimumHeight { content := profile.RedactText(p.minimumSizeView(width, height)) return strings.TrimRight(viewportContent(content, width, height, 0), " \n") + "\n" } var page terminalPage switch state.page { case profilesPage: page = p.profilesView(state) case formPage: page = p.formView(state) default: page = p.mainView(state, width, height) } if state.help.dialog.top(state.operation.confirmation) { help := p.helpDialogView(state) // Retain the underlying page header so Help is visibly contextual and // modal rather than a replacement navigation destination. page.primary = help.primary page.context = "" page.footer = help.footer page.focusLine = help.focusLine page.primaryOffset = help.primaryOffset page.contextOffset = 0 page.dashboard = nil } page.status = renderStatus(state) page.statusExpanded = state.operation.errorExpanded && state.operation.currentError != nil page.statusOffset = state.operation.errorOffset page = redactTerminalPage(page) // Untrusted values were redacted and stripped of controls before semantic // styling. The final pass therefore removes unexpected terminal controls // without reparsing clipped, composed prose as if it were a URL. return sanitizeStyledText(p.renderShell(page, width, height)) } func renderStatus(state presentationState) string { var lines []string operation := state.operation if operation.exitDeferred { lines = append(lines, "Exit requested — waiting for the current mutation to finish safely") } else if operation.status.Summary != "" { lines = append(lines, operation.status.Summary) } else if state.profile.status != "" { lines = append(lines, state.profile.status) } if operation.currentError != nil { errorText := "Current error: " + operation.currentError.status.Summary if operation.errorExpanded && operation.currentError.detail != "" { errorText += "\n" + operation.currentError.detail } lines = append(lines, errorText) } return strings.Join(lines, "\n") } func redactTerminalPage(page terminalPage) terminalPage { page.header = protectRenderedText(page.header) page.primary = protectRenderedText(page.primary) page.context = protectRenderedText(page.context) page.footer = protectRenderedText(page.footer) page.status = protectRenderedText(page.status) return page } func protectRenderedText(value string) string { return sanitizeStyledText(value) } func sanitizeUntrustedControls(value string) string { return sanitizeTerminalText(value, false) } func sanitizeUntrustedText(value string) string { return sanitizeUntrustedControls(profile.RedactText(value)) } func sanitizeUntrustedUserinfoText(value string) string { return sanitizeUntrustedControls(profile.RedactUserinfoText(value)) } func sanitizePresentationState(state presentationState) presentationState { state.snapshot.ActiveProfile = sanitizeUntrustedText(state.snapshot.ActiveProfile) state.snapshot.Profiles = append([]profile.Profile(nil), state.snapshot.Profiles...) for i := range state.snapshot.Profiles { state.snapshot.Profiles[i] = sanitizePresentationProfile(state.snapshot.Profiles[i]) } state.snapshot.Targets = append([]app.TargetSnapshot(nil), state.snapshot.Targets...) for i := range state.snapshot.Targets { item := &state.snapshot.Targets[i] item.Description.Name = sanitizeUntrustedText(item.Description.Name) item.Description.Summary = sanitizeUntrustedText(item.Description.Summary) item.Description.Scope = sanitizeUntrustedText(item.Description.Scope) item.Description.Permissions = sanitizeUntrustedText(item.Description.Permissions) item.Description.Ownership = sanitizeUntrustedText(item.Description.Ownership) item.Description.RestartRisk = sanitizeUntrustedText(item.Description.RestartRisk) item.Description.Details = sanitizeUntrustedText(item.Description.Details) item.AvailabilityEvidence = sanitizeUntrustedText(item.AvailabilityEvidence) item.Evidence = sanitizeUntrustedText(item.Evidence) item.DetectionErr = sanitizePresentationError(item.DetectionErr) item.PrimaryAction.DisabledReason = sanitizeUntrustedText(item.PrimaryAction.DisabledReason) item.DriftResolutionActions = append([]app.TargetAction(nil), item.DriftResolutionActions...) for action := range item.DriftResolutionActions { item.DriftResolutionActions[action].DisabledReason = sanitizeUntrustedText(item.DriftResolutionActions[action].DisabledReason) } } sanitizeStatus := sanitizeUntrustedText if state.page == formPage { // Profile editing treats the identity as private as well as the password. // Apply that stronger policy to every status/error ingress before styling. sanitizeStatus = sanitizeUntrustedUserinfoText } state.profile.status = sanitizeStatus(state.profile.status) state.operation.status.Summary = sanitizeStatus(state.operation.status.Summary) if state.operation.currentError != nil { current := *state.operation.currentError current.status.Summary = sanitizeStatus(current.status.Summary) current.detail = sanitizeStatus(current.detail) state.operation.currentError = ¤t } if state.operation.confirmation != nil { confirmation := sanitizePresentationConfirmation(*state.operation.confirmation) state.operation.confirmation = &confirmation } if state.operation.confirmationDialog != nil { dialog := *state.operation.confirmationDialog dialog.request = sanitizePresentationConfirmation(dialog.request) state.operation.confirmationDialog = &dialog } if state.profile.deletion != nil { confirmation := *state.profile.deletion confirmation.name = sanitizeUntrustedText(confirmation.name) confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request) state.profile.deletion = &confirmation } state.dashboard.picker.selectedName = sanitizeUntrustedText(state.dashboard.picker.selectedName) searchPosition := state.dashboard.picker.search.Position() state.dashboard.picker.search.SetValue(sanitizeUntrustedText(state.dashboard.picker.search.Value())) state.dashboard.picker.search.SetCursor(searchPosition) state.form.state = sanitizePresentationForm(state.form.state) return state } func sanitizePresentationProfile(value profile.Profile) profile.Profile { value.Name = sanitizeUntrustedText(value.Name) value.SingleURL = sanitizeUntrustedText(value.SingleURL) value.HTTPURL = sanitizeUntrustedText(value.HTTPURL) value.HTTPSURL = sanitizeUntrustedText(value.HTTPSURL) value.AllURL = sanitizeUntrustedText(value.AllURL) value.Bypass = append([]string(nil), value.Bypass...) for i := range value.Bypass { value.Bypass[i] = sanitizeUntrustedText(value.Bypass[i]) } return value } func sanitizePresentationForm(form formState) formState { // Preserve credential byte/rune positions in the copied editor state so // textinput cursor and horizontal viewport calculations stay truthful. // renderedInput masks userinfo before clipping, and the final form boundary // redacts it again; this pass only removes terminal controls before styling. form.originalName = sanitizeUntrustedControls(form.originalName) form.profile = sanitizePresentationFormProfile(form.profile) form.inputs = append([]formInput(nil), form.inputs...) for i := range form.inputs { form.inputs[i].label = sanitizeUntrustedText(form.inputs[i].label) position := form.inputs[i].input.Position() form.inputs[i].input.SetValue(sanitizeUntrustedControls(form.inputs[i].input.Value())) form.inputs[i].input.SetCursor(position) } form.probe.address = sanitizeUntrustedText(form.probe.address) form.probe.feedback = sanitizeUntrustedText(form.probe.feedback) form.probe.sourceValue = sanitizeUntrustedText(form.probe.sourceValue) form.refreshValidation() return form } func sanitizePresentationFormProfile(value profile.Profile) profile.Profile { value.Name = sanitizeUntrustedControls(value.Name) value.SingleURL = sanitizeUntrustedControls(value.SingleURL) value.HTTPURL = sanitizeUntrustedControls(value.HTTPURL) value.HTTPSURL = sanitizeUntrustedControls(value.HTTPSURL) value.AllURL = sanitizeUntrustedControls(value.AllURL) value.Bypass = append([]string(nil), value.Bypass...) for i := range value.Bypass { value.Bypass[i] = sanitizeUntrustedControls(value.Bypass[i]) } return value } func sanitizePresentationConfirmation(value app.Confirmation) app.Confirmation { value.Title = sanitizeUntrustedText(value.Title) value.Summary = sanitizeUntrustedText(value.Summary) value.ConfirmLabel = sanitizeUntrustedText(value.ConfirmLabel) value.CancelLabel = sanitizeUntrustedText(value.CancelLabel) value.Details = append([]string(nil), value.Details...) for i := range value.Details { value.Details[i] = sanitizeUntrustedText(value.Details[i]) } return value } func sanitizePresentationError(err error) error { if err == nil { return nil } return errors.New(sanitizeUntrustedText(err.Error())) } var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`) // sanitizeStyledText is the final defense after semantic styling. Untrusted // values have already passed sanitizeUntrustedText, so the only SGR retained // here is output generated by semanticTheme. func sanitizeStyledText(value string) string { return sanitizeTerminalText(value, true) } func sanitizeTerminalText(value string, allowThemeSGR bool) string { var safe strings.Builder for i := 0; i < len(value); { if value[i] == '\x1b' { if i+1 < len(value) && value[i+1] == '[' { end := i + 2 for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) { end++ } if end < len(value) { if allowThemeSGR && value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) { safe.WriteString(value[i : end+1]) } i = end + 1 continue } i = len(value) continue } if i+1 < len(value) && value[i+1] == ']' { i = skipTerminalString(value, i+2) continue } i++ continue } if value[i] == 0x9b { end := i + 1 for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) { end++ } if end < len(value) { i = end + 1 } else { i = len(value) } continue } if value[i] == 0x9d { i = skipTerminalString(value, i+1) continue } switch value[i] { case '\n': safe.WriteByte('\n') case '\t': safe.WriteString(" ") default: if value[i] >= utf8.RuneSelf { r, size := utf8.DecodeRuneInString(value[i:]) if r != utf8.RuneError || size > 1 { if r != '\ufeff' && !(r >= 0x80 && r <= 0x9f) && !(r >= 0x202a && r <= 0x202e) && !(r >= 0x2066 && r <= 0x2069) { safe.WriteString(value[i : i+size]) } i += size continue } } else if value[i] >= 0x20 && value[i] != 0x7f { safe.WriteByte(value[i]) } } i++ } var visible strings.Builder for _, r := range safe.String() { if r == '\ufeff' || (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) { continue } visible.WriteRune(r) } return visible.String() } func skipTerminalString(value string, i int) int { for i < len(value) { if value[i] == '\a' || value[i] == 0x9c { return i + 1 } if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' { return i + 2 } i++ } return i } func (p terminalPresentation) renderShell(page terminalPage, width, height int) string { header := viewportContent(page.header, width, lineCount(page.header), 0) footer := viewportContent(page.footer, width, lineCount(page.footer), 0) headerHeight := lineCount(header) footerHeight := lineCount(footer) baseSeparators := 0 if header != "" { baseSeparators++ } if footer != "" || page.status != "" { baseSeparators++ } status := "" if page.status != "" { wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status)) statusHeight := min(2, lineCount(wrapped)) statusOffset := 0 if page.statusExpanded { statusHeight = min(lineCount(wrapped), max(3, height/2)) // The compact dashboard needs ten rows to retain both panes. Bound // expanded errors to the remaining terminal budget instead of // allowing their viewport to push the frame beyond the terminal. minimumBodyHeight := 1 if page.dashboard != nil { minimumBodyHeight = 10 } statusHeight = min(statusHeight, max(1, height-headerHeight-footerHeight-baseSeparators-minimumBodyHeight)) statusOffset = page.statusOffset } status = viewportContent(wrapped, width, statusHeight, statusOffset) } statusHeight := lineCount(status) separators := baseSeparators bodyHeight := height - headerHeight - footerHeight - statusHeight - separators if bodyHeight < 1 { bodyHeight = 1 } body := renderPageBody(page, width, bodyHeight) if page.dashboard != nil { body = p.renderDashboard(*page.dashboard, width, bodyHeight) } sections := make([]string, 0, 3) if header != "" { sections = append(sections, header) } sections = append(sections, body) bottom := joinNonEmpty(status, footer) if bottom != "" { sections = append(sections, bottom) } frame := strings.Join(sections, "\n\n") return strings.TrimRight(frame, " \n") + "\n" } func renderPageBody(page terminalPage, width, height int) string { primaryContent := page.primary if page.wrapPrimary { primaryContent = lipgloss.NewStyle().Width(width).Render(primaryContent) } if page.context == "" || height < 5 { return viewportContentAt(primaryContent, width, height, page.primaryOffset, page.focusLine) } context := page.context if page.wrapContext { context = lipgloss.NewStyle().Width(width).Render(context) } contextHeight := min(lineCount(context), max(3, height/2)) primaryHeight := height - contextHeight - 1 if primaryHeight < 1 { primaryHeight = 1 contextHeight = max(1, height-primaryHeight-1) } primary := viewportContentAt(primaryContent, width, primaryHeight, page.primaryOffset, page.focusLine) contextFocus := -1 if page.anchorContext { contextFocus = page.contextAnchor } context = viewportContentAt(context, width, contextHeight, page.contextOffset, contextFocus) return primary + "\n\n" + context } func viewportContent(content string, width, height, focusLine int) string { return viewportContentAt(content, width, height, 0, focusLine) } func viewportContentAt(content string, width, height, offset, focusLine int) string { if content == "" || width <= 0 || height <= 0 { return "" } view := viewport.New(width, height) view.SetContent(strings.TrimRight(content, "\n")) top := max(0, offset) if focusLine >= 0 { if focusLine < top { top = focusLine } else if focusLine >= top+height { top = focusLine - height + 1 } } view.SetYOffset(top) return view.View() } func lineCount(content string) int { if content == "" { return 0 } return strings.Count(strings.TrimRight(content, "\n"), "\n") + 1 } func joinNonEmpty(values ...string) string { var present []string for _, value := range values { if value != "" { present = append(present, value) } } return strings.Join(present, "\n") } func (p terminalPresentation) minimumSizeView(width, height int) string { return fmt.Sprintf("%s\n\nMinimum size: 60×16\nCurrent size: %d×%d\nResize the terminal to continue.", p.theme.warning.Render("Terminal too small"), width, height) }