- 后端
【免费下载链接】wish
Make SSH apps, just like that! 💫
本指南完整讲解如何将基于 Charmbracelet Wish 的 SSH 应用从 v1 升级到 v2。核心变化集中在三处:全部包迁移到
charm.land域名下的 v2 模块、Bubble Tea 从 v2 开始采用返回tea.View的声明式视图模式、以及移除终端渲染相关的底层胶水代码。读完本文,你将掌握导入路径改写、Handler 签名调整、ProgramOption 迁移到 View 字段、按键/鼠标/粘贴/剪贴板消息的新写法,以及如何在 SSH 会话中正确获取客户端环境变量。
升级概览:大多数改动都是机械性的
Wish v2 的升级并不复杂,官方指引概括为四步:
- 将导入路径更新为
charm.land/wish/v2; - 将 Bubble Tea 升级到 v2,改用声明式视图;
- 删除颜色配置(color profile)探测代码;
- 更新 Program 选项的写法。
对大多数应用而言,改动主要就是导入路径与视图模式的适配,不需要重写业务逻辑。从当前仓库的 go.mod 可以看到,Wish v2 的模块声明为charm.land/wish/v2,依赖charm.land/bubbletea/v2、charm.land/log/v2、charm.land/ssh与charm.land/lipgloss/v2,因此升级时只需把go.mod中相关 require 一并替换即可。
导入路径:统一迁移到 charm.land 域名
Charm 系列库在 v2 统一使用charm.land这个 vanity domain,导入路径变化如下:
// Before import ( "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/charmbracelet/wish/logging" "github.com/charmbracelet/wish/activeterm" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" ) // After import ( "charm.land/wish/v2" "charm.land/wish/v2/bubbletea" "charm.land/wish/v2/logging" "charm.land/wish/v2/activeterm" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/log/v2" )所有中间件包都遵循同一模式:
charm.land/wish/v2/accesscontrolcharm.land/wish/v2/commentcharm.land/wish/v2/elapsedcharm.land/wish/v2/gitcharm.land/wish/v2/ratelimitercharm.land/wish/v2/recovercharm.land/wish/v2/scp
注意charm.land/ssh(底层 SSH 服务器)本身不带/v2后缀——它独立版本化,与 Wish v2 是同一个依赖体系。在 bubbletea/tea.go 中可以看到 Wish v2 源码自身的导入方式,与升级后的应用完全一致。
Bubble Tea Handler 的四个变化点
移除颜色配置探测:MakeRenderer 已删除
v1 时代需要手动通过bubbletea.MakeRenderer(s)基于 SSH 会话构造渲染器,再判断终端背景色;v2 中该函数已删除,Bubble Tea v2 会自动处理颜色配置探测。
// Before func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { renderer := bubbletea.MakeRenderer(s) txtStyle := renderer.NewStyle().Foreground(lipgloss.Color("10")) bg := "light" if renderer.HasDarkBackground() { bg = "dark" } m := model{ txtStyle: txtStyle, bg: bg, } return m, []tea.ProgramOption{tea.WithAltScreen()} } // After func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { m := model{ txtStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("10")), } return m, []tea.ProgramOption{} }升级后直接使用 Lip Gloss v2 的lipgloss.NewStyle()即可。从源码层面看,Wish v2 的 bubbletea 中间件在 bubbletea/tea_unix.go 的makeOpts中已经完成了颜色配置相关的铺垫:当会话带有 PTY 时会补全TERM环境变量;对于EmulatedPty(无真实 PTY 的会话),则会通过tea.WithColorProfile(colorprofile.Env(envs))基于环境变量强制设定颜色配置。也就是说,颜色探测的职责整体下沉到了 Wish 中间件与 Bubble Tea 运行时,应用层无需再关心。
声明式视图:View() 返回 tea.View
Bubble Tea v2 中,View()不再返回string,而是返回tea.View结构体:
// Before func (m model) View() string { return "Hello, world!" } // After func (m model) View() tea.View { v := tea.NewView("Hello, world!") v.AltScreen = true // 把 tea.WithAltScreen() 移到这里 return v }仓库中的 examples/bubbletea/main.go 是 v2 写法的完整参考:其View()构造tea.NewView(...)后设置v.AltScreen = true并返回,与官方升级指南保持一致。
从消息中获取背景色:监听 BackgroundColorMsg
v1 是在初始化时同步查询渲染器;v2 改为在Init()中发起tea.RequestBackgroundColor,在Update()中通过tea.BackgroundColorMsg异步接收结果:
// Before func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { renderer := bubbletea.MakeRenderer(s) bg := "light" if renderer.HasDarkBackground() { bg = "dark" } m := model{bg: bg} return m, nil } // After func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { m := model{bg: "light"} // 默认值 return m, nil } func (m model) Init() tea.Cmd { return tea.RequestBackgroundColor } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.BackgroundColorMsg: if msg.IsDark() { m.bg = "dark" } else { m.bg = "light" } } return m, nil }这正是 examples/bubbletea/main.go 中model.Init()与Update()的实际写法:Init()通过tea.Batch(tea.RequestBackgroundColor)发起请求,Update()中case tea.BackgroundColorMsg判断msg.IsDark()更新m.bg。
从消息中获取颜色配置:ColorProfileMsg
同理,颜色配置也通过消息传递:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.ColorProfileMsg: m.profile = msg.String() // "TrueColor"、"ANSI256"、"ANSI" 等 } return m, nil }示例应用中tea.ColorProfileMsg的msg.String()结果会直接展示在界面上。由于消息是异步到达的,模型字段应提供合理的默认值,再在Update中覆盖。
中间件 API 变更
已删除的函数
以下函数已从bubbletea中间件中移除:
MakeRenderer()—— 直接使用 Lip Gloss 样式,颜色配置自动处理;MiddlewareWithColorProfile()—— 不再需要;QueryTerminalFilter—— 终端查询由 Bubble Tea v2 自己处理。
在本次仓库源码中已搜索不到这三个标识符的任何实现,确认它们属于 v1 遗留 API。
函数签名变化
所有中间件函数现在返回charm.land/wish/v2.Middleware:
// Before func Middleware(handler Handler) wish.Middleware // After func Middleware(handler Handler) wish.Middleware // 签名不变,导入路径换了MiddlewareWithProgramHandler的签名被简化,去掉了termenv.Profile参数:
// Before func MiddlewareWithProgramHandler( handler ProgramHandler, profile termenv.Profile, ) wish.Middleware // After func MiddlewareWithProgramHandler(handler ProgramHandler) wish.Middleware从源码看,bubbletea/tea.go 中MiddlewareWithProgramHandler现在只接受一个ProgramHandler(签名func(sess ssh.Session) *tea.Program),其职责是:为每个连接创建独立tea.Program、把 PTY 的窗口尺寸变化转换为tea.WindowSizeMsg发送给程序、并在会话结束时执行program.Kill()以恢复终端原始状态。若你想在标准 Handler 之外获得tea.Program的直接访问权(例如定时p.Send()消息),可以参考 examples/bubbleteaprogram/main.go:它用newProg包装tea.NewProgram并起 goroutine 每秒发送timeMsg,最终通过bubbletea.MiddlewareWithProgramHandler(teaHandler)接入。
Program Options:迁移到 View 字段
Bubble Tea v2 中,大部分 ProgramOption 移动到了View结构体上:
// Before return m, []tea.ProgramOption{ tea.WithAltScreen(), tea.WithMouseCellMotion(), } // After func (m model) View() tea.View { v := tea.NewView(m.content) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion return v }输入/输出等 I/O 相关配置仍可保留为 ProgramOption,其中 SSH I/O 的接入必须继续使用bubbletea.MakeOptions(s):
return m, bubbletea.MakeOptions(s) // SSH I/O 接入仍然需要MakeOptions是 Wish v2 中"粘合" SSH 会话与 Bubble Tea 程序的关键函数。查看 bubbletea/tea.go 源码可知它做了三件事:调用makeOpts(s)返回基于会话的 ProgramOption、追加一个消息过滤器(把tea.SuspendMsg转为tea.ResumeMsg,避免 SSH 场景下挂起语义异常)。而makeOpts(见 bubbletea/tea_unix.go)会按会话是否有真实 PTY 分别接线:
- 无 PTY:
tea.WithInput(s)+tea.WithOutput(s)+tea.WithEnvironment(envs); - 模拟 PTY(
EmulatedPty):额外强制tea.WithColorProfile(colorprofile.Env(envs))并设置窗口尺寸; - 真实 PTY:以
pty.Slave作为输入输出,并设置环境变量与窗口尺寸。
这套逻辑正是升级指南强调"MakeOptions(s)仍为 SSH I/O 所必需"的底层原因——它能同时处理好真实与模拟 PTY 两种会话形态。
Bubble Tea v2 的新消息体系
升级到 v2 后,SSH 应用自动获得 Bubble Tea v2 的全部能力,其中最显著的是消息类型拆分。
按键消息:KeyPressMsg 与 KeyReleaseMsg
按键消息拆分为KeyPressMsg和KeyReleaseMsg:
// Before case tea.KeyMsg: switch msg.String() { case " ": // space } // After case tea.KeyPressMsg: switch msg.String() { case "space": // 注意:"space" 而不是 " " // space case "shift+enter": // 现在可以实现了! }注意两处细节:一是空格的字符串表示由" "变为"space";二是组合键(如shift+enter)现在可以直接匹配。
鼠标消息:按类型拆分
鼠标消息按事件类型拆分为三个独立消息:
// Before case tea.MouseMsg: switch msg.Type { case tea.MouseLeft: // click } // After case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { // click } case tea.MouseWheelMsg: // scroll case tea.MouseMotionMsg: // movement粘贴事件:独立 PasteMsg
粘贴事件成为独立消息类型,不再作为KeyMsg上的Paste标志:
// Before case tea.KeyMsg: if msg.Paste { // paste } // After case tea.PasteMsg: m.text += msg.Content剪贴板支持:OSC52 在 SSH 下也可用
v2 支持读写剪贴板(OSC52 转义序列在 SSH 会话中同样有效):
case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": return m, tea.SetClipboard("Copied text") case "ctrl+v": return m, tea.ReadClipboard() } case tea.ClipboardMsg: m.clipboard = msg.String()日志中间件的变化
结构化日志中间件的签名变化主要体现在log.Logger类型来源的迁移:
// Before import "github.com/charmbracelet/log" logging.StructuredMiddlewareWithLogger(logger, log.InfoLevel) // After import "charm.land/log/v2" logging.StructuredMiddlewareWithLogger(logger, log.InfoLevel)log.Logger类型现在来自charm.land/log/v2。查看 logging/logging.go 源码可以看到 v2 的完整 API:Middleware()与MiddlewareWithLogger(Logger)提供非结构化的连接日志(记录用户名、远端地址、是否公钥认证、命令、TERM、窗口尺寸、客户端版本与连接时长);StructuredMiddleware()与StructuredMiddlewareWithLogger(logger *log.Logger, level log.Level)提供结构化日志,默认使用log.Default()与log.InfoLevel。
完整示例:典型 Wish 应用的 Before/After
Before (v1)
package main import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/charmbracelet/wish/logging" ) func main() { s, _ := wish.NewServer( wish.WithAddress(":2222"), wish.WithMiddleware( bubbletea.Middleware(teaHandler), logging.Middleware(), ), ) s.ListenAndServe() } func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { renderer := bubbletea.MakeRenderer(s) style := renderer.NewStyle().Foreground(lipgloss.Color("10")) m := model{style: style} return m, []tea.ProgramOption{tea.WithAltScreen()} } type model struct { style lipgloss.Style } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "q" { return m, tea.Quit } } return m, nil } func (m model) View() string { return m.style.Render("Hello, SSH!\n\nPress 'q' to quit") }After (v2)
package main import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/wish/v2" "charm.land/wish/v2/bubbletea" "charm.land/wish/v2/logging" "github.com/charmbracelet/ssh" ) func main() { s, _ := wish.NewServer( wish.WithAddress(":2222"), wish.WithMiddleware( bubbletea.Middleware(teaHandler), logging.Middleware(), ), ) s.ListenAndServe() } func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { style := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) m := model{style: style} return m, nil } type model struct { style lipgloss.Style } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if msg.String() == "q" { return m, tea.Quit } } return m, nil } func (m model) View() tea.View { v := tea.NewView(m.style.Render("Hello, SSH!\n\nPress 'q' to quit")) v.AltScreen = true return v }注意:ssh包在 v2 中仍来自github.com/charmbracelet/ssh原路径(不随 Wish 模块移动),这与升级指南中的写法一致。
关键变化清单
- 导入路径使用
charm.land/*/v2; MakeRenderer已删除——直接使用 Lip Gloss;tea.WithAltScreen()迁移到v.AltScreen = true;View()返回tea.View而非string;tea.KeyMsg改为tea.KeyPressMsg。
如果你需要更完整的参考,仓库的 examples 目录提供了按难度排列的 16 个示例,其中 examples/bubbletea/main.go 展示了 v2 下完整的 SSH + Bubble Tea 应用(含优雅关闭、activeterm.Middleware()强制 PTY、logging.Middleware()日志),examples/simple/main.go 则是最小化的中间件链路示例。
迁移检查清单
- 更新
go.mod,requirecharm.land/wish/v2; - 将所有导入路径更新为
charm.land/*; - 删除
bubbletea.MakeRenderer()调用; - 删除
MiddlewareWithColorProfile()的使用; - 将
View() string改为View() tea.View; - 将 Program 选项迁移到 view 字段(
v.AltScreen等); - 将
tea.KeyMsg更新为tea.KeyPressMsg; - 将
tea.MouseMsg更新为具体的鼠标消息类型; - 通过
tea.BackgroundColorMsg处理背景色; - 通过
tea.ColorProfileMsg处理颜色配置; - 在多种终端上测试你的 SSH 应用。
在 SSH 应用中获得客户端环境变量
Bubble Tea v2 中可以通过两种方式访问 SSH 客户端的环境变量。
重要前提:当你使用bubbletea.MakeOptions()时,Wish 会自动把客户端的运行环境传递给 Bubble Tea。这意味着tea.EnvMsg中包含的是客户端的环境,而不是服务端的!
方法一:使用 tea.EnvMsg(推荐)
Bubble Tea v2 会自动发送携带客户端环境的EnvMsg:
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { return model{}, bubbletea.MakeOptions(s) // 传递客户端环境 } type model struct { envMsg tea.EnvMsg } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.EnvMsg: m.envMsg = msg // 访问具体的客户端变量 term := msg.Getenv("TERM") lang := msg.Getenv("LANG") user := msg.Getenv("USER") fmt.Printf("Client TERM: %s\n", term) } return m, nil }这一机制的底层支撑可以在 bubbletea/tea_unix.go 的makeOpts中看到:envs := s.Environ()取自 SSH 会话,随后通过tea.WithEnvironment(envs)注入 Program,并在真实/模拟 PTY 场景下补上TERM=<pty.Term>。
方法二:在 Handler 中提取
如果需要在Init()运行之前就拿到环境变量,可以在 Handler 里直接从ssh.Session提取:
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { // 从 SSH 会话获取客户端环境变量 env := make(map[string]string) for _, e := range s.Environ() { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { env[parts[0]] = parts[1] } } m := model{ env: env, // 传给模型 } return m, bubbletea.MakeOptions(s) } type model struct { env map[string]string } func (m model) View() tea.View { // 访问客户端的环境 term := m.env["TERM"] // 客户端的 TERM lang := m.env["LANG"] // 客户端的 LANG user := m.env["USER"] // 客户端的 USER return tea.NewView(fmt.Sprintf("Your TERM: %s", term)) }⚠️警告:不要在 SSH 应用中使用
os.Getenv()——它返回的是服务端的环境!请始终使用tea.EnvMsg(推荐)或ssh.Session.Environ()。
关键点:这里拿到的是客户端的环境变量,而不是服务端的。这对 SSH 应用尤为重要——在服务端执行os.Getenv()会得到完全错误的值。
小结
Wish v2 升级的实质是一次"职责上移":终端探测、颜色配置、窗口尺寸等底层细节从应用代码下沉到 Wish 中间件与 Bubble Tea 运行时,应用层只需拥抱tea.View声明式视图与新的消息类型。按照本文的迁移清单逐项核对,配合 examples 目录中的可运行示例,绝大多数应用都能在短时间内平滑升级到 v2。
- 后端
【免费下载链接】wish
Make SSH apps, just like that! 💫
相关推荐
Bubble Tea v2 升级迁移完全指南:从命令式选项到声明式 View 架构
Bubble Tea v2 升级迁移完全指南:从命令式选项到声明式 View 架构 本篇指南以 UPGRADE_GUIDE_V2.md https://link
机器学习深度学习数据可视化可观测性macOS 菜单栏管理完全指南:3分钟装好 Ice,把拥挤的菜单栏整理成三區
macOS 菜单栏管理完全指南:3分钟装好 Ice,把拥挤的菜单栏整理成三區 Ice 是一款免费开源的 macOS 菜单栏管理工具,它把屏幕顶部的菜单栏拆成"可
桌面应用10分钟如何搭建属于自己的知识库与知识图谱问答系统?Yuxi-Know完整指南
10分钟如何搭建属于自己的知识库与知识图谱问答系统?Yuxi Know完整指南 企业里的流程文档、产品手册、技术方案散落在各个系统里,要查一个问题得翻半天;直接
人工智能大模型AI AgentRAG多智能体知识图谱后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考