0

Command Box bài học 5: Thiết kế giao diện dòng lệnh (CLI UX): Làm sao để công cụ thân thiện như phần mềm GUI?

Đã bao giờ bạn viết xong một công cụ dòng lệnh tâm huyết, gửi cho anh em trong team dùng thử, nhưng chỉ 2 ngày sau thấy họ quay lại làm tay hoặc dùng Postman như cũ?

Lý do thường không nằm ở việc code của bạn chạy sai, mà nằm ở cảm giác bất an khi sử dụng: gõ lệnh xong màn hình đen thui đứng im 10 giây không biết tool đang chạy hay bị treo; quên truyền một flag là văng ra một đống stack trace đỏ lòm; hoặc kết quả trả về là một cục JSON dài 200 dòng không thể đọc nổi bằng mắt thường.

Nhiều người mặc định CLI (Command Line Interface) là phải thô ráp, đen trắng và dành cho "hacker". Nhưng thực tế, những CLI đỉnh cao hiện nay (như gh, vercel, railway, docker) đều được thiết kế dựa trên nguyên lý Human-First Design — biến màn hình terminal thành một giao diện trực quan, dẫn dắt người dùng mượt mà chẳng kém gì phần mềm có giao diện đồ họa (GUI).

https://github.com/charmbracelet/huh

image.png

1. 4 "Tội đồ" giết chết trải nghiệm người dùng trên Terminal

Trước khi làm cho Command Box đẹp lên, bạn cần loại bỏ ngay 4 thói quen xấu thường gặp khi in dữ liệu từ tầng Backend ra màn hình:

  • Sự im lặng đáng sợ (The Silent Hang): Khi lệnh của bạn phải gọi qua mạng (tải file backup DB, đợi Kafka rebalance, build Docker image) mất более 2 giây mà không in ra bất kỳ tín hiệu nào. Người dùng sẽ tưởng tool bị đơ và bấm Ctrl + C liên tục, làm hỏng tiến trình đang chạy dở.

  • Bức tường chữ đơn sắc (Wall of Monospace Text): Đổ thẳng hàng trăm dòng log trắng xóa có cùng độ sáng ra màn hình. Mắt người không thể phân biệt được đâu là thông báo bình thường (INFO), đâu là cảnh báo nguy hiểm (WARN) và đâu là dữ liệu chính cần tìm.

  • Bắt người dùng "Thuộc lòng" mọi tham số (Memory Overload): Nếu một lệnh cần 4 tham số (--env, --service, --action, --replicas) mà người dùng gõ thiếu 1 cái, một CLI tồi sẽ lập tức mắng vào mặt họ: Error: required flag(s) "service" not set rồi thoát. Một CLI có UX tốt sẽ tự động bật lên một menu thả xuống hỏi: "Bạn muốn thao tác trên service nào?" để họ chọn bằng phím mũi tên.

  • Trộn lẫn dữ liệu và lời thoại (Stream Pollution): Đổ cả câu thông báo kiểu "Đang lấy dữ liệu..." lẫn kết quả JSON vào cùng một luồng stdout. Khi đồng nghiệp muốn dùng lệnh của bạn kết hợp với jq (ví dụ: cbox order get -i 123 | jq .status), dòng chữ "Đang lấy dữ liệu..." sẽ làm jq báo lỗi cú pháp ngay lập tức!

2. 5 Trụ cột biến CLI khô khan thành trải nghiệm chuẩn GUI

Để Command Box đạt tới độ thân thiện mà ai gõ vào cũng thích, hãy trang bị cho tầng internal/ui/ (đã thiết kế ở Bài học 4) 5 vũ khí giao diện sau:

① Phản hồi thị giác tức thì (Spinners & Progress Bars)

Nguyên tắc vàng của UX: Mọi tác vụ kéo dài trên 500ms đều phải có chuyển động trên màn hình.

  • Dùng Spinner (Vòng xoay trạng thái): Khi không biết chính xác thời gian hoàn thành (ví dụ: đang đợi query Database nặng hoặc chờ API bên thứ ba phản hồi).

  • Dùng Progress Bar (Thanh tiến trình): Khi biết rõ tổng số lượng công việc (ví dụ: đang đồng bộ 5.000 đơn hàng vào Elasticsearch, hoặc đang tải file dump 500MB).

② Chế độ Lai: Vừa hỗ trợ Flags, vừa có Menu tương tác (Hybrid Interactive Mode)

Đừng ép một con người phải gõ lệnh như một cái máy, nhưng cũng đừng ép một cái máy (CI/CD script) phải bấm phím mũi tên như con người! Hãy thiết kế Command Box hoạt động theo cơ chế Tự động chuyển đổi (Graceful Fallback):

  • Nếu người dùng truyền đủ cờ: cbox deploy --env=staging --service=order-api →\rightarrow Chạy thẳng không hỏi gì thêm (phục vụ cho tự động hóa hoặc người đã quen tay).

  • Nếu người dùng chỉ gõ cụt lủn: cbox deploy →\rightarrow Thay vì báo lỗi, hãy kiểm tra xem họ có đang ngồi trước màn hình terminal thật không (TTY check). Nếu có, hãy hiển thị Interactive Select / Prompt để dẫn dắt họ từng bước như một Wizard trên GUI!

