diff --git a/internal/termout/startup.go b/internal/termout/startup.go new file mode 100644 index 00000000..00209721 --- /dev/null +++ b/internal/termout/startup.go @@ -0,0 +1,67 @@ +package termout + +import ( + "fmt" + "os" + "strings" +) + +// StartupWebUIOptions configures the startup Web UI banner. +type StartupWebUIOptions struct { + Scheme string + Port int + SelfSigned bool + HTTPRedirect bool +} + +// PrintConfigCreated prints a short notice when config.yaml is bootstrapped. +func PrintConfigCreated() { + s := New(os.Stdout) + s.Println("") + s.Println(s.Green("✔ ") + s.Bold("已创建 config.yaml") + s.Dim("(来自 config.example.yaml)")) + s.BlankLine() +} + +// PrintStartupWebUI prints a colored startup banner for the Web UI. +func PrintStartupWebUI(opts StartupWebUIOptions) { + s := New(os.Stdout) + scheme := opts.Scheme + if scheme == "" { + scheme = "http" + } + port := opts.Port + if port <= 0 { + port = 8080 + } + url := fmt.Sprintf("%s://127.0.0.1:%d/", scheme, port) + + s.BlankLine() + s.Println(s.Bold(s.Cyan("CYBERSTRIKE AI")) + s.Dim(" / secure workspace")) + s.Println(s.Dim(strings.Repeat("─", 60))) + s.Println(s.Green("● ONLINE") + " " + s.Bold(s.White(url))) + if opts.SelfSigned { + s.Println(s.Dim(" TLS ") + s.Yellow("self-signed") + s.Dim(" · accept the browser warning once")) + } + if opts.HTTPRedirect { + s.Println(s.Dim(" Redirect ") + fmt.Sprintf("http://127.0.0.1:%d/ → HTTPS", port)) + } + s.BlankLine() +} + +// PrintBootstrapAdminCredentials prints the initial admin password banner. +func PrintBootstrapAdminCredentials(password string) { + password = strings.TrimSpace(password) + if password == "" { + return + } + + s := New(os.Stdout) + s.Println(s.Bold(s.Yellow("ADMIN SETUP REQUIRED"))) + s.Println(s.Dim(strings.Repeat("─", 60))) + s.Println(s.Dim(" Username ") + s.Bold(s.White("admin"))) + s.Println(s.Dim(" Password ") + s.Bold(s.Yellow(password))) + s.BlankLine() + s.Println(s.Yellow(" ! ") + s.White("Store this password securely. It is shown only once.")) + s.Println(s.Dim(" Change it in Settings immediately after signing in.")) + s.BlankLine() +} diff --git a/internal/termout/startup_test.go b/internal/termout/startup_test.go new file mode 100644 index 00000000..f01b610c --- /dev/null +++ b/internal/termout/startup_test.go @@ -0,0 +1,76 @@ +package termout + +import ( + "strings" + "testing" +) + +func TestDisplayWidthEmoji(t *testing.T) { + if got := displayWidth("🚀"); got != 2 { + t.Fatalf("displayWidth(emoji) = %d, want 2", got) + } + if got := displayWidth("ab"); got != 2 { + t.Fatalf("displayWidth(ab) = %d, want 2", got) + } +} + +func TestDisplayWidthIgnoresANSI(t *testing.T) { + s := New(nil) + colored := s.Bold("admin") + if got := displayWidth(colored); got != 5 { + t.Fatalf("displayWidth colored = %d, want 5", got) + } +} + +func TestPadRightDisplay(t *testing.T) { + got := padRightDisplay("pwd", 10) + if displayWidth(got) != 10 { + t.Fatalf("padded width = %d, want 10", displayWidth(got)) + } +} + +func TestColorDisabledWithoutTTY(t *testing.T) { + s := New(nil) + if s.enabled { + t.Fatal("expected colors disabled for nil writer") + } + if got := s.Cyan("x"); got != "x" { + t.Fatalf("Cyan without TTY = %q, want plain text", got) + } +} + +func TestPrintBootstrapAdminCredentialsEmpty(t *testing.T) { + PrintBootstrapAdminCredentials(" ") +} + +func TestPrintStartupWebUIOptions(t *testing.T) { + PrintStartupWebUI(StartupWebUIOptions{ + Scheme: "https", + Port: 8080, + SelfSigned: true, + HTTPRedirect: true, + }) +} + +func TestBoxRowAlignedWidth(t *testing.T) { + s := New(nil) + rows := []string{ + s.Bold("CyberStrikeAI") + s.White(" is ready"), + s.Dim("Web UI ") + s.Bold("https://127.0.0.1:8080/"), + } + inner := maxDisplayWidth(rows...) + for _, row := range rows { + line := s.boxRow(inner, row) + if !strings.Contains(line, "│") { + t.Fatalf("box row missing border: %q", line) + } + } +} + +func TestMaxDisplayWidth(t *testing.T) { + short := "abc" + long := "https://127.0.0.1:8080/" + if got := maxDisplayWidth(short, long); got != displayWidth(long) { + t.Fatalf("maxDisplayWidth = %d, want %d", got, displayWidth(long)) + } +} diff --git a/internal/termout/style.go b/internal/termout/style.go new file mode 100644 index 00000000..2a805de3 --- /dev/null +++ b/internal/termout/style.go @@ -0,0 +1,108 @@ +package termout + +import ( + "fmt" + "io" + "os" + "strings" +) + +const ( + codeReset = "\033[0m" + codeBold = "\033[1m" + codeDim = "\033[2m" + codeRed = "\033[31m" + codeGreen = "\033[32m" + codeYellow = "\033[33m" + codeBlue = "\033[34m" + codeCyan = "\033[36m" + codeWhite = "\033[97m" +) + +// Style wraps ANSI styling with TTY / NO_COLOR awareness. +type Style struct { + out io.Writer + enabled bool +} + +// New creates a Style writing to out (typically os.Stdout). +func New(out io.Writer) *Style { + return &Style{out: out, enabled: colorEnabled(out)} +} + +func colorEnabled(w io.Writer) bool { + if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" { + return false + } + force := strings.TrimSpace(os.Getenv("FORCE_COLOR")) + if force == "1" || strings.EqualFold(force, "true") || strings.EqualFold(force, "yes") { + return true + } + f, ok := w.(*os.File) + if !ok { + return false + } + stat, err := f.Stat() + if err != nil { + return false + } + return stat.Mode()&os.ModeCharDevice != 0 +} + +func (s *Style) paint(code, text string) string { + if !s.enabled || text == "" { + return text + } + return code + text + codeReset +} + +func (s *Style) Bold(text string) string { return s.paint(codeBold, text) } +func (s *Style) Dim(text string) string { return s.paint(codeDim, text) } +func (s *Style) Red(text string) string { return s.paint(codeRed, text) } +func (s *Style) Green(text string) string { return s.paint(codeGreen, text) } +func (s *Style) Yellow(text string) string { return s.paint(codeYellow, text) } +func (s *Style) Blue(text string) string { return s.paint(codeBlue, text) } +func (s *Style) Cyan(text string) string { return s.paint(codeCyan, text) } +func (s *Style) White(text string) string { return s.paint(codeWhite, text) } + +func (s *Style) Println(text string) { + _, _ = fmt.Fprintln(s.out, text) +} + +func (s *Style) Printf(format string, args ...interface{}) { + _, _ = fmt.Fprintf(s.out, format, args...) +} + +func (s *Style) BlankLine() { + s.Println("") +} + +func (s *Style) boxTop(innerWidth int) string { + return s.Cyan("╭" + strings.Repeat("─", innerWidth+2) + "╮") +} + +func (s *Style) boxBottom(innerWidth int) string { + return s.Cyan("╰" + strings.Repeat("─", innerWidth+2) + "╯") +} + +func (s *Style) boxRow(innerWidth int, content string) string { + return s.Cyan("│ ") + padRightDisplay(content, innerWidth) + s.Cyan(" │") +} + +func (s *Style) printBox(rows []string, minInner, maxInner int) { + inner := maxDisplayWidth(rows...) + if inner < minInner { + inner = minInner + } + if maxInner > 0 && inner > maxInner { + inner = maxInner + } + + s.BlankLine() + s.Println(s.boxTop(inner)) + for _, row := range rows { + s.Println(s.boxRow(inner, row)) + } + s.Println(s.boxBottom(inner)) + s.BlankLine() +} diff --git a/internal/termout/width.go b/internal/termout/width.go new file mode 100644 index 00000000..9ea812bf --- /dev/null +++ b/internal/termout/width.go @@ -0,0 +1,73 @@ +package termout + +import ( + "regexp" + "strings" + "unicode/utf8" + + "golang.org/x/text/width" +) + +var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// displayWidth returns the terminal display width of text, ignoring ANSI codes. +func displayWidth(text string) int { + plain := ansiEscapeRe.ReplaceAllString(text, "") + w := 0 + for _, r := range plain { + w += runeDisplayWidth(r) + } + return w +} + +func runeDisplayWidth(r rune) int { + if r == utf8.RuneError { + return 0 + } + // Most emoji / symbols render as double-width in modern terminals. + if isEmojiLikeRune(r) { + return 2 + } + switch width.LookupRune(r).Kind() { + case width.EastAsianWide, width.EastAsianFullwidth: + return 2 + default: + return 1 + } +} + +func isEmojiLikeRune(r rune) bool { + switch { + case r >= 0x1F300 && r <= 0x1FAFF: // pictographs / emoji + return true + case r >= 0x2600 && r <= 0x27BF: // misc symbols + return true + case r >= 0x2300 && r <= 0x23FF: // misc technical (⌚ etc.) + return true + case r >= 0x2B50 && r <= 0x2B55: + return true + default: + return false + } +} + +func padRightDisplay(text string, target int) string { + if target <= 0 { + return "" + } + gap := target - displayWidth(text) + if gap <= 0 { + return text + } + return text + strings.Repeat(" ", gap) +} + +func maxDisplayWidth(rows ...string) int { + max := 0 + for _, row := range rows { + if w := displayWidth(row); w > max { + max = w + } + } + return max +}