14 Commits

Author SHA1 Message Date
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
59f9c6de18 feat: 实现模块6 - TUI集成翻译 (Enter翻译/异步执行/加载状态) 2026-04-06 05:18:11 +08:00
8c6b08cec8 feat: 实现模块5 - TUI快捷键系统 (Ctrl+L清空/Ctrl+T切换语言) 2026-04-06 05:15:24 +08:00
aefa0e8799 feat: 实现模块4 - TUI状态栏和主题配色 2026-04-06 05:13:24 +08:00
7321951d05 feat: 实现模块3 - TUI翻译显示区 2026-04-06 05:11:23 +08:00
6f872ff285 feat: 实现模块2 - TUI输入组件 (textinput) 2026-04-06 05:10:00 +08:00
1787464f52 feat: 实现模块1 - TUI基础框架 (bubbletea) 2026-04-06 05:07:36 +08:00
10 changed files with 1515 additions and 25 deletions

View File

@@ -24,14 +24,99 @@
- [ ] 翻译历史记录
## 当前正实现
- [ ] 项目基础架构搭建
- [ ] 核心类设计实现
- [x] TUI界面模块拆分计划 ✅ 已制定
- [x] 模块1: TUI框架搭建 ✅ 已完成
- [x] 模块2: 输入组件 ✅ 已完成
- [x] 模块3: 翻译显示区 ✅ 已完成
- [x] 模块4: 状态栏/主题 ✅ 已完成
- [x] 模块5: 快捷键系统 ✅ 已完成
- [x] 模块6: 集成翻译 ✅ 已完成
## TUI界面实现计划 (v0.6.0) - 全部完成!
| 步骤 | 模块 | 内容 | 状态 |
|------|------|------|------|
| 1 | TUI框架搭建 | bubbletea基础App结构、运行循环 | ✅ 已完成 |
| 2 | 输入组件 | 文本输入框,光标、基础编辑 | ✅ 已完成 |
| 3 | 翻译显示区 | 结果展示、格式化、滚动 | ✅ 已完成 |
| 4 | 状态栏/主题 | 底部状态栏、语言选择、主题配色 | ✅ 已完成 |
| 5 | 快捷键系统 | 退出、清空、切换语言等 | ✅ 已完成 |
| 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
-
## 版本历史
### 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交互界面
**类型**: 功能版本
**状态**: 已完成
**变更内容**:
- ✅ 模块1: TUI框架搭建 - 添加bubbletea依赖实现基础App结构
- ✅ 模块2: 输入组件 - textinput组件、基础输入处理
- ✅ 模块3: 翻译显示区 - 结果显示区域、样式定义
- ✅ 模块4: 状态栏/主题 - 底部状态栏、语言显示、配色完善
- ✅ 模块5: 快捷键系统 - Ctrl+L清空、Ctrl+T切换语言
- ✅ 模块6: 集成翻译 - Enter触发翻译、异步执行、加载状态、错误处理
**技术实现**:
- 使用 `github.com/charmbracelet/bubbletea` v1.3.10
- 使用 `github.com/charmbracelet/bubbles` (textinput组件)
- 使用 `github.com/charmbracelet/lipgloss` v1.1.0
- 基础model结构: config、translator字段
**讨论记录**:
- [TUI界面模块拆分计划](taolun.md#2026-04-06-1000-版本-060---tui界面模块拆分计划)
**下一步**:
- 实现模块7: 多行输入
---
### 0.5.0 (2026-03-29) - 本地缓存功能
**类型**: 功能版本
**状态**: 已发布
@@ -333,4 +418,27 @@ yoyo onboard --force
### 示例版本递增
- `0.0.1``0.0.2`:小修复
- `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---翻译结果卡片组件设计)

View File

@@ -4,34 +4,34 @@
default_provider: "siliconflow"
default_model: "gpt-3.5-turbo"
timeout: 30
default_source_lang: "auto" # 默认源语言auto为自动检测
default_target_lang: "zh-CN" # 默认目标语言(简体中文)
default_source_lang: "auto" # 默认源语言auto为自动检测
default_target_lang: "zh-CN" # 默认目标语言(简体中文)
providers:
siliconflow:
api_host: "https://api.siliconflow.cn/v1"
api_key: "${SILICONFLOW_API_KEY}"
model: "siliconflow-base"
model: "${SILICONFLOW_MODEL}"
enabled: true
volcano:
api_host: "https://api.volcengine.com/v1"
api_key: "${VOLCANO_API_KEY}"
model: "volcano-chat"
enabled: true
national:
api_host: "https://api.nsc.gov.cn/v1"
api_key: "${NATIONAL_API_KEY}"
model: "nsc-base"
enabled: false
qwen:
api_host: "https://dashscope.aliyuncs.com/compatible-mode/v1"
api_key: "${QWEN_API_KEY}"
model: "qwen-turbo"
enabled: true
openai:
api_host: "https://api.openai.com/v1"
api_key: "${OPENAI_API_KEY}"
@@ -46,7 +46,7 @@ prompts:
# 缓存配置
cache:
enabled: true # 是否启用缓存
max_records: 10000 # 最大缓存记录数
expire_days: 30 # 缓存过期天数
db_path: "~/.config/yoyo/cache.db" # 缓存数据库文件路径
enabled: true # 是否启用缓存
max_records: 10000 # 最大缓存记录数
expire_days: 30 # 缓存过期天数
db_path: "~/.config/yoyo/cache.db" # 缓存数据库文件路径

44
go.mod
View File

@@ -3,17 +3,47 @@ module github.com/titor/fanyi
go 1.26.1
require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/go-enry/go-enry/v2 v2.9.5 // indirect
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.2
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/charmbracelet/bubbles v1.0.0
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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.8 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

77
go.sum
View File

@@ -1,50 +1,122 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
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/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
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-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
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/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
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/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
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/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
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/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
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.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
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/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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
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-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
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/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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.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/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -57,6 +129,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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
}

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

@@ -0,0 +1,371 @@
package tui
import (
"context"
"fmt"
"strings"
"time"
"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/translator"
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
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
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 = "输入要翻译的文本... (Ctrl+J 换行)"
ta.Focus()
ta.Prompt = ""
ta.ShowLineNumbers = false
ta.SetWidth(60)
ta.SetHeight(5)
ta.SetStyles(textarea.DefaultStyles(false))
ta.KeyMap.InsertNewline.SetEnabled(true)
vp := viewport.New(viewport.WithWidth(50), viewport.WithHeight(20))
vp.SetContent("")
return tea.NewProgram(model{
config: cfg,
translator: t,
messages: make([]ChatMessage, 0),
input: ta,
viewport: vp,
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 nil
}
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)
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 {
metaContent := lipgloss.JoinHorizontal(
lipgloss.Left,
CardMetaStyle.Render(fmt.Sprintf("Tokens: %d", msg.Tokens)),
CardMetaSeparatorStyle,
CardMetaStyle.Render(fmt.Sprintf("耗时: %s", msg.Timestamp.Format("15:04:05"))),
CardMetaSeparatorStyle,
CardMetaStyle.Render(fmt.Sprintf("模型: %s", msg.Model)),
)
metaBlock := lipgloss.NewStyle().
BorderStyle(lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
}).
BorderForeground(lipgloss.Color("#374151")).
Width(m.viewport.Width() - 2).
Render(metaContent)
inputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Input)
inputBlock := lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Width(m.viewport.Width()).
Render(inputContent)
var outputBlock string
if msg.Error != "" {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Error)
outputBlock = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Width(m.viewport.Width()).
Render(outputContent)
} else {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Output)
outputBlock = lipgloss.NewStyle().
Width(m.viewport.Width()).
Render(outputContent)
}
return CardStyle.Render(
lipgloss.JoinVertical(
lipgloss.Top,
metaBlock,
inputBlock,
outputBlock,
),
) + "\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()
statusBar := m.renderStatusBar()
content := header + "\n" + messages + inputArea + statusBar
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m model) renderHeader() string {
title := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
Bold(true).
Render("✦ YOYO 翻译")
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-len(title)-len(right)-1) + right)
}
func (m model) renderInputArea() string {
inputView := m.input.View()
container := lipgloss.NewStyle().
Width(m.input.Width() + 1).
BorderStyle(lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
}).
BorderForeground(lipgloss.Color("#60A5FA"))
return "\n" + container.Render(inputView) + "\n"
}
func (m model) renderStatusBar() string {
langInfo := "目标: " + m.targetLang
modelInfo := "模型: " + getModelName(m.config)
tokensInfo := "Tokens: -"
if len(m.messages) > 0 {
lastMsg := m.messages[len(m.messages)-1]
if lastMsg.Tokens > 0 {
tokensInfo = fmt.Sprintf("Tokens: %d", lastMsg.Tokens)
}
}
statusDot := StatusDotStyle.Render("●")
if m.loading {
statusDot = LoadingStyle.Render("○")
}
sep := StatusItemStyle.Render(" │ ")
width := m.width - 4
if width < 30 {
width = 60
}
status := StatusItemStyle.Render(langInfo) +
sep + StatusItemStyle.Render(modelInfo) +
sep + StatusItemStyle.Render(tokensInfo) +
sep + statusDot + " " + StatusValueStyle.Render(m.getStatusText())
return lipgloss.NewStyle().
Width(width).
Background(lipgloss.Color("#1F2937")).
Render(" " + status)
}
func (m model) getStatusText() string {
if m.loading {
return "翻译中..."
}
return "就绪"
}

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

@@ -0,0 +1,71 @@
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(5)
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"))
)

391
memory.md
View File

@@ -505,4 +505,393 @@ db.SetMaxIdleConns(1)
1. 测试缓存与Translator的集成
2. 测试配置文件的正确加载
3. 测试缓存命令的正确执行
4. 测试错误情况的正确处理
4. 测试错误情况的正确处理
---
## TUI界面开发策略
### 分模块实现策略
**决策**: TUI界面分6个小模块逐步实现每次只做一个模块
**原因**:
1. 减少Token消耗和上下文负担
2. 便于逐步测试和验证
3. 适应free模式的限流约束
**模块列表**:
1. TUI框架搭建 - bubbletea基础App结构
2. 输入组件 - 文本输入框
3. 翻译显示区 - 结果展示、滚动
4. 状态栏/主题 - 底部状态栏、配色
5. 快捷键系统 - 操作快捷键
6. 集成翻译 - 对接Translator、加载动画
### 技术选型
**决策**: 使用charmbracelet生态
- `bubbletea` - Elm架构的TUI框架
- `lipgloss` - 样式和主题
- `bubbles/textinput` - 输入框组件
**原因**:
1. Go生态最流行的TUI框架
2. Elm架构清晰易于分模块实现
3. 组件化设计,便于复用
### 文档管理规范
**决策**: 不再创建新的md文件
**原因**: 保持项目文档简洁,避免碎片化
**规则**:
- 讨论内容 → `taolun.md`
- 版本更新 → `changelog.md`
- 经验教训 → `memory.md`
- 项目初衷 → `why.md` (仅用户编辑)
---
## Bubble Tea TUI框架经验
### 版本信息
**当前版本**: v1.3.10
**API风格**: v1版本风格Init返回tea.Cmd
### 基础结构
```go
type model struct {
// 状态字段
}
func (m model) Init() tea.Cmd {
return nil // 初始化命令
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil // 更新逻辑
}
func (m model) View() string {
return "视图内容" // 返回渲染内容
}
func NewApp(cfg, translator) *tea.Program {
return tea.NewProgram(model{...})
}
```
### 关键点
- `tea.NewProgram()` 创建程序实例
- `program.Run()` 返回 `(model, error)`
- model的字段可以是config、translator等依赖
- View方法返回string使用lipgloss样式
### main.go集成注意
- 版本检查(--version)需要在interactive模式检查之前
- 避免interactive模式在非TTY环境启动
- Run()需要两个返回值: `_, err := app.Run()`
### TextInput组件使用
```go
import "github.com/charmbracelet/bubbles/textinput"
// model中添加字段
type model struct {
textInput textinput.Model
}
// 初始化
ti := textinput.New()
ti.Placeholder = "输入文本..."
ti.Focus() // 获取焦点
ti.Prompt = "> " // 提示符
// Update中处理
m.textInput, cmd = m.textInput.Update(msg)
// View中显示
m.textInput.View()
```
### Lipgloss样式定义
```go
var (
headerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D9FF")).
Bold(true)
resultStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#98FB98")).
Background(lipgloss.Color("#0D1B2A"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
keyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA"))
)
```
### 多区域View渲染
```go
func (m model) View() string {
return "\n" +
" " + headerStyle.Render("YOYO翻译") + "\n" +
" " + divider + "\n\n" +
" " + m.textInput.View() + "\n\n" +
m.renderResult() +
helpText
}
func (m model) renderResult() string {
if m.result == "" {
return " " + helpStyle.Render("翻译结果将显示在这里...") + "\n"
}
return " " + resultStyle.Render(m.result) + "\n"
}
### 异步命令和消息模式
```go
// 定义自定义消息类型
type translateMsg struct {
result string
err error
}
// 异步执行函数
func (m model) doTranslate(text string) tea.Cmd {
return func() tea.Msg {
result, err := translate(text)
if err != nil {
return translateMsg{err: err}
}
return translateMsg{result: result}
}
}
// Update中处理消息
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case translateMsg:
if msg.err != nil {
m.errMsg = msg.err.Error()
} else {
m.result = msg.result
}
return m, nil
}
return m, nil
}
```
### 加载状态处理
```go
type model struct {
loading bool
errMsg string
}
// View中显示loading
func (m model) View() string {
if m.loading {
return "正在翻译..."
}
if m.errMsg != "" {
return "错误: " + m.errMsg
}
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()
```

381
taolun.md
View File

@@ -379,4 +379,383 @@ if oldestStr.Valid {
**关联文档**:
- [changelog.md#0.5.1](changelog.md#051)
- [memory.md#本地缓存实现经验](memory.md#本地缓存实现经验)
- [memory.md#本地缓存实现经验](memory.md#本地缓存实现经验)
---
### [2026-04-06 10:00] 版本 0.6.0 - TUI界面模块拆分计划
**原因**: 当前TUI目录(tui/components、tui/theme)已创建但完全为空,需要从零实现终端交互界面
**分析**:
- 采用分模块逐步实现策略减少Token消耗和上下文负担
- 每次只实现一个模块,完成后再进入下一个
- 讨论内容仅保存到taolun.md/changelog.md/memory.md不新增md文件
**解决方案 - TUI模块拆分**:
| 步骤 | 模块 | 内容 | 预计工作量 |
|------|------|------|-----------|
| 1 | TUI框架搭建 | 选择库(bubbletea)、基础App结构、运行循环 | 小 |
| 2 | 输入组件 | 文本输入框、光标、基础编辑 | 中 |
| 3 | 翻译显示区 | 结果展示、格式化、滚动 | 中 |
| 4 | 状态栏/主题 | 底部状态栏、语言选择、主题配色 | 小 |
| 5 | 快捷键系统 | 退出、清空、切换语言等 | 小 |
| 6 | 集成翻译 | 对接现有Translator、加载动画 | 中 |
**技术选型**:
- 优先使用 `charmbracelet/bubbletea` (Elm架构、Go生态最流行)
- 配合 `charmbracelet/lipgloss` 实现样式和主题
- 配合 `charmbracelet/bubbles/textinput` 实现输入框
**当前状态**: ✅ 模块1已完成模块2待实现
**关联文档**:
- [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 10:30] 版本 0.6.0 - 模块1: TUI框架搭建 (已完成)
**原因**: 实现TUI界面的第一步建立基础框架结构
**分析**:
- 需要创建基本的App结构和model
- 需要支持config和translator的注入
- 需要修复main.go中版本检查顺序问题
**解决方案**:
1. 创建 `internal/tui/app.go` 基础文件
2. 定义model结构体包含config和translator字段
3. 实现Init/Update/View三个基本方法
4. 添加bubbletea、bubbles、lipgloss依赖
5. 修复main.go中--version在interactive之前检查
**技术实现**:
```go
type model struct {
config *config.Config
translator *translator.Translator
}
func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program {
return tea.NewProgram(model{...})
}
```
**下一步**: 实现模块2: 输入组件
**关联文档**:
- [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 11:00] 版本 0.6.0 - 模块2: 输入组件 (已完成)
**原因**: 实现TUI输入功能
**分析**:
- 使用bubbletea的textinput组件
- 需要焦点管理和键盘事件处理
**解决方案**:
1. 添加textinput字段到model
2. 初始化时设置placeholder和prompt
3. Update中处理KeyMsg
4. View中渲染输入框
5. 支持Ctrl+C和Esc退出
**技术实现**:
```go
type model struct {
textInput textinput.Model
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc {
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
```
**下一步**: 实现模块3: 翻译显示区
**关联文档**:
- [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 11:30] 版本 0.6.0 - 模块3: 翻译显示区 (已完成)
**原因**: 添加翻译结果显示区域
**分析**:
- 需要定义结果展示区域
- 需要为不同区域定义不同样式
**解决方案**:
1. 添加result字段到model
2. 定义headerStyle、resultStyle、helpStyle
3. 实现renderResult()辅助方法
4. View中组合各个区域
**下一步**: 实现模块4: 状态栏/主题
**关联文档**:
- [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 12:00] 版本 0.6.0 - 模块4: 状态栏/主题 (已完成)
**原因**: 添加底部状态栏和主题配色
**分析**:
- 需要显示当前目标语言
- 需要完善配色方案
- 需要定义状态栏样式
**解决方案**:
1. 添加targetLang字段到model
2. 定义statusBarStyle、langStyle等新样式
3. 实现renderStatusBar()方法
4. View中渲染状态栏
**下一步**: 实现模块5: 快捷键系统
**关联文档**:
- [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 12:30] 版本 0.6.0 - 模块5: 快捷键系统 (已完成)
**原因**: 添加键盘快捷键提升用户体验
**分析**:
- 需要常用操作快捷键
- 需要清晰显示快捷键提示
**解决方案**:
1. 添加Ctrl+L: 清空输入和结果
2. 添加Ctrl+T: 循环切换语言
3. 添加keyStyle样式高亮快捷键
4. 更新帮助提示显示所有快捷键
**快捷键列表**:
- `Ctrl+L`: 清空输入框和翻译结果
- `Ctrl+T`: 循环切换目标语言 (zh-CN→en-US→ja→ko→...)
- `Ctrl+C`/`Esc`: 退出程序
- `Enter`: 翻译 (后续模块实现)
**下一步**: 实现模块6: 集成翻译
**关联文档**:
- [changelog.md#0.6.0](changelog.md#060)
---
### [2026-04-06 13:00] 版本 0.6.0 - 模块6: 集成翻译 (已完成)
**原因**: 将Translator集成到TUI实现真正的翻译功能
**分析**:
- 需要在Enter键时调用翻译API
- 需要异步执行避免阻塞UI
- 需要显示loading状态和错误处理
**解决方案**:
1. 添加translateMsg消息类型处理异步结果
2. 添加loading和errMsg字段
3. 实现doTranslate()函数执行异步翻译
4. Update中处理translateMsg消息
5. View中显示loading状态或错误信息
**技术实现**:
```go
type translateMsg struct {
result string
err error
}
func (m model) doTranslate(text, toLang string) tea.Cmd {
return func() tea.Msg {
result, err := m.translator.Translate(...)
if err != nil {
return translateMsg{err: err}
}
return translateMsg{result: result.Translated}
}
}
```
**下一步**: 测试TUI界面、优化体验
**关联文档**:
- [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)