perf: ask user

This commit is contained in:
voocel
2026-03-13 01:15:00 +08:00
parent 7488198461
commit 25e219e934
10 changed files with 677 additions and 28 deletions

278
tui/ask_user.go Normal file
View File

@@ -0,0 +1,278 @@
package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/voocel/ainovel-cli/tools"
)
type askUserRequest struct {
questions []tools.Question
resultCh chan askUserResult
}
type askUserResult struct {
resp *tools.AskUserResponse
err error
}
type askUserBridge struct {
requests chan askUserRequest
}
func newAskUserBridge() *askUserBridge {
return &askUserBridge{
requests: make(chan askUserRequest),
}
}
func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
req := askUserRequest{
questions: questions,
resultCh: make(chan askUserResult, 1),
}
select {
case b.requests <- req:
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case result := <-req.resultCh:
return result.resp, result.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
type askUserState struct {
request askUserRequest
index int
cursor int
typing bool
input string
selected map[int]bool
answers map[string]string
notes map[string]string
}
func newAskUserState(req askUserRequest) *askUserState {
return &askUserState{
request: req,
selected: make(map[int]bool),
answers: make(map[string]string),
notes: make(map[string]string),
}
}
func (s *askUserState) currentQuestion() tools.Question {
return s.request.questions[s.index]
}
func (s *askUserState) optionCount() int {
return len(s.currentQuestion().Options) + 1
}
func (s *askUserState) choiceLabel(idx int) string {
q := s.currentQuestion()
if idx < len(q.Options) {
return q.Options[idx].Label
}
return "自由输入"
}
func (s *askUserState) choiceDescription(idx int) string {
q := s.currentQuestion()
if idx < len(q.Options) {
return q.Options[idx].Description
}
return "以上都不合适,自己补充"
}
func (s *askUserState) moveCursor(delta int) {
total := s.optionCount()
if total == 0 {
s.cursor = 0
return
}
s.cursor = (s.cursor + delta + total) % total
}
func (s *askUserState) toggleSelection() {
if s.selected[s.cursor] {
delete(s.selected, s.cursor)
return
}
s.selected[s.cursor] = true
}
func (s *askUserState) finishCurrentAnswer() bool {
q := s.currentQuestion()
if s.typing {
text := strings.TrimSpace(s.input)
if text == "" {
return false
}
s.answers[q.Question] = text
s.notes[q.Question] = text
return s.advance()
}
if q.MultiSelect {
var values []string
var custom string
for idx := 0; idx < s.optionCount(); idx++ {
if !s.selected[idx] {
continue
}
if idx < len(q.Options) {
values = append(values, q.Options[idx].Label)
continue
}
custom = strings.TrimSpace(s.input)
}
if custom != "" {
values = append(values, custom)
s.notes[q.Question] = custom
}
if len(values) == 0 {
return false
}
s.answers[q.Question] = strings.Join(values, "、")
return s.advance()
}
if s.cursor >= len(q.Options) {
s.typing = true
s.input = ""
return false
}
s.answers[q.Question] = q.Options[s.cursor].Label
return s.advance()
}
func (s *askUserState) advance() bool {
s.index++
if s.index >= len(s.request.questions) {
return true
}
s.cursor = 0
s.typing = false
s.input = ""
s.selected = make(map[int]bool)
return false
}
func (s *askUserState) submit() {
s.request.resultCh <- askUserResult{
resp: &tools.AskUserResponse{
Answers: s.answers,
Notes: s.notes,
},
}
}
func (s *askUserState) cancelCurrentTyping() {
s.typing = false
s.input = ""
}
func renderAskUserModal(width, height int, state *askUserState) string {
if state == nil {
return ""
}
q := state.currentQuestion()
boxW := minInt(maxInt(width*60/100, 52), width-4)
boxH := minInt(maxInt(height*60/100, 16), height-4)
if boxW < 40 {
boxW = maxInt(width-2, 20)
}
if boxH < 10 {
boxH = maxInt(height-2, 8)
}
var b strings.Builder
title := fmt.Sprintf("需要补充信息 %d/%d", state.index+1, len(state.request.questions))
b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(title))
b.WriteString("\n\n")
if q.Header != "" {
b.WriteString(highlightValueStyle.Render(q.Header))
b.WriteString("\n")
}
b.WriteString(cardContentStyle.Render(q.Question))
b.WriteString("\n\n")
for idx := 0; idx < state.optionCount(); idx++ {
prefix := " "
if state.cursor == idx {
prefix = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(" ")
}
label := state.choiceLabel(idx)
if q.MultiSelect {
marker := "[ ]"
if state.selected[idx] {
marker = "[x]"
}
label = marker + " " + label
}
b.WriteString(prefix + cardContentStyle.Render(label))
b.WriteString("\n")
b.WriteString(" " + lipgloss.NewStyle().Foreground(colorDim).Render(state.choiceDescription(idx)))
b.WriteString("\n")
}
if state.typing || (q.MultiSelect && state.selected[len(q.Options)]) {
b.WriteString("\n")
b.WriteString(panelTitleStyle.Render("补充内容"))
b.WriteString("\n")
content := state.input
if content == "" {
content = "请输入..."
}
style := lipgloss.NewStyle().
Width(boxW-8).
Border(baseBorder).
BorderForeground(colorDim).
Padding(0, 1)
b.WriteString(style.Render(content))
b.WriteString("\n")
}
hint := "↑↓ 选择 · Enter 确认"
if q.MultiSelect {
hint = "↑↓ 选择 · Space 勾选 · Enter 提交"
}
if state.typing {
hint = "输入补充内容 · Enter 确认 · Esc 返回选项"
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(hint))
box := lipgloss.NewStyle().
Width(boxW).
Height(boxH).
Border(baseBorder).
BorderForeground(colorAccent).
Padding(1, 2).
Background(lipgloss.Color("#1b1712")).
Render(b.String())
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}