31 Commits
v6.0 ... v1.1.0

Author SHA1 Message Date
c0156a88d6 feat: 修复配置路径BUG并迁移onboard到huh
All checks were successful
Release / build (push) Successful in 12m14s
- 新增路径解析工具 internal/config/path.go
- 配置查找优先级: --config > ~/.config/yoyo/config.yaml > ./configs/config.yaml
- onboard配置保存到 ~/.config/yoyo/config.yaml (符合XDG规范)
- .env文件从 ~/.config/yoyo/.env 加载
- onboard使用huh替代survey库,更现代的交互体验
- 添加Ctrl+C取消支持,打印'你已取消本次配置'
- 保存前增加确认步骤
- 版本号 v0.5.1 -> v1.1.0
2026-04-07 23:51:33 +08:00
21e4710829 fix: upload release assets with correct API endpoint
All checks were successful
Release / build (push) Successful in 11m33s
2026-04-07 09:17:11 +08:00
0b102dcb2a chore: add yoo to gitignore 2026-04-07 09:06:59 +08:00
d26558ad0a fix: install curl before release step
All checks were successful
Release / build (push) Successful in 12m1s
2026-04-07 08:53:32 +08:00
c2c3b11d35 add: cmd directory 2026-04-07 08:46:15 +08:00
34a7e7d208 fix: use absolute path for workspace 2026-04-07 08:42:43 +08:00
d327bedf04 fix: add -buildvcs=false flag and remove debug step
Some checks failed
Release / build (push) Failing after 5m39s
2026-04-07 08:40:40 +08:00
8c5eff79cc fix: use current dir instead of /workspace
Some checks failed
Release / build (push) Failing after 1m3s
2026-04-07 08:29:30 +08:00
0ba6df60b0 fix: copy files to /workspace and add debug step
Some checks failed
Release / build (push) Failing after 29s
2026-04-07 08:28:17 +08:00
1733d7b1cc fix: clone to subdir then move files to root
Some checks failed
Release / build (push) Failing after 58s
2026-04-07 08:24:47 +08:00
a4f3f1fef3 fix: use relative path for build instead of module name
Some checks failed
Release / build (push) Failing after 48s
2026-04-07 08:23:19 +08:00
9b0a5dcb4c fix: clone dev branch
Some checks failed
Release / build (push) Failing after 1m26s
2026-04-07 08:19:53 +08:00
15759ec42d fix: add go mod download before build
Some checks failed
Release / build (push) Failing after 46s
2026-04-07 08:18:28 +08:00
6fbf9a68cf fix: use module name instead of file path
Some checks failed
Release / build (push) Failing after 31s
2026-04-07 08:17:19 +08:00
cdf661734f fix: install git before clone
Some checks failed
Release / build (push) Failing after 21s
2026-04-07 08:15:35 +08:00
4b12c90e50 refactor: use golang:alpine container with direct clone
Some checks failed
Release / build (push) Failing after 2m30s
2026-04-07 08:12:22 +08:00
a3bc91bdaf fix: build path should point to main.go
Some checks failed
Release / build (push) Failing after 1m47s
2026-04-07 08:06:56 +08:00
1d12134314 fix: use curl to create release instead of external action
Some checks failed
Release / build (push) Failing after 5m9s
2026-04-07 08:00:28 +08:00
cd90a9c1b3 fix: use mirrored gitea-release-action
Some checks failed
Release / build (push) Failing after 19s
2026-04-07 07:58:38 +08:00
60c9f99525 fix: use gitea-release-action directly
Some checks failed
Release / build (push) Failing after 14s
2026-04-07 07:53:15 +08:00
aded7dba33 fix: use curl instead of external action
Some checks failed
Release / build (push) Has been cancelled
2026-04-07 07:51:56 +08:00
917002834c add: Gitea Actions workflow for release
Some checks failed
Release / build (push) Failing after 3m17s
2026-04-07 07:39:42 +08:00
b04092fd68 feat: 发布 v1.0.0-beta 版本
- 添加 ASCII 艺术 Logo 带渐变效果
- 改造输入框使用 ::: 紫色分隔符
- 改造信息栏合并显示语言/模型/记录数
- 添加 Spinner 翻译状态动画
- 优化翻译卡片样式
- 版本号三方同步规则
2026-04-07 07:12:00 +08:00
98f2c69151 fix: 应用 CardStyle marginBottom 2026-04-07 04:52:09 +08:00
217db90cfa feat: 升级到 lipgloss/bubbletea v2,实现翻译卡片组件
- 升级 charm.land/lipgloss/v2 v1.1.0 -> v2.0.2
- 升级 charm.land/bubbletea/v2 v1.3.10 -> v2.0.2
- 升级 charm.land/bubbles/v2 -> v2.1.0
- 新增翻译卡片组件:元信息行(Tokens/耗时/模型)、用户输入(碳黑背景)、翻译结果
- 卡片组件间距 5px
- 重构 model.go 适配 v2 API
- 更新 keys.go, messages.go, styles.go
2026-04-07 04:47:58 +08:00
18b191d10d fix: textarea - Enter翻译/Alt+Enter换行 2026-04-06 06:15:34 +08:00
1996e60567 fix: textarea - Enter翻译/Ctrl+Enter换行 2026-04-06 06:08:19 +08:00
3c45730751 fix: textarea - Enter翻译/Ctrl+J换行,禁用Enter自动换行 2026-04-06 06:05:20 +08:00
b35508e623 feat: textarea布局优化 - 全宽/自适应高度(最多7行)/深色背景/窗口尺寸响应 2026-04-06 05:50:11 +08:00
7539987877 fix: textarea输入框 - 隐藏行号/移除提示符/Ctrl+J换行 2026-04-06 05:43:40 +08:00
5fb0d5c58b feat: 模块7 - 多行输入支持 (textarea替换textinput) 2026-04-06 05:39:21 +08:00
17 changed files with 2064 additions and 451 deletions

View File

