package tui import ( "fmt" "io" "os" "regexp" "strings" "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 width int height int selected int profileOffset int profileDetails int profileDelete *profileDeleteConfirmation selectedTargetID string dashboardFocus dashboardPane showUnavailable bool detailsOffset int driftResolutionTargetID string picker profilePickerState form formState help helpDialogState confirmation *app.Confirmation confirmationDialog *confirmationDialog status string operationStatus app.OperationStatus currentError *operationError errorExpanded bool errorOffset int exitDeferred bool operationActive bool operationProgress map[string]app.TargetProgressPhase } // 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 { width, height := state.width, state.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.top(state.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.errorExpanded && state.currentError != nil page.statusOffset = state.errorOffset page = redactTerminalPage(page) // Dashboard composition retains structured Target metadata until pane sizes // are known. Redact the fully composed frame as the final presentation // boundary as well, so no render path can bypass credential scrubbing. Form // frames use the stricter boundary that removes the entire userinfo. frame := p.renderShell(page, width, height) if state.page == formPage { return sanitizeStyledText(profile.RedactUserinfoText(frame)) } return sanitizeStyledText(profile.RedactText(frame)) } func renderStatus(state presentationState) string { var lines []string if state.exitDeferred { lines = append(lines, "Exit requested — waiting for the current mutation to finish safely") } else if state.operationStatus.Summary != "" { lines = append(lines, state.operationStatus.Summary) } else if state.status != "" { lines = append(lines, state.status) } if state.currentError != nil { errorText := "Current error: " + state.currentError.status.Summary if state.errorExpanded && state.currentError.detail != "" { errorText += "\n" + state.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(profile.RedactText(value)) } var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`) // sanitizeStyledText removes terminal-control injection while retaining only // the small SGR palette emitted by semanticTheme. OSC title/hyperlink commands, // cursor movement, concealment, carriage returns, and private C1 controls are // never meaningful application text and must not reach a rendered frame. func sanitizeStyledText(value string) 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 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 += 2 for i < len(value) { if value[i] == '\a' { i++ break } if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' { i += 2 break } i++ } continue } i++ continue } switch value[i] { case '\n': safe.WriteByte('\n') case '\t': safe.WriteString(" ") default: if value[i] >= 0x20 && value[i] != 0x7f { safe.WriteByte(value[i]) } } i++ } var visible strings.Builder for _, r := range safe.String() { if (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) { continue } visible.WriteRune(r) } return visible.String() } 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) 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)) statusOffset = page.statusOffset } status = viewportContent(wrapped, width, statusHeight, statusOffset) } headerHeight := lineCount(header) footerHeight := lineCount(footer) statusHeight := lineCount(status) separators := 0 if header != "" { separators++ } if footer != "" || status != "" { separators++ } 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) }