③ Phân cấp thị giác bằng Màu sắc và Biểu tượng (Semantic Colors & Nerd Icons)

Màu sắc trên terminal không phải để trang trí cho lòe loẹt, mà để bộ não quét thông tin trong 0.1 giây. Hãy quy chuẩn hóa bảng màu trong internal/ui/printer.go:

Trạng thái Màu sắc chuẩn (ANSI) Ký hiệu đi kèm Ngữ cảnh sử dụng
Thành công (Success) Xanh lá (Green) ✔ hoặc ✅ Khi hoàn tất tác vụ, kết nối thành công.
Cảnh báo (Warning) Vàng cam (Yellow) ⚠ hoặc ⚡ Khi dùng cấu hình mặc định, dữ liệu bị bỏ qua, hoặc sắp chạm ngưỡng.
Lỗi (Error / Danger) Đỏ tươi (Red + Bold) ✖ hoặc ❌ Khi lệnh thất bại, mất kết nối, hoặc cảnh báo tác động vào môi trường PROD.
Thông tin phụ (Muted) Xám mờ (Gray / Faint) ℹ hoặc • Thời gian chạy, đường dẫn file, gợi ý lệnh tiếp theo.
Điểm nhấn (Highlight) Xanh lơ (Cyan / Magenta) Không cần Tên biến, ID đơn hàng, tên container, URL.

④ Cấu trúc hóa dữ liệu đầu ra (Tables, Trees & Box Layout)

Khi người dùng gõ cbox kafka lag hoặc cbox db status, tuyệt đối không in các dòng chữ lệch lạc nhau.

  • Hãy đóng khung dữ liệu vào Bảng biểu (ASCII/Unicode Table) có căn lề trái cho chữ, căn lề phải cho con số (như số lượng message lag, dung lượng RAM).

  • Sử dụng đường viền bo góc (╭─╮) để gom nhóm thông tin quan trọng (ví dụ: Thông tin chi tiết của 1 đơn hàng và trạng thái của nó ở 3 hệ thống khác nhau).

⑤ Thông báo lỗi có tính "Hướng dẫn giải quyết" (Actionable Error Messages)

Một thông báo lỗi chuẩn UX không bao giờ chỉ nói "Cái gì đã sai", mà phải chỉ luôn cho người dùng "Họ cần gõ lệnh gì tiếp theo để sửa".

  • CLI Tồi: Error: dial tcp 127.0.0.1:5432: connect: connection refused

  • CLI Chuẩn UX:

    Plaintext

    ✖ Không thể kết nối tới PostgreSQL tại cổng 5432.
    
    💡 Gợi ý khắc phục:
      1. Kiểm tra xem container DB đã bật chưa:  cbox docker status
      2. Hoặc khởi động nhanh môi trường local:  cbox env up -d
    
    

3. Phân tách luồng stdout và stderr: Bí quyết của sự chuyên nghiệp

Đây là chi tiết kỹ thuật phân biệt một tay mơ viết CLI và một kỹ sư hệ thống thực thụ:

  • os.Stdout (Standard Output): Chỉ dành riêng cho Dữ liệu kết quả chính của câu lệnh (bảng dữ liệu, chuỗi JSON khi có cờ --output=json, danh sách ID).

  • os.Stderr (Standard Error): Dành cho Mọi thứ thuộc về UX như: vòng xoay Spinner, thanh Progress Bar, dòng chữ "Đang kết nối...", các cảnh báo màu vàng và thông báo lỗi màu đỏ.

Tại sao phải tách như vậy? Khi bạn in Spinner và lời dẫn ra os.Stderr, trên màn hình terminal người dùng vẫn nhìn thấy hiệu ứng quay vòng rất đẹp mắt. Nhưng khi họ chạy lệnh:

Bash

cbox order list --status=failed -o json > failed_orders.json

Hệ điều hành sẽ chỉ lấy đúng phần dữ liệu sạch ở stdout để ghi vào file failed_orders.json, còn toàn bộ hiệu ứng Spinner ở stderr vẫn hiện trên màn hình để người dùng theo dõi tiến độ mà không hề làm bẩn file kết quả!

4. Thực hành viết code: Xây dựng tầng internal/ui với hệ sinh thái Charmbracelet

Trong Golang, bộ thư viện của Charmbracelet (lipgloss, huh, bubbletea) hiện là tiêu chuẩn vàng để làm CLI UX. Hãy xem cách viết một lệnh cbox deploy có khả năng tự động bật giao diện chọn tương tác cực mượt:

Go

package deploy

import (
	"fmt"
	"os"

	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/huh/spinner"
	"github.com/charmbracelet/lipgloss"
	"github.com/spf13/cobra"
)

// Định nghĩa Style màu sắc với Lipgloss
var (
	successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
	warnStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
	boxStyle     = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 2).BorderForeground(lipgloss.Color("63"))
)

type deployOpts struct {
	env     string
	service string
	confirm bool
}