@@ -0,0 +1,60 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
env:
GOPROXY: "https://mirrors.aliyun.com/goproxy/,direct"
steps:
- name: Checkout
run: |
apk add git
git clone -b dev https://hub.gaomia.site/titor/yoyo.git /workspace/titor/yoyo/project
cp -r /workspace/titor/yoyo/project/* /workspace/titor/yoyo/
cp -r /workspace/titor/yoyo/project/.* /workspace/titor/yoyo/ 2>/dev/null || true
- name: Download dependencies
run: go mod download
- name: Build
run: |
for p in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
os=${p%/*}
arch=${p#*/}
ext=""
[ "$os" = "windows" ] && ext=".exe"
GOOS=$os GOARCH=$arch go build -buildvcs=false -o "yoo-${os}-${arch}${ext}" ./cmd/yoyo
done
- name: Checksums
run: sha256sum yoo-* > checksums.txt
- name: Release
env:
GITEA_TOKEN: ${{ secrets.release_token }}
run: |
apk add curl
TAG_NAME="${GITHUB_REF#refs/tags/}"
# 创建 Release 并获取 release_id
RELEASE_RESPONSE=$(curl -s -X POST "https://hub.gaomia.site/api/v1/repos/titor/yoyo/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"Automated release\"}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
# 上传附件
for f in yoo-* checksums.txt; do
[ -f "$f" ] && curl -s -X POST "https://hub.gaomia.site/api/v1/repos/titor/yoyo/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@$f"
done

4
.gitignore vendored
View File

@@ -5,6 +5,7 @@ yoyo
*.dll *.dll
*.so *.so
*.dylib *.dylib
yoo
# 测试 # 测试
*.test *.test
@@ -27,6 +28,7 @@ vendor/
# 构建输出 # 构建输出
dist/ dist/
build/ build/
project/
# Go工作区 # Go工作区
go.work go.work
@@ -34,4 +36,4 @@ go.work.sum
# 本地配置文件 # 本地配置文件
configs/local.yaml configs/local.yaml
configs/*.local.yaml configs/*.local.yaml

View File

@@ -36,17 +36,61 @@
| 步骤 | 模块 | 内容 | 状态 | | 步骤 | 模块 | 内容 | 状态 |
|------|------|------|------| |------|------|------|------|
| 1 | TUI框架搭建 | bubbletea基础App结构、运行循环 | ✅ 已完成 | | 1 | TUI框架搭建 | bubbletea基础App结构、运行循环 | ✅ 已完成 |
| 2 | 输入组件 | 文本输入框光标、基础编辑 | ✅ 已完成 | | 2 | 输入组件 | 文本输入框光标、基础编辑 | ✅ 已完成 |
| 3 | 翻译显示区 | 结果展示、格式化、滚动 | ✅ 已完成 | | 3 | 翻译显示区 | 结果展示、格式化、滚动 | ✅ 已完成 |
| 4 | 状态栏/主题 | 底部状态栏、语言选择、主题配色 | ✅ 已完成 | | 4 | 状态栏/主题 | 底部状态栏、语言选择、主题配色 | ✅ 已完成 |
| 5 | 快捷键系统 | 退出、清空、切换语言等 | ✅ 已完成 | | 5 | 快捷键系统 | 退出、清空、切换语言等 | ✅ 已完成 |
| 6 | 集成翻译 | 对接现有Translator、加载动画 | ✅ 已完成 | | 6 | 集成翻译 | 对接现有Translator、加载动画 | ✅ 已完成 |
## TUI界面重构计划 (v0.8.0) - 聊天风格
| 步骤 | 模块 | 内容 | 状态 |
|------|------|------|------|
| 1 | 模块结构拆分 | 创建 view/components/styles/keys 子目录 | ✅ 已完成 |
| 2 | 消息数据结构 | ChatMessage, ChatGroup 结构定义 | ✅ 已完成 |
| 3 | 消息列表组件 | 可滚动的消息历史展示 (viewport) | ✅ 已完成 |
| 4 | 原文+译文样式 | 区分显示用户输入和翻译结果 | ✅ 已完成 |
| 5 | 固定底部输入框 | textarea + Ctrl+J 换行 + 固定高度5行 | ✅ 已完成 |
| 6 | 状态栏 | 完整信息显示 | ✅ 已完成 |
| 7 | 翻译逻辑集成 | 对接 Translator | ✅ 已完成 |
| 8 | 输入框背景色 | 使用 FocusedStyle/BlurredStyle 设置背景 | ✅ 已完成 |
| 9 | 输入框修复 | 修复Ctrl+J换行后第一行被遮住的问题 | ✅ 已完成 |
## TUI界面改进计划 (v0.7.0)
| 步骤 | 模块 | 内容 | 状态 |
|------|------|------|------|
| 7 | 多行输入 | textarea组件替换textinput | ✅ 已完成 |
| 8 | 弹出框组件 | 通用modal组件 | ⏳ 待实现 |
| 9 | 斜杠命令菜单 | / 触发命令选择器,模糊匹配 | ⏳ 待实现 |
| 10 | 翻译结果滚动 | viewport组件支持长文本 | ⏳ 待实现 |
| 11 | 复制功能 | clipboard集成 | ⏳ 待实现 |
| 12 | 状态栏扩展 | 显示耗时、token用量 | ⏳ 待实现 |
## 待修复BUG ## 待修复BUG
- -
## 版本历史 ## 版本历史
### 0.7.0 (2026-04-06) - TUI界面改进
**类型**: 功能版本
**状态**: 开发中
**改进内容**:
- ✅ 模块7: 多行输入 - textarea组件替换textinput
- ✅ 模块7补充: 布局和样式优化 - 全宽/自适应高度/深色背景
- ⏳ 模块8: 弹出框组件 - 通用modal
- ⏳ 模块9: 斜杠命令菜单 - / 命令选择器
- ⏳ 模块10: 翻译结果滚动 - viewport
- ⏳ 模块11: 复制功能 - clipboard
- ⏳ 模块12: 状态栏扩展 - 耗时/token
**讨论记录**:
- [TUI界面改进计划](taolun.md#2026-04-06-1400-版本-070---tui界面改进计划)
**下一步**:
- 实现模块8: 弹出框组件
---
### 0.6.0 (2026-04-06) - TUI交互界面 ### 0.6.0 (2026-04-06) - TUI交互界面
**类型**: 功能版本 **类型**: 功能版本
**状态**: 已完成 **状态**: 已完成
@@ -69,9 +113,7 @@
- [TUI界面模块拆分计划](taolun.md#2026-04-06-1000-版本-060---tui界面模块拆分计划) - [TUI界面模块拆分计划](taolun.md#2026-04-06-1000-版本-060---tui界面模块拆分计划)
**下一步**: **下一步**:
- 测试TUI交互界面 - 实现模块7: 多行输入
- 优化用户体验
- 添加更多功能(如复制翻译结果)
--- ---
@@ -376,4 +418,84 @@ yoyo onboard --force
### 示例版本递增 ### 示例版本递增
- `0.0.1``0.0.2`:小修复 - `0.0.1``0.0.2`:小修复
- `0.0.99``0.1.0`:新功能(修订版本溢出) - `0.0.99``0.1.0`:新功能(修订版本溢出)
- `1.2.3``2.0.0`:重大架构变更 - `1.2.3``2.0.0`:重大架构变更
---
## v0.8.1 (2026-04-07)
### 新功能
- 使用 lipgloss v2 设计翻译结果卡片组件
- 卡片包含三部分元信息行Tokens/耗时/模型)、用户输入(碳黑背景)、翻译结果
### 升级
- 升级 `charm.land/lipgloss/v2` v1.1.0 → v2.0.2
- 升级 `charm.land/bubbletea/v2` v1.3.10 → v2.0.2
- 升级 `charm.land/bubbles/v2` → v2.1.0
- 更新所有模块路径为 `charm.land/xxx/v2` 格式
### 技术细节
- 背景色: #1A1A1A (碳黑色)
- 用户输入区域无边框,纯背景色
- 组件间距: 5px marginBottom
- View() 方法返回 `tea.View` 类型
- KeyMsg 改为 KeyPressMsg使用 `msg.String()` 判断键位
**讨论记录**: [taolun.md#版本-0.8.1-翻译结果卡片组件设计](taolun.md#版本-081---翻译结果卡片组件设计)
---
## v1.0.0-beta (2026-04-07)
### 新功能
- ASCII艺术Logo标题带紫色→粉色渐变效果
- 输入框改造:
- 使用 `:::` 紫色分隔符替代上下边框
- Ctrl+J 换行功能
- 信息栏改造:
- 合并显示:语言、模型名、缓存记录数
- 添加翻译状态 Spinner 动画 (MiniDot)
- 翻译结果卡片优化:
- 底部 `▣` 图标左侧边距从3减少到2
- `▣` 与文字间距从3减少到2
### 技术细节
- 使用 lipgloss 实现 True Color 渐变效果
- 使用 charmbracelet/bubbles spinner 组件实现加载动画
- 版本号显示在Logo右侧 [v1.0.0-beta]
- 动态调整 viewport 高度适应终端
### 版本号规则
- 版本号需与 git 标签、changelog.md 中的版本号保持三方同步
**讨论记录**: [taolun.md#版本-100-beta-Logo和信息栏改造](taolun.md#版本-100-beta-logo和信息栏改造)
---
## v1.1.0 (2026-04-07)
### BUG修复
- 修复配置文件路径使用相对路径导致管道模式下无法找到配置的问题
- 修复onboard配置保存到错误路径的问题
- 修复.env文件只在当前目录加载的问题
### 新功能
- 配置文件路径智能解析:`~/.config/yoyo/config.yaml``./configs/config.yaml`
- onboard配置向导迁移到 `charm.land/huh/v2`,替代 `survey`
- 新增路径解析工具 `internal/config/path.go`
### 改进
- 配置查找优先级:`--config` 参数 > `~/.config/yoyo/config.yaml` > `./configs/config.yaml`
- 配置文件统一保存到 `~/.config/yoyo/config.yaml`符合XDG规范
- .env文件统一从 `~/.config/yoyo/.env` 加载
- onboard使用huh的Form+Group模式更美观的交互体验
- 移除 `github.com/AlecAivazis/survey/v2` 依赖
### 技术细节
- 新增 `config.ResolveConfigPath()` 函数处理路径解析
- 新增 `config.GetUserConfigPath()` 返回标准配置路径
- 新增 `config.GetUserEnvPath()` 返回标准环境变量路径
- 支持 `~` 路径展开
- huh使用v2版本支持泛型和链式API
**讨论记录**: [taolun.md#2026-04-07-配置路径修复和huh迁移](taolun.md)

559
cmd/yoyo/main.go Normal file
View File

@@ -0,0 +1,559 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/joho/godotenv"
"github.com/titor/fanyi/internal/cache"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/lang"
"github.com/titor/fanyi/internal/onboard"
"github.com/titor/fanyi/internal/provider"
"github.com/titor/fanyi/internal/translator"
"github.com/titor/fanyi/internal/tui"
)
var (
// 命令行参数
version = flag.Bool("version", false, "显示版本信息")
help = flag.Bool("help", false, "显示帮助信息")
h = flag.Bool("h", false, "显示帮助信息")
langFlag = flag.String("lang", "", "目标语言代码(如 zh-CN, en-US, cn, en 等)")
langLong = flag.String("language", "", "目标语言代码(--lang的长格式")
configFile = flag.String("config", "", "配置文件路径")
providerFlag = flag.String("provider", "", "指定翻译厂商")
promptFlag = flag.String("prompt", "", "指定Prompt模式")
onboardFlag = flag.Bool("onboard", false, "启动交互式配置向导")
onboardForce = flag.Bool("onboard-force", false, "强制重新配置")
quietFlag = flag.Bool("quiet", false, "静默模式,不显示统计信息")
quietShort = flag.Bool("q", false, "静默模式,不显示统计信息(-q的短格式")
interactive = flag.Bool("interactive", false, "启动交互式翻译界面")
interactiveShort = flag.Bool("i", false, "启动交互式翻译界面(-i的短格式")
)
const versionString = "YOYO翻译工具 v1.1.0"
// isPipeInput 检测是否有管道输入
func isPipeInput() bool {
fileInfo, err := os.Stdin.Stat()
if err != nil {
return false
}
// 检查是否是管道(字符设备且不是普通文件)
return (fileInfo.Mode() & os.ModeCharDevice) == 0
}
// readFromStdin 从标准输入读取所有内容
func readFromStdin() (string, error) {
scanner := bufio.NewScanner(os.Stdin)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("读取标准输入失败: %w", err)
}
return strings.Join(lines, "\n"), nil
}
// isQuiet 检查是否处于静默模式
func isQuiet() bool {
return *quietFlag || *quietShort
}
// isTTY 检查是否在TTY环境中
func isTTY() bool {
// 检查标准输出是否是TTY
if fi, err := os.Stdout.Stat(); err == nil {
if (fi.Mode() & os.ModeCharDevice) != 0 {
return true
}
}
// 检查标准错误是否是TTY
if fi, err := os.Stderr.Stat(); err == nil {
if (fi.Mode() & os.ModeCharDevice) != 0 {
return true
}
}
// 检查标准输入是否是TTY
if fi, err := os.Stdin.Stat(); err == nil {
if (fi.Mode() & os.ModeCharDevice) != 0 {
return true
}
}
return false
}
func main() {
// 解析命令行参数
flag.Parse()
// 处理版本和帮助
if *version {
printVersion()
return
}
// 处理交互式模式
if *interactive || *interactiveShort || shouldStartInteractive() {
startInteractiveMode()
return
}
// 处理管道输入情况
if isPipeInput() {
// 管道模式下,即使没有参数也继续执行
if *help || *h {
printHelp()
return
}
} else {
// 非管道模式下,没有参数则显示帮助
if *help || *h || flag.NArg() == 0 {
printHelp()
return
}
}
// 获取第一个参数作为命令或文本
firstArg := flag.Arg(0)
// 处理子命令
if firstArg == "onboard" {
force := false
// 检查是否有--force参数
for _, arg := range os.Args[1:] {
if arg == "--force" || arg == "-f" {
force = true
break
}
}
runOnboard(force)
return
}
// 处理缓存命令
if firstArg == "cache" {
if flag.NArg() < 2 {
fmt.Fprintln(os.Stderr, "错误: cache命令需要子命令")
fmt.Fprintln(os.Stderr, "可用子命令: clear, stats, cleanup")
os.Exit(1)
}
cacheSubcommand := flag.Arg(1)
runCacheCommand(cacheSubcommand)
return
}
// 创建上下文支持Ctrl+C中断
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 处理中断信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Println("\n收到中断信号正在退出...")
cancel()
}()
// 获取要翻译的文本
var text string
var err error
if isPipeInput() {
// 从stdin读取
text, err = readFromStdin()
if err != nil {
fmt.Fprintf(os.Stderr, "读取管道输入失败: %v\n", err)
os.Exit(1)
}
// 如果没有输入内容,显示错误
if strings.TrimSpace(text) == "" {
fmt.Fprintln(os.Stderr, "错误: 管道输入为空")
os.Exit(1)
}
} else {
// 从命令行参数读取
text = firstArg
if text == "" {
printHelp()
os.Exit(1)
}
}
// 加载环境变量文件
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath, err := config.ResolveConfigPath(*configFile)
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
fmt.Println("提示: 运行 'yoyo onboard' 进行配置")
os.Exit(1)
}
// 解析语言参数
targetLang := parseLanguageFlag()
if targetLang == "" {
targetLang = cfg.DefaultTargetLang
}
// 解析Prompt参数
promptName := *promptFlag
if promptName == "" {
promptName = "simple"
}
// 获取厂商配置
providerName := *providerFlag
if providerName == "" {
providerName = cfg.DefaultProvider
}
providerConfig, err := cfg.GetProviderConfig(providerName)
if err != nil {
fmt.Printf("获取厂商配置失败: %v\n", err)
os.Exit(1)
}
// 创建厂商实例
providerInstance, err := provider.CreateProvider(providerName, provider.ProviderConfig{
APIHost: providerConfig.APIHost,
APIKey: providerConfig.APIKey,
Model: providerConfig.Model,
})
if err != nil {
fmt.Printf("创建厂商实例失败: %v\n", err)
os.Exit(1)
}
// 创建翻译器
translatorInstance := translator.NewTranslator(cfg, providerInstance)
// 设置翻译选项
options := &translator.TranslateOptions{
ToLang: targetLang,
PromptName: promptName,
}
// 执行翻译
result, err := translatorInstance.Translate(ctx, text, options)
if err != nil {
fmt.Printf("翻译失败: %v\n", err)
os.Exit(1)
}
// 输出结果
fmt.Println(result.Translated)
// 根据quiet参数决定是否显示统计信息
if !isQuiet() && result.Usage != nil {
fmt.Fprintf(os.Stderr, "\n--- 用量统计 ---\n")
fmt.Fprintf(os.Stderr, "模型: %s\n", result.Model)
fmt.Fprintf(os.Stderr, "提示词: %d tokens\n", result.Usage.PromptTokens)
fmt.Fprintf(os.Stderr, "完成: %d tokens\n", result.Usage.CompletionTokens)
fmt.Fprintf(os.Stderr, "总计: %d tokens\n", result.Usage.TotalTokens)
}
}
// parseLanguageFlag 解析语言参数
func parseLanguageFlag() string {
// 优先使用--lang参数然后是--language参数
language := *langFlag
if language == "" {
language = *langLong
}
// 如果没有指定语言参数,返回空字符串(将使用配置文件中的默认值)
if language == "" {
return ""
}
// 使用语言解析模块处理语言代码
return lang.ParseLanguageCode(language)
}
// runOnboard 运行配置向导
func runOnboard(force bool) {
if err := onboard.RunOnboard(force); err != nil {
fmt.Printf("配置向导失败: %v\n", err)
os.Exit(1)
}
}
// runCacheCommand 运行缓存命令
func runCacheCommand(subcommand string) {
// 加载环境变量文件
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath, err := config.ResolveConfigPath("")
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
os.Exit(1)
}
// 创建缓存实例
if !cfg.Cache.Enabled {
fmt.Fprintln(os.Stderr, "缓存功能未启用")
os.Exit(1)
}
cacheConfig := &cache.CacheConfig{
Enabled: cfg.Cache.Enabled,
MaxRecords: cfg.Cache.MaxRecords,
ExpireDays: cfg.Cache.ExpireDays,
DBPath: cfg.Cache.DBPath,
}
cacheInstance, err := cache.NewSQLiteCache(cacheConfig)
if err != nil {
fmt.Printf("创建缓存实例失败: %v\n", err)
os.Exit(1)
}
defer cacheInstance.Close()
cleanupManager := cache.NewCleanupManager(cacheInstance)
ctx := context.Background()
switch subcommand {
case "clear":
if err := cleanupManager.ClearAll(ctx); err != nil {
fmt.Printf("清空缓存失败: %v\n", err)
os.Exit(1)
}
fmt.Println("缓存已清空")
case "stats":
stats, err := cleanupManager.GetStats(ctx)
if err != nil {
fmt.Printf("获取缓存统计失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("缓存统计:\n")
fmt.Printf(" 总记录数: %d\n", stats.TotalRecords)
fmt.Printf(" 总大小: %.2f MB\n", float64(stats.TotalSizeBytes)/1024/1024)
fmt.Printf(" 最早记录: %v\n", stats.OldestRecord)
fmt.Printf(" 最新记录: %v\n", stats.NewestRecord)
fmt.Printf(" 平均tokens/记录: %.2f\n", stats.AvgTokensPerRecord)
case "cleanup":
if err := cleanupManager.CleanupManual(ctx); err != nil {
fmt.Printf("清理缓存失败: %v\n", err)
os.Exit(1)
}
fmt.Println("缓存清理完成")
default:
fmt.Fprintf(os.Stderr, "未知的缓存子命令: %s\n", subcommand)
fmt.Fprintln(os.Stderr, "可用子命令: clear, stats, cleanup")
os.Exit(1)
}
}
// printVersion 显示版本信息
func printVersion() {
fmt.Println(versionString)
}
// printHelp 显示帮助信息
func printHelp() {
fmt.Printf(`%s
使用方法:
yoyo [选项] <文本>
yoyo onboard [选项]
基本翻译:
yoyo "Hello world" # 翻译为中文(默认)
yoyo --lang=cn "Hello world" # 指定翻译为简体中文
yoyo --lang=en "你好" # 翻译为英文
yoyo --lang=zh-TW "Hello world" # 翻译为繁体中文
管道使用:
cat file.txt | yoyo # 翻译文件内容
cat file.txt | yoyo --lang=en # 翻译为英文
cat file.txt | yoyo -q # 静默模式,只输出翻译结果
echo "Hello" | yoyo --lang=cn # 翻译命令输出
yoyo "Hello" | grep "你好" # 与其他命令组合使用
语言代码支持:
- 标准格式: zh-CN, zh-TW, en-US, en-GB, ja, ko, es, fr, de 等
- 简短别名: cn(中文), en(英文), jp(日文), kr(韩文) 等
- 中文名称: chinese(中文), english(英文), japanese(日文) 等
命令:
onboard 启动交互式配置向导
onboard --force 强制重新配置
cache clear 清空翻译缓存
cache stats 查看缓存统计信息
cache cleanup 清理过期缓存
翻译选项:
--lang=<语言代码> 指定目标语言
--language=<语言代码> 指定目标语言(长格式)
--config=<路径> 指定配置文件路径
--provider=<厂商> 指定翻译厂商
--prompt=<模式> 指定Prompt模式
--quiet, -q 静默模式,不显示统计信息
--interactive, -i 启动交互式翻译界面
通用选项:
-h, --help 显示帮助信息
--version 显示版本信息
示例:
yoyo "Hello world"
yoyo --lang=cn "Hello world"
yoyo --lang=en "你好世界"
yoyo --lang=jp "Hello world"
yoyo --lang=ko "Hello world"
yoyo --provider=siliconflow --lang=cn "Hello"
yoyo --prompt=technical --lang=zh-CN "API documentation"
echo "Hello" | yoyo --lang=cn -q
cat file.txt | yoyo --lang=en
yoyo onboard
yoyo onboard --force
yoyo cache clear # 清空翻译缓存
yoyo cache stats # 查看缓存统计信息
yoyo cache cleanup # 清理过期缓存
yoyo --interactive # 启动交互式翻译界面
yoyo -i # 启动交互式翻译界面(短格式)
配置:
- 配置文件: ~/.config/yoyo/config.yaml
- 环境变量: ~/.config/yoyo/.env
- 默认厂商: siliconflow
- 默认目标语言: zh-CN (简体中文)
更多信息请访问: https://github.com/titor/fanyi
`, versionString)
}
// shouldStartInteractive 判断是否应该启动交互式模式
func shouldStartInteractive() bool {
// 如果没有参数,且不是管道输入,则启动交互模式
if flag.NArg() == 0 && !isPipeInput() {
return true
}
return false
}
// startInteractiveMode 启动交互式模式
func startInteractiveMode() {
// 检查是否在TTY环境中
if !isTTY() {
fmt.Fprintln(os.Stderr, "错误: 交互式模式需要在TTY环境中运行")
fmt.Fprintln(os.Stderr, "请直接使用传统模式: yoyo \"要翻译的文本\"")
os.Exit(1)
}
// 加载环境变量文件
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath, err := config.ResolveConfigPath(*configFile)
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
fmt.Println("提示: 运行 'yoyo onboard' 进行配置")
fmt.Println("提示: 使用默认配置启动交互模式...")
// 使用默认配置
cfg = &config.Config{
DefaultProvider: "siliconflow",
DefaultModel: "gpt-3.5-turbo",
Timeout: 30,
DefaultTargetLang: "zh-CN",
Providers: make(map[string]config.ProviderConfig),
Prompts: make(map[string]string),
}
}
// 解析语言参数
targetLang := parseLanguageFlag()
if targetLang == "" {
targetLang = cfg.DefaultTargetLang
}
// 解析Prompt参数
promptName := *promptFlag
if promptName == "" {
promptName = "simple"
}
// 获取厂商配置
providerName := *providerFlag
if providerName == "" {
providerName = cfg.DefaultProvider
}
providerConfig, err := cfg.GetProviderConfig(providerName)
if err != nil {
fmt.Printf("获取厂商配置失败: %v\n", err)
fmt.Println("使用默认厂商配置...")
// 使用默认配置
providerConfig = config.ProviderConfig{
APIHost: "https://api.siliconflow.cn/v1",
APIKey: "",
Model: "gpt-3.5-turbo",
Enabled: true,
}
}
// 创建厂商实例
providerInstance, err := provider.CreateProvider(providerName, provider.ProviderConfig{
APIHost: providerConfig.APIHost,
APIKey: providerConfig.APIKey,
Model: providerConfig.Model,
})
if err != nil {
fmt.Printf("创建厂商实例失败: %v\n", err)
fmt.Println("使用模拟翻译功能...")
// 这里可以创建一个模拟厂商实例
// 暂时直接返回错误
fmt.Println("交互式模式需要有效的厂商配置")
return
}
// 创建翻译器
translatorInstance := translator.NewTranslator(cfg, providerInstance)
// 启动TUI界面
fmt.Println("正在启动交互式翻译界面...")
fmt.Println("使用 Ctrl+C 退出")
// 创建并运行TUI应用程序
app := tui.NewApp(cfg, translatorInstance)
if _, err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "运行TUI界面失败: %v\n", err)
os.Exit(1)
}
}

51
go.mod
View File

@@ -3,38 +3,37 @@ module github.com/titor/fanyi
go 1.26.1 go 1.26.1
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
github.com/go-enry/go-enry/v2 v2.9.5
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.37
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/go-enry/go-enry/v2 v2.9.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

147
go.sum
View File

@@ -1,125 +1,90 @@
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-enry/go-enry/v2 v2.9.5 h1:HPhAQQHYwJgihL2PxBZiUMFWiROsGwOBdB6/D8zCUhY= github.com/go-enry/go-enry/v2 v2.9.5 h1:HPhAQQHYwJgihL2PxBZiUMFWiROsGwOBdB6/D8zCUhY=
github.com/go-enry/go-enry/v2 v2.9.5/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-enry/v2 v2.9.5/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

76
internal/config/path.go Normal file
View File

@@ -0,0 +1,76 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// ConfigDirName 配置目录名称
ConfigDirName = "yoyo"
// ConfigFileName 配置文件名
ConfigFileName = "config.yaml"
// EnvFileName 环境变量文件名
EnvFileName = ".env"
)
// ResolveConfigPath 解析配置文件路径
// 优先级: 用户指定路径 > ~/.config/yoyo/config.yaml > ./configs/config.yaml
func ResolveConfigPath(userPath string) (string, error) {
// 1. 用户通过 --config 指定的路径
if userPath != "" {
return expandPath(userPath)
}
// 2. 标准用户配置目录 ~/.config/yoyo/config.yaml
userConfigPath := GetUserConfigPath()
if _, err := os.Stat(userConfigPath); err == nil {
return userConfigPath, nil
}
// 3. 项目本地配置 ./configs/config.yaml向后兼容
localConfigPath := "configs/config.yaml"
if _, err := os.Stat(localConfigPath); err == nil {
return localConfigPath, nil
}
// 4. 都不存在返回标准路径onboard 会创建)
return userConfigPath, nil
}
// GetUserConfigDir 获取用户配置目录路径
// 返回 ~/.config/yoyo
func GetUserConfigDir() string {
home, err := os.UserHomeDir()
if err != nil {
// 降级到当前目录
return ".config/" + ConfigDirName
}
return filepath.Join(home, ".config", ConfigDirName)
}
// GetUserConfigPath 获取用户配置文件路径
// 返回 ~/.config/yoyo/config.yaml
func GetUserConfigPath() string {
return filepath.Join(GetUserConfigDir(), ConfigFileName)
}
// GetUserEnvPath 获取用户环境变量文件路径
// 返回 ~/.config/yoyo/.env
func GetUserEnvPath() string {
return filepath.Join(GetUserConfigDir(), EnvFileName)
}
// expandPath 展开路径中的 ~ 符号
func expandPath(path string) (string, error) {
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("无法获取用户主目录: %w", err)
}
path = filepath.Join(home, path[1:])
}
return path, nil
}

View File

@@ -5,7 +5,7 @@ import (
) )
const ( const (
Version = "1.0.0" Version = "1.0.0-beta"
) )
func DetectLanguage(text string) string { func DetectLanguage(text string) string {

View File

@@ -1,30 +1,39 @@
package onboard package onboard
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2" "charm.land/huh/v2"
"github.com/titor/fanyi/internal/config" "github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/lang" "github.com/titor/fanyi/internal/lang"
) )
// RunOnboard 启动配置向导 // RunOnboard 启动配置向导
func RunOnboard(force bool) error { func RunOnboard(force bool) error {
fmt.Println("欢迎使用YOYO翻译工具配置向导!") configPath := config.GetUserConfigPath()
fmt.Println("这个向导将帮助您配置翻译工具。")
fmt.Println()
// 检查配置文件是否存在 // 检查配置文件是否存在
configPath := "configs/config.yaml"
if _, err := os.Stat(configPath); err == nil && !force { if _, err := os.Stat(configPath); err == nil && !force {
overwrite := false var overwrite bool
prompt := &survey.Confirm{ form := huh.NewForm(
Message: "检测到配置文件已存在,是否要重新配置?", huh.NewGroup(
Default: false, huh.NewConfirm().
} Title("检测到配置文件已存在,是否要重新配置?").
if err := survey.AskOne(prompt, &overwrite); err != nil { Affirmative("是").
Negative("否").
Value(&overwrite),
),
)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("用户输入错误: %w", err) return fmt.Errorf("用户输入错误: %w", err)
} }
if !overwrite { if !overwrite {
@@ -34,30 +43,75 @@ func RunOnboard(force bool) error {
} }
// 步骤1: 选择主要厂商 // 步骤1: 选择主要厂商
fmt.Println("步骤1: 选择主要翻译服务提供商") var providerName string
providerName, err := SelectProvider() providerForm := huh.NewForm(
if err != nil { huh.NewGroup(
huh.NewSelect[string]().
Title("请选择要使用的翻译服务提供商").
Options(
huh.NewOption("硅基流动 (推荐,免费额度)", "siliconflow"),
huh.NewOption("火山引擎", "volcano"),
huh.NewOption("国家超算", "national"),
huh.NewOption("Qwen (通义千问)", "qwen"),
huh.NewOption("OpenAI兼容格式", "openai"),
).
Value(&providerName),
),
)
if err := providerForm.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("选择厂商失败: %w", err) return fmt.Errorf("选择厂商失败: %w", err)
} }
// 步骤2: 配置主要厂商 // 步骤2: 配置主要厂商
fmt.Println("\n步骤2: 配置主要厂商") providerConfig, err := ConfigureProviderHuh(providerName)
providerConfig, err := ConfigureProvider(providerName)
if err != nil { if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("配置厂商失败: %w", err) return fmt.Errorf("配置厂商失败: %w", err)
} }
// 步骤3: 全局设置 // 步骤3: 全局设置
fmt.Println("\n步骤3: 全局设置") globalConfig, err := GlobalSettingsHuh()
globalConfig, err := GlobalSettings()
if err != nil { if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("全局设置失败: %w", err) return fmt.Errorf("全局设置失败: %w", err)
} }
// 步骤4: 确认并保存配置 // 步骤4: 确认并保存配置
fmt.Println("\n步骤4: 保存配置")
configData := BuildConfig(providerName, providerConfig, globalConfig) configData := BuildConfig(providerName, providerConfig, globalConfig)
var confirmSave bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("确认保存配置?").
Description(fmt.Sprintf("配置文件将保存到: %s", configPath)).
Affirmative("是,保存").
Negative("否,取消").
Value(&confirmSave),
),
)
if err := confirmForm.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("用户输入错误: %w", err)
}
if !confirmSave {
fmt.Println("配置已取消。")
return nil
}
if err := SaveConfig(configData, configPath); err != nil { if err := SaveConfig(configData, configPath); err != nil {
return fmt.Errorf("保存配置失败: %w", err) return fmt.Errorf("保存配置失败: %w", err)
} }
@@ -71,54 +125,17 @@ func RunOnboard(force bool) error {
return nil return nil
} }
// SelectProvider 选择主要厂商 // GlobalConfig 全局设置配置
func SelectProvider() (string, error) { type GlobalConfig struct {
providers := []string{ DefaultProvider string
"siliconflow", DefaultModel string
"volcano", Timeout int
"national", DefaultSourceLang string
"qwen", DefaultTargetLang string
"openai",
}
providerNames := map[string]string{
"siliconflow": "硅基流动 (推荐,免费额度)",
"volcano": "火山引擎",
"national": "国家超算",
"qwen": "Qwen (通义千问)",
"openai": "OpenAI兼容格式",
}
var selected string
prompt := &survey.Select{
Message: "请选择要使用的翻译服务提供商:",
Options: func() []string {
var opts []string
for _, p := range providers {
opts = append(opts, providerNames[p])
}
return opts
}(),
Default: providerNames["siliconflow"],
}
if err := survey.AskOne(prompt, &selected); err != nil {
return "", err
}
// 返回对应的厂商名称
for name, displayName := range providerNames {
if displayName == selected {
return name, nil
}
}
return "siliconflow", nil
} }
// ConfigureProvider 配置厂商 // ConfigureProviderHuh 使用 huh 配置厂商
func ConfigureProvider(providerName string) (config.ProviderConfig, error) { func ConfigureProviderHuh(providerName string) (config.ProviderConfig, error) {
// 厂商默认配置
defaults := map[string]config.ProviderConfig{ defaults := map[string]config.ProviderConfig{
"siliconflow": { "siliconflow": {
APIHost: "https://api.siliconflow.cn/v1", APIHost: "https://api.siliconflow.cn/v1",
@@ -154,47 +171,43 @@ func ConfigureProvider(providerName string) (config.ProviderConfig, error) {
Enabled: defaultConfig.Enabled, Enabled: defaultConfig.Enabled,
} }
// 输入API密钥 var apiKey string
apiKeyPrompt := &survey.Input{ apiKeyForm := huh.NewForm(
Message: fmt.Sprintf("请输入 %s 的API密钥:", providerName), huh.NewGroup(
Help: "API密钥用于身份验证将存储在配置文件中", huh.NewInput().
} Title(fmt.Sprintf("请输入 %s 的API密钥", providerName)).
if err := survey.AskOne(apiKeyPrompt, &cfg.APIKey, survey.WithValidator(survey.Required)); err != nil { Description("API密钥用于身份验证将存储在配置文件中").
return config.ProviderConfig{}, err Value(&apiKey).
} Validate(func(str string) error {
if strings.TrimSpace(str) == "" {
// 确认API HOST return fmt.Errorf("API密钥不能为空")
apiHostPrompt := &survey.Input{ }
Message: "API HOST (直接回车使用默认值):", return nil
Default: cfg.APIHost, }),
}
if err := survey.AskOne(apiHostPrompt, &cfg.APIHost); err != nil { huh.NewInput().
return config.ProviderConfig{}, err Title("API HOST").
} Description("直接回车使用默认值").
Value(&cfg.APIHost).
// 确认默认模型 Placeholder(defaultConfig.APIHost),
modelPrompt := &survey.Input{
Message: "默认模型 (直接回车使用默认值):", huh.NewInput().
Default: cfg.Model, Title("默认模型").
} Description("直接回车使用默认值").
if err := survey.AskOne(modelPrompt, &cfg.Model); err != nil { Value(&cfg.Model).
Placeholder(defaultConfig.Model),
),
)
if err := apiKeyForm.Run(); err != nil {
return config.ProviderConfig{}, err return config.ProviderConfig{}, err
} }
cfg.APIKey = apiKey
return cfg, nil return cfg, nil
} }
// GlobalSettings 全局设置 // GlobalSettingsHuh 使用 huh 进行全局设置
type GlobalConfig struct { func GlobalSettingsHuh() (*GlobalConfig, error) {
DefaultProvider string
DefaultModel string
Timeout int
DefaultSourceLang string
DefaultTargetLang string
}
// GlobalSettings 全局设置
func GlobalSettings() (*GlobalConfig, error) {
cfg := &GlobalConfig{ cfg := &GlobalConfig{
DefaultProvider: "siliconflow", DefaultProvider: "siliconflow",
DefaultModel: "siliconflow-base", DefaultModel: "siliconflow-base",
@@ -203,43 +216,33 @@ func GlobalSettings() (*GlobalConfig, error) {
DefaultTargetLang: "zh-CN", DefaultTargetLang: "zh-CN",
} }
// 选择默认语言
targetLangOptions := lang.GetCommonLanguages() targetLangOptions := lang.GetCommonLanguages()
var targetLangDisplay []string var options []huh.Option[string]
for _, code := range targetLangOptions { for _, code := range targetLangOptions {
targetLangDisplay = append(targetLangDisplay, fmt.Sprintf("%s (%s)", code, lang.GetLanguageName(code))) options = append(options, huh.NewOption(
fmt.Sprintf("%s (%s)", code, lang.GetLanguageName(code)),
code,
))
} }
targetLangPrompt := &survey.Select{
Message: "请选择默认目标语言:",
Options: targetLangDisplay,
Default: fmt.Sprintf("%s (%s)", "zh-CN", lang.GetLanguageName("zh-CN")),
}
var selectedTarget string
if err := survey.AskOne(targetLangPrompt, &selectedTarget); err != nil {
return nil, err
}
// 从选择中提取语言代码
for i, display := range targetLangDisplay {
if display == selectedTarget {
cfg.DefaultTargetLang = targetLangOptions[i]
break
}
}
// 设置超时时间
timeoutPrompt := &survey.Input{
Message: "API超时时间(秒):",
Default: fmt.Sprintf("%d", cfg.Timeout),
}
var timeoutStr string var timeoutStr string
if err := survey.AskOne(timeoutPrompt, &timeoutStr); err != nil { form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("请选择默认目标语言").
Options(options...).
Value(&cfg.DefaultTargetLang),
huh.NewInput().
Title("API超时时间(秒)").
Value(&timeoutStr).
Placeholder("30"),
),
)
if err := form.Run(); err != nil {
return nil, err return nil, err
} }
// 解析超时时间
if timeout := parseIntOrDefault(timeoutStr, 30); timeout > 0 { if timeout := parseIntOrDefault(timeoutStr, 30); timeout > 0 {
cfg.Timeout = timeout cfg.Timeout = timeout
} }
@@ -249,12 +252,10 @@ func GlobalSettings() (*GlobalConfig, error) {
// BuildConfig 构建配置对象 // BuildConfig 构建配置对象
func BuildConfig(providerName string, providerConfig config.ProviderConfig, globalConfig *GlobalConfig) *config.Config { func BuildConfig(providerName string, providerConfig config.ProviderConfig, globalConfig *GlobalConfig) *config.Config {
// 创建厂商配置
providers := map[string]config.ProviderConfig{ providers := map[string]config.ProviderConfig{
providerName: providerConfig, providerName: providerConfig,
} }
// 创建Prompt配置
prompts := map[string]string{ prompts := map[string]string{
"technical": "你是一位专业的技术翻译,请准确翻译以下技术文档,保持专业术语的准确性。", "technical": "你是一位专业的技术翻译,请准确翻译以下技术文档,保持专业术语的准确性。",
"creative": "你是一位富有创造力的翻译家,请用优美流畅的语言翻译以下内容。", "creative": "你是一位富有创造力的翻译家,请用优美流畅的语言翻译以下内容。",
@@ -275,25 +276,24 @@ func BuildConfig(providerName string, providerConfig config.ProviderConfig, glob
// SaveConfig 保存配置文件 // SaveConfig 保存配置文件
func SaveConfig(cfg *config.Config, path string) error { func SaveConfig(cfg *config.Config, path string) error {
// 确保目录存在
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err) return fmt.Errorf("创建配置目录失败: %w", err)
} }
// 使用config包的Save方法
loader := &config.YAMLConfigLoader{} loader := &config.YAMLConfigLoader{}
return loader.Save(cfg, path) return loader.Save(cfg, path)
} }
// parseIntOrDefault 解析整数,失败时返回默认值 // parseIntOrDefault 解析整数,失败时返回默认值
func parseIntOrDefault(s string, defaultValue int) int { func parseIntOrDefault(s string, defaultValue int) int {
s = strings.TrimSpace(s)
if s == "" { if s == "" {
return defaultValue return defaultValue
} }
var result int result, err := strconv.Atoi(s)
if _, err := fmt.Sscanf(s, "%d", &result); err != nil { if err != nil {
return defaultValue return defaultValue
} }

View File

@@ -20,6 +20,10 @@ type Translator struct {
cache cache.Cache cache cache.Cache
} }
func (t *Translator) GetCache() cache.Cache {
return t.cache
}
// NewTranslator 创建翻译器实例 // NewTranslator 创建翻译器实例
func NewTranslator(config *config.Config, provider provider.Provider) *Translator { func NewTranslator(config *config.Config, provider provider.Provider) *Translator {
translator := &Translator{ translator := &Translator{

View File

@@ -1,191 +0,0 @@
package tui
import (
"context"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/translator"
)
type model struct {
config *config.Config
translator *translator.Translator
textInput textinput.Model
result string
errMsg string
targetLang string
langIndex int
loading bool
}
type translateMsg struct {
result string
err error
}
var (
headerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D9FF")).
Bold(true)
dividerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D9FF"))
inputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#1A1A2E"))
resultStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#98FB98")).
Background(lipgloss.Color("#0D1B2A"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF6B6B")).
Background(lipgloss.Color("#1A1A2E"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
statusBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#1F2937")).
Width(60)
langStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FBBF24")).
Bold(true)
keyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA"))
loadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA"))
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program {
targetLang := "zh-CN"
if cfg != nil && cfg.DefaultTargetLang != "" {
targetLang = cfg.DefaultTargetLang
}
ti := textinput.New()
ti.Placeholder = "输入要翻译的文本..."
ti.Focus()
ti.Prompt = "> "
ti.TextStyle = inputStyle
return tea.NewProgram(model{
config: cfg,
translator: t,
textInput: ti,
targetLang: targetLang,
})
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case translateMsg:
m.loading = false
if msg.err != nil {
m.errMsg = msg.err.Error()
m.result = ""
} else {
m.result = msg.result
m.errMsg = ""
}
return m, nil
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
if m.loading {
return m, nil
}
text := m.textInput.Value()
if text == "" {
return m, nil
}
m.loading = true
m.errMsg = ""
return m, m.doTranslate(text, m.targetLang)
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyCtrlL:
m.textInput.SetValue("")
m.result = ""
m.errMsg = ""
return m, nil
case tea.KeyCtrlT:
m.langIndex = (m.langIndex + 1) % len(supportedLangs)
m.targetLang = supportedLangs[m.langIndex]
return m, nil
case tea.KeyEsc:
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) doTranslate(text, toLang string) tea.Cmd {
return func() tea.Msg {
result, err := m.translator.Translate(
context.Background(),
text,
&translator.TranslateOptions{
ToLang: toLang,
PromptName: "simple",
},
)
if err != nil {
return translateMsg{err: err}
}
return translateMsg{result: result.Translated}
}
}
func (m model) View() string {
resultBox := m.renderResult()
helpText := helpStyle.Render("\n " +
keyStyle.Render("Ctrl+L") + " 清空 " +
keyStyle.Render("Ctrl+T") + " 切换语言 " +
keyStyle.Render("Enter") + " 翻译 " +
keyStyle.Render("Ctrl+C") + " 退出")
return "\n" +
" " + headerStyle.Render("YOYO翻译") + "\n" +
" " + dividerStyle.Render("─────────────────────") + "\n\n" +
" " + m.textInput.View() + "\n\n" +
resultBox +
helpText +
"\n" +
m.renderStatusBar()
}
func (m model) renderResult() string {
if m.loading {
return " " + loadingStyle.Render("正在翻译...") + "\n"
}
if m.errMsg != "" {
return " " + errorStyle.Render("错误: "+m.errMsg) + "\n"
}
if m.result == "" {
return " " + helpStyle.Render("翻译结果将显示在这里...") + "\n"
}
return " " + resultStyle.Render(m.result) + "\n"
}
func (m model) renderStatusBar() string {
divider := dividerStyle.Render("─")
langInfo := langStyle.Render("目标: " + m.targetLang)
return "\n " + divider + "\n" +
" " + statusBarStyle.Render(" "+langInfo+" ")
}

48
internal/tui/keys.go Normal file
View File

@@ -0,0 +1,48 @@
package tui
import (
"charm.land/bubbles/v2/key"
)
type KeyMap struct {
Quit key.Binding
Clear key.Binding
SwitchLang key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
ScrollTop key.Binding
ScrollBottom key.Binding
}
func NewKeyMap() KeyMap {
return KeyMap{
Quit: key.NewBinding(
key.WithKeys("ctrl+c", "esc"),
key.WithHelp("Ctrl+C", "退出"),
),
Clear: key.NewBinding(
key.WithKeys("ctrl+l"),
key.WithHelp("Ctrl+L", "清空输入"),
),
SwitchLang: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("Ctrl+T", "切换语言"),
),
ScrollUp: key.NewBinding(
key.WithKeys("up", "ctrl+up"),
key.WithHelp("↑/Ctrl+↑", "上滚"),
),
ScrollDown: key.NewBinding(
key.WithKeys("down", "ctrl+down"),
key.WithHelp("↓/Ctrl+↓", "下滚"),
),
ScrollTop: key.NewBinding(
key.WithKeys("home"),
key.WithHelp("Home", "顶部"),
),
ScrollBottom: key.NewBinding(
key.WithKeys("end"),
key.WithHelp("End", "底部"),
),
}
}

21
internal/tui/messages.go Normal file
View File

@@ -0,0 +1,21 @@
package tui
import "time"
type ChatMessage struct {
ID string
Input string
Output string
FromLang string
ToLang string
Model string
Tokens int
Timestamp time.Time
Error string
}
type ChatHistory struct {
Messages []ChatMessage
scrollPos int
totalLines int
}

418
internal/tui/model.go Normal file
View File

@@ -0,0 +1,418 @@
package tui
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/content"
"github.com/titor/fanyi/internal/translator"
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
var logoPattern = "l_ _ _____ _____ " + "\n" +
"( \\/ ( _ ( _ ) " + "\n" +
" \\ / )(_)( )(_)( " + "\n" +
" (__)(_____(_____((() [v" + content.Version + "]"
type translateMsg struct {
result string
tokens int
err error
}
type model struct {
config *config.Config
translator *translator.Translator
messages []ChatMessage
input textarea.Model
viewport viewport.Model
spinner spinner.Model
keys KeyMap
targetLang string
langIndex int
loading bool
lastInput string
width int
height int
inputHeight int
}
func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program {
keys := NewKeyMap()
ta := textarea.New()
ta.Placeholder = "在这里输入你要翻译的内容。Enter 翻译Ctrl+J 启用换行)"
ta.Focus()
ta.Prompt = ""
ta.ShowLineNumbers = false
ta.SetWidth(60)
ta.SetHeight(5)
ta.SetStyles(textarea.DefaultStyles(true))
ta.KeyMap.InsertNewline.SetKeys("ctrl+j")
ta.KeyMap.InsertNewline.SetEnabled(true)
vp := viewport.New(viewport.WithWidth(50), viewport.WithHeight(20))
vp.SetContent("")
sp := spinner.New()
sp.Spinner = spinner.MiniDot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6"))
return tea.NewProgram(model{
config: cfg,
translator: t,
messages: make([]ChatMessage, 0),
input: ta,
viewport: vp,
spinner: sp,
keys: keys,
targetLang: getDefaultLang(cfg),
})
}
func getDefaultLang(cfg *config.Config) string {
if cfg != nil && cfg.DefaultTargetLang != "" {
return cfg.DefaultTargetLang
}
return "zh-CN"
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateLayout()
case translateMsg:
m.loading = false
if msg.err != nil {
m.addErrorMessage(msg.err.Error())
} else {
m.addSuccessMessage(msg.result, msg.tokens)
}
m.updateViewportContent()
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+l":
m.input.Reset()
case "ctrl+t":
m.langIndex = (m.langIndex + 1) % len(supportedLangs)
m.targetLang = supportedLangs[m.langIndex]
case "enter":
text := strings.TrimSpace(m.input.Value())
if text != "" && !m.loading {
m.input.Reset()
m.lastInput = text
m.loading = true
cmds = append(cmds, m.doTranslate(text, m.targetLang))
}
case "alt+up":
m.viewport.ScrollUp(3)
case "alt+down":
m.viewport.ScrollDown(3)
case "home":
m.viewport.GotoTop()
case "end":
m.viewport.GotoBottom()
}
}
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *model) addSuccessMessage(result string, tokens int) {
msg := ChatMessage{
Input: m.lastInput,
Output: result,
ToLang: m.targetLang,
Model: getModelName(m.config),
Tokens: tokens,
Timestamp: time.Now(),
}
m.messages = append(m.messages, msg)
}
func (m *model) addErrorMessage(err string) {
msg := ChatMessage{
Input: m.lastInput,
Error: err,
ToLang: m.targetLang,
Timestamp: time.Now(),
}
m.messages = append(m.messages, msg)
}
func (m *model) doTranslate(text, toLang string) tea.Cmd {
return func() tea.Msg {
result, err := m.translator.Translate(
context.Background(),
text,
&translator.TranslateOptions{
ToLang: toLang,
PromptName: "simple",
},
)
if err != nil {
return translateMsg{err: err}
}
tokens := 0
if result.Usage != nil {
tokens = result.Usage.TotalTokens
}
return translateMsg{result: result.Translated, tokens: tokens}
}
}
func (m *model) updateLayout() {
if m.width <= 0 || m.height <= 0 {
return
}
contentWidth := m.width - 4
if contentWidth < 20 {
contentWidth = 60
}
m.input.SetWidth(contentWidth)
m.viewport.SetWidth(contentWidth)
m.viewport.SetHeight(m.height - 12)
if m.viewport.Height() < 5 {
m.viewport.SetHeight(10)
}
m.updateViewportContent()
}
func (m *model) updateViewportContent() {
var b strings.Builder
for _, msg := range m.messages {
b.WriteString(m.renderTranslationCard(msg))
}
m.viewport.SetContent(b.String())
m.viewport.GotoBottom()
}
func (m *model) renderTranslationCard(msg ChatMessage) string {
contentWidth := m.viewport.Width() - 2
timeStr := msg.Timestamp.Format("15:04")
timeLabel := CardTimeStyle.Render("# " + timeStr)
inputText := lipgloss.NewStyle().
Width(contentWidth).
Render(msg.Input)
inputBlock := lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Padding(1, 3).
Width(m.viewport.Width()).
Render(lipgloss.JoinVertical(
lipgloss.Top,
timeLabel,
inputText,
))
var outputBlock string
if msg.Error != "" {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Error)
outputBlock = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Padding(0, 3, 1, 3).
Width(m.viewport.Width()).
Render(outputContent)
} else {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Output)
outputBlock = lipgloss.NewStyle().
Padding(0, 3, 1, 3).
Width(m.viewport.Width()).
Render(outputContent)
}
footerContent := lipgloss.NewStyle().
MarginLeft(3).
Render(lipgloss.JoinHorizontal(
lipgloss.Left,
CardFooterIconStyle.Render("▣"),
CardFooterTextStyle.Render(msg.Model+" · "+strconv.Itoa(msg.Tokens)),
))
return CardStyle.Render(
lipgloss.JoinVertical(
lipgloss.Top,
inputBlock,
outputBlock,
footerContent,
),
) + "\n"
}
func getModelName(cfg *config.Config) string {
if cfg != nil && cfg.DefaultModel != "" {
return cfg.DefaultModel
}
return "gpt-3.5-turbo"
}
func (m model) View() tea.View {
if m.width == 0 {
return tea.NewView("正在加载...")
}
header := m.renderHeader()
messages := m.viewport.View()
inputArea := m.renderInputArea()
infoBar := m.renderInfoBar()
spinnerView := m.renderSpinner()
content := header + "\n" + messages + inputArea + infoBar + spinnerView
v := tea.NewView(content)
v.AltScreen = true
return v
}
func gradientText(text string, startColor, endColor string) string {
startR, startG, startB := parseHexColor(startColor)
endR, endG, endB := parseHexColor(endColor)
lines := strings.Split(text, "\n")
if len(lines) == 0 {
return text
}
var result []string
for _, line := range lines {
if len(line) == 0 {
result = append(result, line)
continue
}
var coloredLine string
for i, char := range line {
ratio := float64(i) / float64(len(line)-1)
r := int(float64(startR) + float64(endR-startR)*ratio)
g := int(float64(startG) + float64(endG-startG)*ratio)
b := int(float64(startB) + float64(endB-startB)*ratio)
coloredLine += fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, string(char))
}
result = append(result, coloredLine)
}
return strings.Join(result, "\n")
}
func parseHexColor(hex string) (int, int, int) {
hex = strings.TrimPrefix(hex, "#")
r, _ := strconv.ParseInt(hex[0:2], 16, 64)
g, _ := strconv.ParseInt(hex[2:4], 16, 64)
b, _ := strconv.ParseInt(hex[4:6], 16, 64)
return int(r), int(g), int(b)
}
func (m model) renderHeader() string {
title := gradientText(logoPattern, "#8B5CF6", "#EC4899")
width := m.width - 4
if width < 20 {
width = 60
}
right := lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Render("[Ctrl+C 退出]")
return lipgloss.NewStyle().
Width(width).
Render(title + strings.Repeat(" ", width-29-len(right)-1) + right)
}
func (m model) renderInputArea() string {
inputView := m.input.View()
separator := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
Render(":::")
return "\n" + separator + "\n" + inputView + "\n"
}
func (m model) renderInfoBar() string {
var recordCount int
if m.translator != nil && m.translator.GetCache() != nil {
stats, _ := m.translator.GetCache().Stats(context.Background())
if stats != nil {
recordCount = stats.TotalRecords
}
}
var countInfo string
if recordCount == 0 {
countInfo = "暂无记录"
} else {
countInfo = fmt.Sprintf("%d条", recordCount)
}
sep := lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Render(" ")
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Render(":::")
lang := lipgloss.NewStyle().Foreground(lipgloss.Color("#F87171")).Render(m.targetLang)
model := lipgloss.NewStyle().Foreground(lipgloss.Color("#FAFAFA")).Render(getModelName(m.config))
count := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(countInfo)
result := separator + sep + lang + sep + model + sep + count
if m.loading {
result += sep + m.spinner.View() + " 翻译中..."
}
return result + "\n"
}
func (m model) renderSpinner() string {
return ""
}
func (m model) getStatusText() string {
if m.loading {
return "翻译中..."
}
return "就绪"
}

84
internal/tui/styles.go Normal file
View File

@@ -0,0 +1,84 @@
package tui
import (
"charm.land/lipgloss/v2"
)
var (
HeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true).
Padding(0, 1)
InputLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA")).
Bold(true)
OutputLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#34D399")).
Bold(true)
InputTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#E5E7EB"))
OutputTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
ErrorTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Bold(true)
TimestampStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280"))
DividerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#374151"))
StatusBarStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#1F2937")).
Foreground(lipgloss.Color("#9CA3AF"))
StatusItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#9CA3AF"))
StatusValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
LoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA"))
StatusDotStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#34D399"))
CardStyle = lipgloss.NewStyle().
MarginBottom(1)
CardMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Padding(0, 1)
CardMetaSeparatorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#374151")).
Render(" │ ")
CardInputStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Foreground(lipgloss.Color("#E5E7EB")).
Padding(0, 1)
CardOutputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
CardTimeStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
MarginBottom(1)
CardFooterIconStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
AlignVertical(lipgloss.Center)
CardFooterTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
MarginLeft(2).
AlignVertical(lipgloss.Center)
)

198
memory.md
View File

@@ -696,4 +696,202 @@ func (m model) View() string {
return m.result return m.result
} }
``` ```
---
## TUI界面改进知识
### TextInput vs Textarea
- **textinput**: 单行输入,适合短文本
- **textarea**: 多行输入,适合长段落
- 切换时需要调整布局和样式
### Modal/弹出框设计
```go
type model struct {
showModal bool
modalType string // "help", "command", "info"
}
// View中渲染modal
func (m model) View() string {
s := "主界面..."
if m.showModal {
s += m.renderModal()
}
return s
}
```
### 斜杠命令菜单
```go
type command struct {
name string
desc string
handler func()
}
var commands = []command{
{"help", "显示帮助", handleHelp},
{"clear", "清空内容", handleClear},
{"copy", "复制结果", handleCopy},
}
// 模糊匹配
func matchCommand(input string) []command {
// 过滤匹配的命令
}
```
### Viewport组件
用于长文本滚动显示配合scrollbar展示滚动位置。
```
---
## TUI输入框踩坑记录 (v0.8.0)
### 问题1Ctrl+J换行后第一行被遮住
**现象**
- 按Ctrl+J换行后第一行内容往上滚动被遮住
- 光标在新行,但下方显示一个空行
- 实际渲染了3行但只显示2行
**尝试过的方案**
1. 移除 lipgloss Width() 限制 - 无效
2. 设置 SetWidth() 后再 SetHeight() - 无效
3. 动态计算行数后调用 SetHeight() - 无效
4. 移除 updateInputHeight() 调用 - 无效
**根因分析**
- textarea 内部使用 viewport 组件管理滚动
- 每次按键后动态调用 `m.input.SetHeight(lines)` 调整高度
- 导致 textarea 内部 viewport 滚动位置与渲染不同步
**最终解决方案**
- 放弃动态调整高度的方案
- 固定 textarea 高度为5行
- 超过5行时textarea 内部自动滚动,光标始终可见
**关键代码** (`internal/tui/model.go`):
```go
ta.SetWidth(60)
ta.SetHeight(5) // 固定高度,不动态调整
```
### 问题2输入框背景颜色
**解决方案**
- 使用 `textarea.DefaultStyles()` 获取默认样式
- 修改 Style.Base 设置背景色
**关键代码**:
```go
focusedStyle, blurredStyle := textarea.DefaultStyles()
bgStyle := lipgloss.NewStyle().
Background(lipgloss.Color("#1F2937")).
Foreground(lipgloss.Color("#FAFAFA"))
focusedStyle.Base = bgStyle
blurredStyle.Base = bgStyle
ta.FocusedStyle = focusedStyle
ta.BlurredStyle = blurredStyle
```
### 经验总结
1. **Bubble Tea的textarea组件**内部包含viewport不适合频繁动态调整高度
2. **固定高度方案**:更稳定,让组件内部控制滚动
3. **样式设置**:使用 FocusedStyle/BlurredStyle + Style.Base 而非直接设置 Style
---
## Bubble Tea/Lipgloss v2 升级经验 (v0.8.1)
### 模块路径变更
v2 版本全部迁移到 `charm.land` 域名:
| v1 | v2 |
|----|-----|
| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` |
| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` |
| `github.com/charmbracelet/bubbles` | `charm.land/bubbles/v2` |
### View() 方法变更
v1:
```go
func (m model) View() string {
return "内容"
}
```
v2:
```go
func (m model) View() tea.View {
v := tea.NewView("内容")
v.AltScreen = true // 进入备用屏幕
return v
}
```
### KeyMsg 变更
v1:
```go
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
}
```
v2:
```go
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "space": // 注意:空格键改为"space"
}
```
### 快捷键对比
| 功能 | v1 | v2 |
|-----|-----|-----|
| Ctrl+C | `tea.KeyCtrlC` | `"ctrl+c"` |
| Alt修饰键 | `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` |
| 空格键 | `" "` | `"space"` |
### viewport API变更
| 功能 | v1 | v2 |
|-----|-----|-----|
| 滚动上 | `m.viewport.LineUp(n)` | `m.viewport.ScrollUp(n)` |
| 滚动下 | `m.viewport.LineDown(n)` | `m.viewport.ScrollDown(n)` |
| 宽度 | `m.viewport.Width` | `m.viewport.Width()` |
| 高度 | `m.viewport.Height` | `m.viewport.Height()` |
| 创建 | `viewport.New(w, h)` | `viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))` |
### textarea API变更
| 功能 | v1 | v2 |
|-----|-----|-----|
| 默认样式 | `textarea.DefaultStyles()` | `textarea.DefaultStyles(isDark bool)` |
| 重置内容 | `m.input.SetValue("")` | `m.input.Reset()` |
### Program启动
v1:
```go
p := tea.NewProgram(model{})
p.Start()
```
v2:
```go
p := tea.NewProgram(model{})
p.Run()
``` ```

250
taolun.md
View File

@@ -583,4 +583,252 @@ func (m model) doTranslate(text, toLang string) tea.Cmd {
**下一步**: 测试TUI界面、优化体验 **下一步**: 测试TUI界面、优化体验
**关联文档**: **关联文档**:
- [changelog.md#0.6.0](changelog.md#060) - [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 14:00] 版本 0.7.0 - TUI界面改进计划
**原因**: TUI基础功能完成后讨论改进方向和用户体验优化
**分析**:
- 当前单行textinput无法输入多行文本
- 快捷键固定显示在底部不够美观
- 缺少命令菜单系统
- 长翻译结果无法滚动
**解决方案 - 新增模块**:
| 步骤 | 模块 | 内容 |
|------|------|------|
| 7 | 多行输入 | textarea组件替换textinput |
| 8 | 弹出框组件 | 通用modal支持快捷键帮助 |
| 9 | 斜杠命令菜单 | / 触发命令选择器类似opencode |
| 10 | 翻译结果滚动 | viewport组件 |
| 11 | 复制功能 | clipboard集成 |
| 12 | 状态栏扩展 | 耗时、token用量 |
**斜杠命令设计**:
| 命令 | 功能 |
|------|------|
| `/help` | 显示快捷键帮助 |
| `/clear` | 清空内容 |
| `/copy` | 复制翻译结果 |
| `/lang` | 切换语言 |
| `/history` | 翻译历史 |
| `/quit` | 退出 |
**设计亮点**:
1. 隐藏底部快捷键提示,改为按 ? 或 F1 弹出帮助框
2. 输入 / 触发命令菜单,上下键选择,回车执行
3. 命令菜单支持模糊匹配
4. modal组件通用化可复用于其他弹窗场景
**关联文档**:
- [changelog.md#0.7.0](changelog.md#070)
---
### [2026-04-06 14:30] 版本 0.7.0 - 模块7: 多行输入 (已完成)
**原因**: 当前textinput只支持单行需要支持多行文本输入
**分析**:
- 长段落、多行文本无法输入
- 需要换用bubbles的textarea组件
**解决方案**:
1. 将textinput替换为textarea
2. 调整样式和布局宽50高5
3. 底部状态栏提示按 / 显示命令
**技术实现**:
- 使用 `github.com/charmbracelet/bubbles/textarea`
- textarea.SetWidth(50)、SetHeight(5) 设置尺寸
- 移除底部固定快捷键提示,改为按需显示
- 隐藏行号: `ShowLineNumbers = false`
- 移除左侧提示符: `Prompt = ""`
- Enter执行翻译Ctrl+J换行
**下一步**: 实现模块8: 弹出框组件
**关联文档**:
- [changelog.md#0.7.0](changelog.md#070)
---
### [2026-04-06 15:00] 版本 0.8.0 - TUI重构: 聊天风格界面
**原因**: 用户希望使用类似 charmbracelet/crush 的聊天风格界面
**分析**:
- 当前界面:标题在上 → 输入框在中 → 结果在下
- 期望界面:标题在上 → 聊天消息区域(可滚动历史)→ 输入框固定底部 → 状态栏最底部
- 原文+译文成对显示,类似聊天软件
**解决方案**:
1. 界面布局重构:
```
┌─────────────────────────────────────────┐
│ ✦ YOYO 翻译 [Ctrl+C退出] │
├─────────────────────────────────────────┤
│ (聊天消息区域,可滚动查看历史) │
│ ── 用户输入 ── │
│ Hello world │
│ ── 翻译结果 ── │
│ 你好世界 │
│ ... │
├─────────────────────────────────────────┤
│ 输入框... [回车] │
├─────────────────────────────────────────┤
│ 目标:zh-CN │ 模型:gpt-3.5 │ Tokens:125 │
└─────────────────────────────────────────┘
```
2. 技术方案:
- 消息结构:原文+译文成对显示
- 底部固定输入框textarea
- 状态栏显示完整信息
- Ctrl+J 换行Enter 发送
- 自动调整输入框高度
**下一步**: 模块1: 创建TUI模块结构
**关联文档**:
- [changelog.md#0.8.0](changelog.md#080)
---
### [2026-04-06 16:00] 版本 0.8.0 - 输入框踩坑与修复
**原因**: Ctrl+J换行后第一行被遮住显示错乱
**分析**:
- 换行后 textarea 内部渲染3行但只显示2行
- 第一行内容往上滚动被遮住光标在第2行第3行是空行
- 尝试多种方案均无效移除Width限制、设置SetWidth/SetHeight顺序、动态调整高度
**解决方案**:
1. 放弃动态调整高度方案固定高度为5行
2. 超过5行时textarea内部自动滚动光标始终可见
**技术细节**:
- textarea内部使用viewport组件频繁SetHeight导致滚动位置错乱
- 使用 FocusedStyle/BlurredStyle + Style.Base 设置背景色
**代码变更**:
```go
ta.SetWidth(60)
ta.SetHeight(5) // 固定高度,不动态调整
```
**下一步**: 完善其他UI功能
**关联文档**:
- [memory.md#TUI输入框踩坑记录](memory.md#tui输入框踩坑记录)
- [changelog.md#0.8.0](changelog.md#080)
---
### [2026-04-07 10:00] 版本 0.8.1 - 翻译结果卡片组件设计
**原因**: 用户希望改进翻译结果显示样式使用lipgloss构建更美观的组件
**分析**:
- 当前翻译结果显示比较简单,只有标签和内容
- 需要设计一个结构化的翻译卡片组件
- 组件需要显示Tokens、翻译时间、模型名称等元信息
**解决方案**:
1. **卡片组件结构**:
```
┌─ 标题栏 ─────────────────────────────────────────┐
│ Tokens: 150 │ 耗时: 1.2s │ 模型: gpt-4 │
└─────────────────────────────────────────────────┘
┌─ 用户输入 ───────────────────────────────────────┐
│ ██████████ 碳黑背景 ████████████████████████████ │
└─────────────────────────────────────────────────┘
┌─ 翻译结果 ───────────────────────────────────────┐
│ AI 翻译的文本内容... │
└─────────────────────────────────────────────────┘
```
2. **技术实现**:
- 使用 lipgloss.Div() 和 lipgloss.JoinVertical() 构建组件
- 背景色: #1A1A1A (碳黑色)
- 用户输入区域: 纯背景色,无边框
- 组件间距: 5px marginBottom
3. **样式定义**:
- CardStyle: 卡片容器marginBottom(5)
- CardMetaStyle: 元信息行样式,#6B7280 灰色
- CardInputStyle: 用户输入,#1A1A1A 背景 + #E5E7EB 文字
- CardOutputStyle: 翻译结果,白色文字
**下一步**: 实现组件代码
**关联文档**:
- [changelog.md#0.8.1](changelog.md#081)
---
### [2026-04-07] 版本 1.0.0-beta - Logo和信息栏改造
**原因**: 用户希望改进TUI界面的视觉效果使标题更独特输入框和信息栏更美观
**分析**:
- 原标题 "✦ YOYO 翻译" 过于简单
- 输入框需要更好的视觉分隔
- 需要添加翻译状态动画
**解决方案**:
1. **标题Logo**:
- 使用ASCII艺术 "l_ _ _____ _____"
- 实现紫色→粉色渐变效果 (ANSI True Color)
- 右侧显示版本号 [v1.0.0-beta]
2. **输入框改造**:
- 去掉边框
- 上下使用紫色 `:::` 分隔符
- Ctrl+J 启用换行
3. **信息栏改造**:
- 合并显示:语言(红色) + 模型名(白色) + 缓存记录(碳黑)
- 翻译时显示 Spinner 动画 (MiniDot)
4. **翻译卡片优化**:
- `` 图标边距调整
**版本号规则**:
- 版本号需与 git 标签、changelog.md 中的版本号保持三方同步
- 遵循语义化版本:主版本.次版本.修订版本
- beta版使用 `-beta` 后缀
**关联版本**: [changelog.md#1.0.0-beta](changelog.md#100-beta-2026-04-07)
---
### [2026-04-07] 版本 1.1.0 - 配置路径修复和huh迁移
**原因**:
1. 管道模式下(如 `cd /docs && cat readme.md | yoyo`)找不到配置文件
2. onboard配置保存到错误的相对路径
3. 希望用 `charmbracelet/huh` 替代 `survey` 获得更好的UX
**分析**:
- 所有配置路径硬编码为 `configs/config.yaml`相对CWD
- 从不同目录运行程序时路径解析失败
- survey库API较老huh提供更现代的表单体验
**解决方案**:
1. 新增 `internal/config/path.go` 路径解析工具
2. 配置查找优先级:`--config` > `~/.config/yoyo/config.yaml` > `./configs/config.yaml`
3. onboard保存到 `~/.config/yoyo/config.yaml`
4. .env从 `~/.config/yoyo/.env` 加载
5. onboard使用huh重写Form+Group模式链式API泛型支持
**技术细节**:
```go
// 路径解析
config.ResolveConfigPath(userPath) // 智能查找配置
config.GetUserConfigPath() // ~/.config/yoyo/config.yaml
config.GetUserEnvPath() // ~/.config/yoyo/.env
```
**huh迁移要点**:
- `survey.Select` → `huh.NewSelect[string]().Options(huh.NewOption(...)...)`
- `survey.Input` → `huh.NewInput().Value(&var).Validate(fn)`
- `survey.Confirm` → `huh.NewConfirm().Affirmative("是").Negative("否")`
- 分步表单 → `huh.NewForm(huh.NewGroup(...), huh.NewGroup(...))`
**关联版本**: [changelog.md#1.1.0](changelog.md#110-2026-04-07)