func NewDeployCmd() *cobra.Command {
	opts := &deployOpts{}

	cmd := &cobra.Command{
		Use:   "deploy",
		Short: "Triển khai microservice lên môi trường đích",
		RunE: func(cmd *cobra.Command, args []string) error {
			// 1. Nếu thiếu tham số, tự động bật giao diện Interactive Form (giống GUI)
			if opts.env == "" || opts.service == "" {
				form := huh.NewForm(
					huh.NewGroup(
						huh.NewSelect[string]().
							Title("Chọn môi trường triển khai (Environment):").
							Options(
								huh.NewOption("Staging (Thử nghiệm)", "staging"),
								huh.NewOption("Production (Thực tế - Cẩn trọng!)", "prod"),
							).
							Value(&opts.env),

						huh.NewSelect[string]().
							Title("Chọn dịch vụ cần nâng cấp (Service):").
							Options(
								huh.NewOption("Order Service", "order-srv"),
								huh.NewOption("Payment Gateway", "payment-srv"),
								huh.NewOption("Logistics Routing", "routing-srv"),
							).
							Value(&opts.service),
					),
				)
				if err := form.Run(); err != nil {
					return err
				}
			}

			// 2. Chốt chặn an toàn nếu đụng vào Production
			if opts.env == "prod" && !opts.confirm {
				fmt.Fprintln(os.Stderr, warnStyle.Render("⚠ CẢNH BÁO: Bạn đang chuẩn bị tác động trực tiếp lên môi trường PRODUCTION!"))
				err := huh.NewConfirm().
					Title(fmt.Sprintf("Bạn có chắc chắn muốn deploy [%s] lên PROD không?", opts.service)).
					Affirmative("Chắc chắn, chạy đi!").
					Negative("Hủy bỏ").
					Value(&opts.confirm).
					Run()
				if err != nil || !opts.confirm {
					fmt.Fprintln(os.Stderr, "Đã hủy thao tác triển khai.")
					return nil
				}
			}

			// 3. Hiển thị Spinner trong lúc chờ xử lý ngầm
			action := func() {
				// Giả lập gọi API/SSH deploy mất 3 giây
				performActualDeploy(opts.env, opts.service)
			}
			_ = spinner.New().
				Title(fmt.Sprintf(" Đang đóng gói và triển khai %s lên %s...", opts.service, opts.env)).
				Action(action).
				Run()

			// 4. In kết quả đóng khung đẹp mắt
			summary := fmt.Sprintf("%s\n• Dịch vụ:    %s\n• Môi trường: %s\n• Trạng thái: Healthy (3/3 pods)",
				successStyle.Render("✔ TRIỂN KHAI THÀNH CÔNG"), opts.service, opts.env)
			fmt.Println(boxStyle.Render(summary))

			return nil
		},
	}

	cmd.Flags().StringVarP(&opts.env, "env", "e", "", "Môi trường đích (staging|prod)")
	cmd.Flags().StringVarP(&opts.service, "service", "s", "", "Tên service cần deploy")
	cmd.Flags().BoolVarP(&opts.confirm, "yes", "y", false, "Bỏ qua bước hỏi xác nhận (dùng cho CI/CD)")

	return cmd
}

5. Checklist "Chuẩn hóa CLI UX" trước khi xuất xưởng

Mỗi khi viết xong một lệnh mới trong Command Box, hãy tự kiểm tra nhanh qua 5 câu hỏi này:

  1. Quy tắc NO_COLOR: Nếu chạy lệnh trên môi trường CI/CD (GitLab CI, GitHub Actions) hoặc người dùng truyền biến môi trường NO_COLOR=1, tool có tự động tắt mã màu ANSI để tránh làm rác file log bằng các ký tự lạ như \x1b[32m không? (Thư viện lipgloss tự động xử lý việc này cho bạn).

  2. Cờ -y / --yes cho tự động hóa: Mọi chỗ có câu hỏi xác nhận tương tác (Confirm Prompt) đều phải đi kèm một cờ --yes hoặc --force để khi nhúng vào script tự động nó không bị đứng chờ người bấm phím.

  3. Hỗ trợ --output=json (hoặc -o json): Ngoài bảng biểu đẹp mắt cho mắt người đọc, lệnh lấy dữ liệu đã hỗ trợ xuất ra JSON thô để dễ dàng pipe (|) sang các công cụ khác chưa?

  4. Ví dụ thực tế trong --help: Trong phần khai báo Cobra, bạn đã điền thuộc tính Example với 2-3 câu lệnh mẫu hay dùng nhất chưa? (Người dùng luôn nhìn vào Example đầu tiên chứ không đọc phần mô tả dài dòng).

💡 Bài tập thực hành cuối bài (Dành cho độc giả):

Hãy mở lại một lệnh CLI hoặc script mà bạn đang có. Tìm chỗ nào đang dùng lệnh fmt.Println("Đang xử lý...") đơn điệu và thử thay thế nó bằng một chiếc Spinner hoặc đóng khung kết quả đầu ra bằng Lipgloss Box. Bạn sẽ thấy cảm giác chuyên nghiệp của công cụ tăng lên gấp 10 lần chỉ sau 10 dòng code!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí