在 SSH 环境中舒服地使用 Claude Code:Tabby + Herdr 完整配置

8497 字
42 分钟
在 SSH 环境中舒服地使用 Claude Code:Tabby + Herdr 完整配置

概述#

在 Windows 本机通过 SSH 连接远程 macOS 或 Linux,并在远端运行 Claude Code 时,常见体验并不理想:

  • Shift + Enter 会直接发送消息,无法自然换行。
  • Windows 剪贴板里的截图不能直接粘贴给远端 Claude Code。
  • SSH 窗口关闭后,正在运行的 Claude Code 会话容易丢失。
  • 同时运行多个 Agent 时,很难判断哪个正在工作、哪个已经完成或等待输入。

本文使用下面这套组合改善远程开发体验:

Windows 本机
└─ Tabby
├─ tabby-clipboard-image:跨 SSH 粘贴图片
├─ tabby-nv-shift-enter:Shift + Enter 换行
└─ SSH
└─ 远端 macOS / Linux
└─ Herdr
└─ Claude Code
└─ statusline.mjs:双行底栏状态

最终可以实现:

Shift + Enter → 在 Claude Code 中换行
Tabby 粘贴快捷键 → 上传截图并插入远端路径
Status Line → 显示路径、Git、上下文、模型与 Token 用量
关闭 Tabby → Herdr、Claude Code 和其他进程继续运行
重新执行 herdr → 恢复之前的工作现场
安装位置

两个 Tabby 插件安装在 Windows 本机;Herdr、Claude Code 和 statusline.mjs 安装在代码实际所在的远端 macOS 或 Linux 机器上。

组件安装位置作用
TabbyWindows 本机SSH 终端和本地输入入口
tabby-clipboard-imageWindows 本机将本地剪贴板图片上传到远端并粘贴为附件或路径
tabby-nv-shift-enterWindows 本机Shift + Enter 提供纯换行
Herdr远端机器保持会话并管理多个 Agent Pane
Claude Code远端机器在远端项目中执行开发任务
statusline.mjs远端机器Claude Code 双行底栏:路径、Git、上下文与用量

配置 Tabby SSH 连接#

在 Tabby 中创建一个真正的 SSH 配置:

Tabby
→ 设置
→ 配置和连接
→ 新建配置
→ SSH 连接

填写远端连接信息:

主机:远端机器的 IP 或域名
用户名:远端用户名
端口:22
认证:密码或私钥

以后直接从 Tabby 的配置列表打开这个 SSH 连接。

不要使用下面这种方式:

Tabby 本地 PowerShell
→ 手动执行 ssh user@server
→ 进入远端

图片插件必须取得由 Tabby 管理的 SSH 会话和 SFTP 对象。手动在本地终端里再执行一层 ssh 时,Tabby 仍然把当前标签视为本地终端,插件无法直接通过当前会话上传文件。

此外还需要确保:

  • 当前活动标签是 Tabby 内置 SSH 标签页。
  • SSH 已经完成连接。
  • 远端 SSH 服务支持 SFTP。
  • 远端 /tmp 目录可写。

修复 Tabby 字符渲染异常#

部分 Windows 显卡驱动或 GPU 环境中,Tabby 默认的 xterm (WebGL) 前端会把终端字符渲染错位、发虚、叠字,或者让 Nerd Font 图标与底栏错位。

如果出现这类问题,把终端前端从 WebGL 改成 Canvas:

Tabby
→ 设置
→ 终端
→ 渲染
→ 前端

将:

xterm (WebGL)

改为:

xterm (canvas)

Tabby 界面中这一项通常显示为:

前端
切换终端前端实现(实验性)
→ xterm (WebGL)
→ xterm (canvas)

保存后重新打开终端标签页,或彻底退出并重启 Tabby。改用 Canvas 后,Claude Code 底栏、Herdr 侧栏和普通命令输出中的字符通常会恢复正常。

优先改渲染,而不是先换字体

如果字体本身已是 Nerd Font,但字符仍错位或发糊,优先切换 xterm (canvas)。只有图标显示成方块或缺字时,才需要先检查字体配置。

在远端安装 Herdr#

Herdr 是一个运行在终端中的 Agent 多路复用器,可以管理多个 Claude Code、Codex 或其他 Agent Pane,并让会话独立于当前 SSH 窗口持续运行。

Herdr 应安装在代码和 Claude Code 实际运行的远端机器上。

使用官方脚本安装#

在远端 macOS 或 Linux 中执行:

Terminal window
curl -fsSL https://herdr.dev/install.sh | sh

安装脚本会根据当前平台和架构下载对应的稳定版二进制,并将其加入 PATH

使用 Homebrew 安装#

已经安装 Homebrew 时,也可以执行:

Terminal window
brew install herdr

验证安装:

Terminal window
herdr --version

Herdr 官方为 Linux 和 macOS 提供稳定版本;原生 Windows 版本目前属于预览状态。本文只在远端 macOS 或 Linux 中运行 Herdr。

启动并恢复会话#

进入项目目录:

Terminal window
cd "/path/to/project"

启动 Herdr:

Terminal window
herdr

Herdr 会创建或连接默认的后台会话。然后在 Pane 中启动 Claude Code:

Terminal window
claude

Herdr 会识别 Agent,并在侧栏显示 workingblockeddoneidle 等状态。

使用 Herdr 后,可以直接关闭 Tabby,或者主动断开客户端。Herdr 服务和 Pane 中的 Agent 仍会在远端运行。重新连接 SSH 后再次执行:

Terminal window
herdr

即可恢复之前的工作现场。

如果需要真正结束整个 Herdr 服务,执行:

Terminal window
herdr server stop

常用 Herdr 快捷键#

Herdr 的默认前缀键是:

Ctrl + B

文档中的 prefix + v 不是同时按下 Ctrl + B + V,而是按顺序操作:

按下 Ctrl + B
松开
再按 V

常用快捷键如下:

操作快捷键
向右分屏Ctrl + B,然后按 V
向下分屏Ctrl + B,然后按 -
切换 PaneCtrl + B,然后按 H/J/K/L
放大或还原当前 PaneCtrl + B,然后按 Z
断开客户端并保留后台会话Ctrl + B,然后按 Q
查看所有快捷键Ctrl + B,然后按 ?

这种 prefix-first 操作方式可以减少与 Claude Code、Shell 和编辑器快捷键的冲突。

安装图片粘贴插件#

tabby-clipboard-image 可以读取本机剪贴板中的图片,通过当前 Tabby SSH 会话的 SFTP 上传到远端 /tmp。默认情况下,它会让 Claude Code 将图片识别为原生附件;也可以切换为输入带引号的远端文件路径。

为什么普通 SSH 不能直接粘贴图片#

Claude Code 实际运行在远端机器上:

Windows 剪贴板
Tabby
↓ SSH
远端 macOS / Linux
Claude Code

普通文本可以通过终端发送,但图片剪贴板不会自动通过 SSH 传输。

直接粘贴时可能提示:

No image found in clipboard

也可能出现 Windows 本地路径:

C:\Users\你的用户名\AppData\Local\Temp\image.png

这个路径只存在于 Windows,远端机器无法访问。

图片插件采用的方式是:

读取本机剪贴板图片
→ 转换为 PNG
→ 通过当前 SSH 会话上传
→ 在远端生成真实 PNG 文件
→ 默认通过 Bracketed Paste 让 Claude Code 识别为原生附件

插件还提供 Remote Path 模式,可将带双引号的远端绝对路径作为普通文本输入。

通过插件管理器安装#

打开:

Tabby
→ 设置
→ 插件

搜索:

tabby-clipboard-image

安装后彻底退出并重新启动 Tabby。

重新进入:

设置 → 插件 → Clipboard Image

建议确认:

Enable image paste:开启
Paste mode:Native Attachment
Fall back to Remote Path:按需开启
Show notifications:开启
Advanced → Remote upload directory:/tmp

使用默认的原生附件模式时,还需要保持 Tabby 的以下选项开启:

设置 → 终端 → Bracketed paste

插件要求 Tabby 桌面版 1.0.230 或更高版本,不支持 Tabby 网页版。

正确理解图片粘贴快捷键#

图片插件监听的是 Tabby 的 paste 粘贴动作,并没有硬编码一个独立的插件快捷键。

Tabby 在 Windows 上默认使用:

Ctrl + Shift + V

作为粘贴快捷键。因此默认配置下按 Ctrl + Shift + V 会触发插件;如果修改过 Tabby 的粘贴快捷键,应使用修改后的快捷键。

插件触发后会检查剪贴板:

  • 有图片:上传图片,并按当前模式粘贴为原生附件或远端路径。
  • 没有图片:交还给 Tabby 执行普通文本粘贴。

使用 npm 手动安装#

如果插件管理器中暂时搜索不到插件,可以从 npm 安装到 Tabby 的用户插件目录。不要使用 npm install -g

彻底关闭 Tabby:

Terminal window
Get-Process Tabby -ErrorAction SilentlyContinue |
Stop-Process -Force

进入插件目录:

Terminal window
Set-Location "$env:APPDATA\tabby\plugins"

安装插件:

Terminal window
npm install --no-save tabby-clipboard-image

验证入口文件:

Terminal window
Test-Path `
".\node_modules\tabby-clipboard-image\dist\index.js"

正常应返回:

True

也可以检查安装版本:

Terminal window
npm ls tabby-clipboard-image --depth=0

本文使用的版本为 [email protected]。安装后重新启动 Tabby;插件默认启用原生附件模式、关闭自动降级、开启通知,并将 /tmp 设为远端上传目录。

使用图片粘贴#

首先通过 Tabby 保存的 SSH 配置连接远端,然后启动 Herdr 和 Claude Code。

在 Windows 上截图:

Win + Shift + S

回到 Claude Code,按下 Tabby 当前配置的粘贴快捷键。默认情况下是:

Ctrl + Shift + V

插件会完成:

Windows 剪贴板图片
→ PNG
→ SFTP
→ 远端 /tmp/clipboard_<时间戳>.png
→ 默认作为 Claude Code 原生附件粘贴

默认的 Native Attachment 模式会让输入框显示:

[Image #1]

继续补充问题即可:

请查看这张截图,分析其中的报错:[Image #1]

如果需要看到远端路径,可在插件设置中将 Paste mode 改为 Remote Path。输入框会出现:

"/tmp/clipboard_1785200000000.png"

必要时可以改用明确的文件引用:

@/tmp/clipboard_1785200000000.png

图片插件无效时检查#

确认当前标签是 Tabby 直接建立的 SSH 会话,而不是本地 PowerShell 中手动执行的 ssh

在远端检查 /tmp 是否可写:

Terminal window
touch /tmp/tabby-image-test &&
rm /tmp/tabby-image-test

检查上传后的图片:

Terminal window
file /tmp/clipboard_*.png

正常应显示:

PNG image data

如果粘贴快捷键仍然只粘贴普通文字,通常说明剪贴板中没有真实图片,或者插件没有取得当前 SSH 会话。建议重新使用 Win + Shift + S 截图后立即粘贴。

如果图片已上传但没有显示为 [Image #1],请确认 Paste modeNative Attachment,并开启 Tabby 的 设置 → 终端 → Bracketed paste。也可以开启 Fall back to Remote Path,让原生附件失败时改为粘贴远端路径。

安装 NV Shift Enter#

Claude Code 可以使用:

Ctrl + J

插入换行,但多数聊天软件使用 Shift + Enter,频繁切换并不自然。

tabby-nv-shift-enter 可以让 Tabby 在按下 Shift + Enter 时发送自定义内容或纯换行。

插件提供两种行为:

反斜杠 + 换行
纯换行

当前 1.0.1 版本依赖 Tabby 1.0.197-nightly.1 或更高版本。

通过插件管理器安装#

在 Tabby 插件页面搜索:

tabby-nv-shift-enter

安装并彻底重启 Tabby。插件管理器正常工作时,不需要手动安装。

手动安装预编译插件包#

直接执行:

Terminal window
npm install tabby-nv-shift-enter

可能再次触发 tabby-sshpostinstall 错误。

这种情况下,可以只下载并解压已经编译好的插件包,不安装它声明的 Tabby 宿主依赖。

彻底关闭 Tabby:

Terminal window
Get-Process Tabby -ErrorAction SilentlyContinue |
Stop-Process -Force

然后执行:

Terminal window
$pluginRoot = "$env:APPDATA\tabby\plugins"
$target = Join-Path $pluginRoot "node_modules\tabby-nv-shift-enter"
Set-Location $pluginRoot
Remove-Item $target `
-Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory `
-Path $target -Force | Out-Null
$tgz = (
npm pack tabby-nv-shift-[email protected] --silent |
Select-Object -Last 1
).Trim()
if (-not $tgz -or -not (Test-Path $tgz)) {
throw "插件包下载失败"
}
tar -xzf $tgz `
-C $target `
--strip-components=1
if ($LASTEXITCODE -ne 0) {
throw "插件解压失败"
}
Remove-Item $tgz -Force
if (Test-Path "$target\dist\index.js") {
Write-Host "安装成功:$target"
} else {
throw "安装失败:缺少 dist\index.js"
}

插件 1.0.1 的发布包入口是 dist/index.js,并且发布内容中包含已经构建好的 dist 目录,因此这种解压方式不需要本地编译。

配置纯换行#

打开:

Tabby
→ 设置
→ NV Shift Enter

关闭:

Include backslash before newline

然后进入:

设置
→ 快捷键

找到:

Send configured custom text

绑定为:

Shift + Enter

最终效果:

Shift + Enter → Claude Code 输入框内换行
Enter → 发送消息

为什么换行时会出现反斜杠#

插件默认开启:

Include backslash before newline

这个模式主要用于 Bash 或 Zsh 命令续行,会发送:

反斜杠 + 换行

在 Claude Code 输入框中可能表现为:

第一行
第二行 \
第三行

这不是 Claude Code 自动添加的字符,而是插件主动发送的内容。关闭该选项后,插件只发送纯换行。

配置 Claude Code 底栏#

Claude Code 支持通过自定义命令渲染底部状态栏。本文使用 ~/.claude/statusline.mjs,以双行布局显示当前工作区、Git 状态、上下文占用、模型与 Token 用量。

这套底栏配置改自 pi-open-tui

底栏会显示什么#

脚本从 Claude Code 的 stdin JSON 读取会话状态,再结合当前工作区信息输出两行:

第一行 左:当前目录 · Git 分支/变更 · 运行时
右:上下文进度条 · 占用百分比 · 已用/窗口 Token
第二行 左:模型 ID · thinking / effort · output style
右:输入 Token · 输出 Token · Cache Hit · 累计费用

示意效果类似:

 ~/code/luna  main  26.4.0  [███░░░░░░░░░] 22.5% · 45k/200k
 · claude-sonnet-4 ·  high · ✦ Explanatory  45k |  0 |  82% |  $1.234

主要信息来源:

区块来源说明
当前目录workspace.current_dir / cwd家目录会显示为 ~/...,过长时按路径段折叠
Gitgit status --porcelain=v1 --branch --show-stash分支、ahead/behind、冲突、删除、修改、重命名、暂存、未跟踪、stash
运行时工作区特征文件例如 package.jsonCargo.toml.python-version 等,并尽量读取版本号
上下文context_window进度条、占用百分比、已用 Token / 窗口大小
模型、effort 与 stylemodeleffort.leveloutput_style.name显示模型 ID、非 off 的 thinking 等级,以及当前 output style
用量与费用transcript / cost.total_cost_usd输入、输出、最近 cache hit 率、累计费用

脚本还会:

  • 用 Catppuccin Mocha 真彩色配色。
  • 按终端宽度自动压缩低优先级字段,避免换行错乱。
  • 用 Nerd Font 图标;图标按单列宽计算,与 Claude Code 的 Bun.stringWidth 行为对齐。
  • 缓存 Git 与运行时探测结果约 5 秒,降低刷新开销。

前置条件#

底栏脚本安装在 Claude Code 实际运行的远端机器上,需要:

  • 远端已安装 Node.js,并能执行 node
  • 终端字体支持 Nerd Font 图标,例如 JetBrainsMono Nerd FontMesloLGS NF
  • 终端支持真彩色。
  • 当前工作区如果是 Git 仓库,远端 PATH 中可执行 git

在 Tabby 中确认当前 SSH 配置使用的字体已经切换为 Nerd Font。否则底栏图标会显示为方块或缺字。

写入 settings.json#

编辑远端 Claude Code 配置:

Terminal window
nano ~/.claude/settings.json

加入或合并下面的 statusLine 字段。如果文件里已有其他配置,只追加这一段即可,不要覆盖整个文件:

{
"statusLine": {
"type": "command",
"command": "node ~/.claude/statusline.mjs",
"padding": 0,
"refreshInterval": 2
}
}

字段含义:

字段作用
typecommand通过外部命令渲染底栏
commandnode ~/.claude/statusline.mjs每次刷新时执行的脚本
padding0去掉底栏额外内边### 安装 statusline.mjs

将自定义底栏脚本放到远端:

Terminal window
mkdir -p ~/.claude
nano ~/.claude/statusline.mjs

把下面的完整脚本写入 ~/.claude/statusline.mjs 后保存:

#!/usr/bin/env node
import {
closeSync,
existsSync,
openSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { homedir, tmpdir } from "node:os";
import { join, relative, resolve, sep } from "node:path";
import { WriteStream } from "node:tty";
const FALLBACK_COLUMNS = 120;
const PROJECT_CACHE_SECONDS = 5;
// Claude Code 用 Bun.stringWidth 量宽;任一行超出终端宽度会截断/错乱。
// 预留边距覆盖容器 inset;图标宽度必须与 Bun.stringWidth 一致(1 列)。
const STATUSLINE_RESERVED_COLUMNS = 4;
const STATUSLINE_MIN_COLUMNS = 60;
const G = {
cwd: "",
git: "",
context: "",
model: "",
thinking: "",
style: "✦",
input: "",
output: "",
cacheHit: "",
cost: "",
ahead: "↑",
behind: "↓",
diverged: "⇕",
conflicted: "=",
stashed: "$",
modified: "!",
staged: "+",
untracked: "?",
renamed: "»",
deleted: "✘",
};
// 接近 pi-open-tui 预览效果的 Catppuccin Mocha 配色。
const RESET = "\x1b[0m";
const paint = (hex, text) => {
const value = hex.replace("#", "");
const r = Number.parseInt(value.slice(0, 2), 16);
const g = Number.parseInt(value.slice(2, 4), 16);
const b = Number.parseInt(value.slice(4, 6), 16);
return `\x1b[38;2;${r};${g};${b}m${text}${RESET}`;
};
const C = {
accent: (s) => paint("#cba6f7", s),
link: (s) => paint("#89b4fa", s),
text: (s) => paint("#cdd6f4", s),
muted: (s) => paint("#a6adc8", s),
dim: (s) => paint("#6c7086", s),
success: (s) => paint("#a6e3a1", s),
warning: (s) => paint("#f9e2af", s),
error: (s) => paint("#f38ba8", s),
low: (s) => paint("#a6e3a1", s),
medium: (s) => paint("#89b4fa", s),
high: (s) => paint("#cba6f7", s),
xhigh: (s) => paint("#f5c2e7", s),
max: (s) => paint("#f38ba8", s),
};
const ANSI_RE = /\x1b\[[0-9;]*m/g;
const stripAnsi = (text) => text.replace(ANSI_RE, "");
function charWidth(codePoint) {
if (
codePoint === 0 ||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
) {
return 0;
}
// Claude Code measures with Bun.stringWidth({ ambiguousIsNarrow: true }).
// Nerd Font / PUA glyphs count as 1 there — do NOT treat them as double-width,
// or multi-line right edges drift (more icons => more padding error).
if (
codePoint >= 0x1100 &&
(
codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(
codePoint >= 0x2e80 &&
codePoint <= 0xa4cf &&
codePoint !== 0x303f
) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
)
) {
return 2;
}
return 1;
}
function visibleWidth(text) {
let width = 0;
for (const ch of stripAnsi(text)) {
width += charWidth(ch.codePointAt(0));
}
return width;
}
function truncateAnsi(text, maxWidth, ellipsis = "...") {
if (maxWidth <= 0) {
return "";
}
if (visibleWidth(text) <= maxWidth) {
return text;
}
const ellipsisWidth = visibleWidth(ellipsis);
if (maxWidth <= ellipsisWidth) {
return C.dim(ellipsis.slice(0, maxWidth));
}
const target = maxWidth - ellipsisWidth;
let output = "";
let width = 0;
for (let i = 0; i < text.length;) {
if (text[i] === "\x1b") {
const match = text.slice(i).match(/^\x1b\[[0-9;]*m/);
if (match) {
output += match[0];
i += match[0].length;
continue;
}
}
const codePoint = text.codePointAt(i);
const ch = String.fromCodePoint(codePoint);
const nextWidth = charWidth(codePoint);
if (width + nextWidth > target) {
break;
}
output += ch;
width += nextWidth;
i += ch.length;
}
return `${output}${RESET}${C.dim(ellipsis)}`;
}
function alignRight(left, right, width) {
if (!right) {
return truncateAnsi(left, width);
}
let rightText = right;
let rightWidth = visibleWidth(rightText);
if (rightWidth > width) {
rightText = truncateAnsi(rightText, width);
rightWidth = visibleWidth(rightText);
}
const maxLeft = Math.max(0, width - rightWidth);
let leftText = left;
let leftWidth = visibleWidth(leftText);
if (leftWidth > maxLeft) {
// Keep a gap when both sides are present.
const budget = rightWidth > 0 && maxLeft > 0
? Math.max(0, maxLeft - 1)
: maxLeft;
leftText = truncateAnsi(leftText, budget);
leftWidth = visibleWidth(leftText);
}
const pad = Math.max(0, width - leftWidth - rightWidth);
if (pad > 0) {
return leftText + " ".repeat(pad) + rightText;
}
if (!leftText) {
return rightText;
}
if (!rightText) {
return leftText;
}
// Exact fit or 0-gap squeeze.
return leftWidth + rightWidth <= width
? leftText + rightText
: `${leftText} ${rightText}`;
}
// Force every statusline row to the same measured width so multi-line
// right edges stay locked together.
function finalizeStatusLines(lines, width) {
return lines.map((line) => {
const current = visibleWidth(line);
if (current === width) {
return line;
}
if (current < width) {
return line + " ".repeat(width - current);
}
return truncateAnsi(line, width);
});
}
function fitSegmentsByPriority(segments, maxWidth) {
const items = segments.map((item) => ({
...item,
width: visibleWidth(item.text),
}));
const totalWidth = () => {
const active = items.filter((item) => item.text);
return (
active.reduce((sum, item) => sum + item.width, 0) +
Math.max(0, active.length - 1)
);
};
while (totalWidth() > maxWidth) {
let target = -1;
for (let i = 0; i < items.length; i++) {
if (
items[i].text &&
(
target === -1 ||
items[i].priority < items[target].priority
)
) {
target = i;
}
}
if (target === -1) {
break;
}
const others = items.filter(
(item, index) => index !== target && item.text,
);
const otherWidth =
others.reduce((sum, item) => sum + item.width, 0) +
Math.max(0, others.length - 1);
const available =
maxWidth -
otherWidth -
(others.length ? 1 : 0);
if (available <= 3) {
items[target].text = "";
items[target].width = 0;
} else if (available < items[target].width) {
items[target].text = truncateAnsi(
items[target].text,
available,
);
items[target].width = visibleWidth(
items[target].text,
);
} else {
break;
}
}
return items
.filter((item) => item.text)
.map((item) => item.text);
}
function fitJoinedSegmentsByPriority(
segments,
maxWidth,
separator = ` ${C.dim("|")} `,
) {
const sepWidth = visibleWidth(separator);
const items = segments.map((item) => ({
...item,
width: visibleWidth(item.text),
}));
const totalWidth = () => {
const active = items.filter((item) => item.text);
return (
active.reduce((sum, item) => sum + item.width, 0) +
Math.max(0, active.length - 1) * sepWidth
);
};
while (totalWidth() > maxWidth) {
let target = -1;
for (let i = 0; i < items.length; i++) {
if (
items[i].text &&
(target === -1 || items[i].priority < items[target].priority)
) {
target = i;
}
}
if (target === -1) {
break;
}
const others = items.filter(
(item, index) => index !== target && item.text,
);
const otherWidth =
others.reduce((sum, item) => sum + item.width, 0) +
Math.max(0, others.length - 1) * sepWidth;
const available =
maxWidth -
otherWidth -
(others.length ? sepWidth : 0);
if (available <= 3) {
items[target].text = "";
items[target].width = 0;
} else if (available < items[target].width) {
items[target].text = truncateAnsi(
items[target].text,
available,
);
items[target].width = visibleWidth(items[target].text);
} else {
break;
}
}
return items
.filter((item) => item.text)
.map((item) => item.text)
.join(separator);
}
function saneColumns(value) {
const cols = Number(value);
return Number.isFinite(cols) && cols > 20
? Math.floor(cols)
: null;
}
function columnsFromFd(fd) {
try {
const ws = new WriteStream(fd);
const cols = saneColumns(ws.columns);
ws.destroy?.();
return cols;
} catch {
return null;
}
}
function columnsFromControllingTty() {
// statusline 子进程的 stdin/stdout 通常是 pipe,
// 必须直接问控制终端才能拿到真实宽度。
try {
const fd = openSync("/dev/tty", "r+");
try {
return columnsFromFd(fd);
} finally {
try {
closeSync(fd);
} catch {
// ignore
}
}
} catch {
return null;
}
}
function columnsFromSttyDevice(devicePath) {
if (!devicePath) {
return null;
}
const commands = process.platform === "darwin"
? [
`stty -f ${devicePath} size 2>/dev/null`,
`stty size < ${devicePath} 2>/dev/null`,
]
: [
`stty -F ${devicePath} size 2>/dev/null`,
`stty size < ${devicePath} 2>/dev/null`,
];
for (const command of commands) {
try {
const output = execSync(command, {
encoding: "utf8",
shell: "/bin/sh",
stdio: ["ignore", "pipe", "ignore"],
timeout: 300,
}).trim();
// stty size -> "rows cols"
const cols = saneColumns(output.split(/\s+/)[1] ?? output);
if (cols !== null) {
return cols;
}
} catch {
// try next form
}
}
return null;
}
function normalizeTtyName(tty) {
if (!tty || tty === "??" || tty === "-") {
return null;
}
if (tty.startsWith("/dev/")) {
return tty;
}
// ps may report "ttys011" or "pts/3"
return `/dev/${tty}`;
}
function columnsFromParentProcessTree() {
// Claude Code 通过 pipe 跑 statusline 时,当前进程可能没有
// controlling tty,但父进程(claude/pi)通常挂在真实终端上。
let pid = process.ppid;
for (let depth = 0; depth < 8 && pid > 1; depth++) {
try {
const output = execFileSync(
"ps",
["-o", "ppid=,tty=", "-p", String(pid)],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 300,
},
).trim();
if (!output) {
break;
}
const match = output.match(/^\s*(\d+)\s+(\S+)\s*$/);
if (!match) {
break;
}
const parentPid = Number(match[1]);
const device = normalizeTtyName(match[2]);
const cols = columnsFromSttyDevice(device);
if (cols !== null) {
return cols;
}
pid = parentPid;
} catch {
break;
}
}
return null;
}
function terminalColumns() {
// 优先控制终端 / 父进程终端,这才是 Claude Code 里真实窗口宽度。
const ttyCols =
columnsFromControllingTty() ??
columnsFromSttyDevice("/dev/tty") ??
columnsFromParentProcessTree();
if (ttyCols !== null) {
return ttyCols;
}
const envCols = saneColumns(process.env.COLUMNS);
if (envCols !== null) {
return envCols;
}
const streamCols =
saneColumns(process.stdout?.columns) ??
saneColumns(process.stderr?.columns);
if (streamCols !== null) {
return streamCols;
}
// tput 在无 TTY 时常返回 80,不可信;这里不再使用。
return FALLBACK_COLUMNS;
}
function formatCwd(cwd) {
const home = homedir();
const resolvedCwd = resolve(cwd);
const rel = relative(resolve(home), resolvedCwd);
const insideHome =
rel === "" ||
(
rel !== ".." &&
!rel.startsWith(`..${sep}`)
);
if (!insideHome) {
return cwd;
}
return rel === ""
? "~"
: `~${sep}${rel}`;
}
function truncatePath(value, maxLength) {
if (value.length <= maxLength) {
return value;
}
if (maxLength <= 3) {
return "...".slice(0, maxLength);
}
const separator = value.includes("/")
? "/"
: "\\";
const parts = value.split(/[\\/]/);
if (parts.length <= 2) {
return value.slice(0, maxLength - 3) + "...";
}
const tail = [];
let tailLength = 0;
for (let i = parts.length - 1; i >= 1; i--) {
const part = parts[i];
if (
tailLength +
part.length +
4 >
maxLength
) {
break;
}
tail.unshift(part);
tailLength += part.length + 1;
}
const result =
`${parts[0]}${separator}...${separator}${tail.join(separator)}`;
return result.length > maxLength
? result.slice(0, maxLength - 3) + "..."
: result;
}
function fmtTokens(value) {
const n = Number(value) || 0;
if (n < 1_000) {
return String(Math.round(n));
}
if (n < 10_000) {
return `${(n / 1_000).toFixed(1)}k`;
}
if (n < 1_000_000) {
return `${Math.round(n / 1_000)}k`;
}
if (n < 10_000_000) {
return `${(n / 1_000_000).toFixed(1)}M`;
}
return `${Math.round(n / 1_000_000)}M`;
}
function stressColor(value) {
if (value >= 90) {
return C.error;
}
if (value >= 70) {
return C.warning;
}
return C.accent;
}
function cacheHitColor(value) {
if (value < 30) {
return C.error;
}
if (value < 70) {
return C.warning;
}
return C.success;
}
function effortColor(level) {
return C[level] ?? C.medium;
}
function renderBar(percent, width = 12) {
const filled = Math.max(
0,
Math.min(
width,
Math.round((percent / 100) * width),
),
);
const color = stressColor(percent);
return (
C.dim("[") +
color("█".repeat(filled)) +
C.dim("░".repeat(width - filled)) +
C.dim("]")
);
}
function safeExec(
command,
args,
cwd,
timeout = 2_000,
) {
try {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout,
maxBuffer: 1024 * 1024,
});
} catch {
return null;
}
}
function emptyGitStatus() {
return {
branch: undefined,
ahead: 0,
behind: 0,
modified: 0,
untracked: 0,
staged: 0,
stashed: 0,
conflicted: 0,
renamed: 0,
deleted: 0,
commit: null,
};
}
function readGitStatus(cwd) {
const output = safeExec(
"git",
[
"status",
"--porcelain=v1",
"--branch",
"--show-stash",
],
cwd,
);
if (output === null) {
return emptyGitStatus();
}
const status = emptyGitStatus();
let sawStashLine = false;
for (const line of output.split("\n")) {
if (line.startsWith("## ")) {
const branchPart = line.slice(3);
if (
branchPart.startsWith(
"HEAD (no branch)",
)
) {
status.commit = {
detached: true,
oid: null,
tag: null,
};
} else {
status.branch =
branchPart
.split("...")[0]
.split(" ")[0];
status.ahead = Number(
branchPart.match(
/ahead (\d+)/,
)?.[1] ?? 0,
);
status.behind = Number(
branchPart.match(
/behind (\d+)/,
)?.[1] ?? 0,
);
}
continue;
}
if (line.startsWith("# stash ")) {
sawStashLine = true;
status.stashed =
Number.parseInt(
line.slice(8).trim(),
10,
) || 0;
continue;
}
if (line.length < 3) {
continue;
}
const x = line[0];
const y = line[1];
if (
x === "U" ||
y === "U" ||
(
x === "C" &&
y === "C"
)
) {
status.conflicted++;
} else if (
x === "?" &&
y === "?"
) {
status.untracked++;
} else if (x === "R") {
status.renamed++;
} else if (
x === "D" ||
y === "D"
) {
status.deleted++;
} else {
if (
x !== " " &&
x !== "?"
) {
status.staged++;
}
if (
y === "M" ||
y === "D"
) {
status.modified++;
}
}
}
if (!sawStashLine) {
const stashOutput = safeExec(
"git",
[
"stash",
"list",
"--count",
],
cwd,
);
status.stashed =
Number.parseInt(
stashOutput?.trim() ?? "0",
10,
) || 0;
}
if (status.commit?.detached) {
status.commit.oid =
safeExec(
"git",
[
"rev-parse",
"HEAD",
],
cwd,
)?.trim() || null;
status.commit.tag =
safeExec(
"git",
[
"describe",
"--tags",
"--exact-match",
"HEAD",
],
cwd,
)?.trim() || null;
}
return status;
}
function renderGit(status) {
const parts = [];
if (status.branch) {
parts.push(C.link(G.git));
parts.push(
C.link(
truncatePath(
status.branch,
20,
),
),
);
} else if (
status.commit?.detached
) {
parts.push(
C.warning(G.git),
C.warning("HEAD"),
);
if (status.commit.oid) {
const tag = status.commit.tag
? ` ${status.commit.tag}`
: "";
parts.push(
C.dim(
`${status.commit.oid.slice(0, 7)}${tag}`,
),
);
}
}
const changes = [];
const add = (
count,
glyph,
color,
) => {
if (count > 0) {
changes.push(
color(`${glyph}${count}`),
);
}
};
add(
status.conflicted,
G.conflicted,
C.error,
);
add(
status.deleted,
G.deleted,
C.error,
);
add(
status.modified,
G.modified,
C.warning,
);
add(
status.renamed,
G.renamed,
C.warning,
);
add(
status.staged,
G.staged,
C.success,
);
add(
status.untracked,
G.untracked,
C.muted,
);
add(
status.stashed,
G.stashed,
C.muted,
);
if (
status.ahead > 0 &&
status.behind > 0
) {
changes.push(
C.warning(
`${G.diverged}${status.ahead}/${status.behind}`,
),
);
} else if (status.ahead > 0) {
changes.push(
C.success(
`${G.ahead}${status.ahead}`,
),
);
} else if (status.behind > 0) {
changes.push(
C.warning(
`${G.behind}${status.behind}`,
),
);
}
if (changes.length) {
parts.push(
C.dim("[") +
changes.join(" ") +
C.dim("]"),
);
}
return parts.join(" ");
}
const RUNTIMES = [
{
name: "nodejs",
files: [
"package.json",
".nvmrc",
".node-version",
],
cmd: "node",
args: ["--version"],
pattern: /v(\d+\.\d+\.\d+)/,
},
{
name: "rust",
files: ["Cargo.toml"],
cmd: "rustc",
args: ["--version"],
pattern: /rustc\s+(\d+\.\d+\.\d+)/,
},
{
name: "go",
files: ["go.mod"],
cmd: "go",
args: ["version"],
pattern: /go(\d+\.\d+\.\d+)/,
},
{
name: "python",
files: [
"pyproject.toml",
"requirements.txt",
"setup.py",
"Pipfile",
".python-version",
],
cmd: "python3",
args: ["--version"],
pattern: /Python\s+(\d+\.\d+\.\d+)/,
},
{
name: "ruby",
files: [
"Gemfile",
".ruby-version",
],
cmd: "ruby",
args: ["--version"],
pattern: /ruby\s+(\d+\.\d+\.\d+)/,
},
{
name: "java",
files: [
"pom.xml",
"build.gradle",
"build.gradle.kts",
".java-version",
],
cmd: "java",
args: ["-version"],
pattern: /version\s+"(\d+\.\d+[.\d]*)"/,
},
{
name: "swift",
files: ["Package.swift"],
cmd: "swift",
args: ["--version"],
pattern: /Swift\s+(\d+\.\d+)/,
},
{
name: "kotlin",
files: [
"build.gradle.kts",
"settings.gradle.kts",
],
},
{
name: "cpp",
files: [
"CMakeLists.txt",
"Makefile",
],
},
{
name: "c",
files: [
"Makefile",
"CMakeLists.txt",
],
},
{
name: "deno",
files: [
"deno.json",
"deno.jsonc",
"deno.lock",
],
cmd: "deno",
args: ["--version"],
pattern: /deno\s+(\d+\.\d+\.\d+)/,
},
{
name: "bun",
files: [
"bun.lock",
"bun.lockb",
],
cmd: "bun",
args: ["--version"],
pattern: /(\d+\.\d+\.\d+)/,
},
{
name: "php",
files: ["composer.json"],
cmd: "php",
args: ["--version"],
pattern: /PHP\s+(\d+\.\d+\.\d+)/,
},
{
name: "haskell",
files: [
"stack.yaml",
"cabal.project",
".cabal",
],
cmd: "ghc",
args: ["--version"],
pattern: /(\d+\.\d+\.\d+)/,
},
{
name: "julia",
files: [
"Project.toml",
"Manifest.toml",
],
cmd: "julia",
args: ["--version"],
pattern: /julia\s+(\d+\.\d+\.\d+)/,
},
{
name: "lua",
files: [
"stylua.toml",
".luarc.json",
],
cmd: "lua",
args: ["-v"],
pattern: /Lua\s+(\d+\.\d+)/,
},
{
name: "elixir",
files: ["mix.exs"],
cmd: "elixir",
args: ["--version"],
pattern: /Elixir\s+(\d+\.\d+\.\d+)/,
},
{
name: "erlang",
files: [
"rebar.config",
"erlang.mk",
],
},
{
name: "gleam",
files: ["gleam.toml"],
cmd: "gleam",
args: ["--version"],
pattern: /gleam\s+(\d+\.\d+\.\d+)/,
},
{
name: "crystal",
files: ["shard.yml"],
cmd: "crystal",
args: ["--version"],
pattern: /Crystal\s+(\d+\.\d+\.\d+)/,
},
{
name: "dart",
files: ["pubspec.yaml"],
cmd: "dart",
args: ["--version"],
pattern:
/Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/,
},
{
name: "nim",
files: [
"nim.cfg",
".nimble",
],
},
{
name: "zig",
files: ["build.zig"],
cmd: "zig",
args: ["version"],
pattern: /(\d+\.\d+\.\d+)/,
},
{
name: "ocaml",
files: [
".opam",
"dune",
"dune-project",
],
},
{
name: "clojure",
files: [
"project.clj",
"deps.edn",
],
},
{
name: "scala",
files: [
"build.sbt",
".scala",
".metals",
],
},
{
name: "perl",
files: [
"Makefile.PL",
"cpanfile",
],
},
{
name: "r",
files: [
".Rproj",
"DESCRIPTION",
],
},
{
name: "elm",
files: ["elm.json"],
},
{
name: "haxe",
files: [
"haxelib.json",
".haxerc",
],
},
{
name: "vagrant",
files: ["Vagrantfile"],
},
{
name: "terraform",
files: [
"main.tf",
"variables.tf",
],
folders: [".terraform"],
},
{
name: "helm",
files: [
"Chart.yaml",
"helmfile.yaml",
],
},
{
name: "solidity",
files: [],
extensions: [".sol"],
},
{
name: "fortran",
files: ["fpm.toml"],
extensions: [
".f",
".f90",
".f95",
],
},
{
name: "mojo",
files: [],
extensions: [".mojo"],
},
{
name: "red",
files: [],
extensions: [
".red",
".reds",
],
},
{
name: "raku",
files: ["META6.json"],
extensions: [
".raku",
".rakumod",
],
},
{
name: "purescript",
files: [
"spago.dhall",
"spago.yaml",
],
},
{
name: "fennel",
files: [],
extensions: [".fnl"],
},
{
name: "odin",
files: [],
extensions: [".odin"],
},
{
name: "v",
files: [
"v.mod",
"vpkg.json",
],
extensions: [".v"],
},
{
name: "xmake",
files: ["xmake.lua"],
},
{
name: "gradle",
files: [
"build.gradle",
"build.gradle.kts",
],
folders: ["gradle"],
},
{
name: "maven",
files: ["pom.xml"],
},
{
name: "cmake",
files: [
"CMakeLists.txt",
"CMakeCache.txt",
],
},
{
name: "meson",
files: ["meson.build"],
env: "MESON_DEVENV",
},
{
name: "nix",
files: [
"flake.nix",
"shell.nix",
],
env: "IN_NIX_SHELL",
},
{
name: "guix",
files: [],
env: "GUIX_ENVIRONMENT",
},
{
name: "conda",
files: [],
env: "CONDA_DEFAULT_ENV",
},
{
name: "pixi",
files: [
"pixi.toml",
"pixi.lock",
],
env: "PIXI_ENVIRONMENT_NAME",
},
{
name: "spack",
files: [],
env: "SPACK_ENV",
},
{
name: "pulumi",
files: [
"Pulumi.yaml",
"Pulumi.yml",
],
},
{
name: "typst",
files: ["template.typ"],
extensions: [".typ"],
},
{
name: "buf",
files: [
"buf.yaml",
"buf.gen.yaml",
"buf.work.yaml",
],
},
{
name: "dotnet",
files: [
".csproj",
".fsproj",
"global.json",
"Directory.Build.props",
],
},
{
name: "cobol",
files: [],
extensions: [
".cbl",
".cob",
],
},
];
const RUNTIME_SYMBOLS = {
nodejs: "\uE718",
rust: "\uE7A8",
go: "\uE626",
python: "\uE73C",
ruby: "\uE739",
java: "\uE256",
cpp: "\uE61D",
c: "\uE61E",
swift: "\uE755",
kotlin: "\uE634",
deno: "\uE7FB",
bun: "\uE6FB",
php: "\uE73D",
haskell: "\uE777",
julia: "\uE624",
lua: "\uE620",
elixir: "\uE62B",
erlang: "\uE7B1",
gleam: "\uE6B4",
crystal: "\uE62F",
dart: "\uE7C0",
nim: "\uE677",
zig: "\uE6A9",
ocaml: "\uE67A",
clojure: "\uE76A",
scala: "\uE747",
perl: "\uE769",
r: "\uE68A",
elm: "\uE62C",
haxe: "\uE7B7",
vagrant: "\uE21A",
terraform: "\uE1A5",
};
function runtimeMatches(
definition,
cwd,
) {
if (
definition.env &&
process.env[definition.env]
) {
return true;
}
if (
definition.files.some(
(file) => existsSync(join(cwd, file)),
)
) {
return true;
}
if (
definition.folders?.some(
(folder) => existsSync(join(cwd, folder)),
)
) {
return true;
}
if (definition.extensions) {
try {
const entries = readdirSync(cwd);
return entries.some((entry) =>
definition.extensions.some(
(extension) =>
entry.endsWith(extension),
),
);
} catch {
// 忽略目录读取失败。
}
}
return false;
}
function readRuntime(cwd) {
for (const definition of RUNTIMES) {
if (
!runtimeMatches(
definition,
cwd,
)
) {
continue;
}
let version;
if (definition.cmd) {
const output = safeExec(
definition.cmd,
definition.args ?? [],
cwd,
2_500,
);
version =
output?.match(
definition.pattern,
)?.[1];
}
return {
name: definition.name,
version,
};
}
return null;
}
function renderRuntime(runtime) {
if (!runtime) {
return "";
}
const symbol =
RUNTIME_SYMBOLS[runtime.name] ??
"";
return [
C.success(symbol),
runtime.version
? C.muted(runtime.version)
: "",
]
.filter(Boolean)
.join(" ");
}
function projectCachePath(cwd) {
const hash = createHash("sha1")
.update(cwd)
.digest("hex")
.slice(0, 16);
return join(
tmpdir(),
`claude-open-tui-project-${hash}.json`,
);
}
function readProjectMeta(cwd) {
const cachePath = projectCachePath(cwd);
try {
const stat = statSync(cachePath);
if (
Date.now() - stat.mtimeMs <
PROJECT_CACHE_SECONDS * 1_000
) {
return JSON.parse(
readFileSync(
cachePath,
"utf8",
),
);
}
} catch {
// 缓存不存在或已失效。
}
const meta = {
git: readGitStatus(cwd),
runtime: readRuntime(cwd),
};
try {
writeFileSync(
cachePath,
JSON.stringify(meta),
);
} catch {
// 缓存写入失败不影响状态栏。
}
return meta;
}
function transcriptUsage(data) {
const fallback = {
input: Number(
data.context_window
?.total_input_tokens ?? 0,
),
output: Number(
data.context_window
?.total_output_tokens ?? 0,
),
cacheRead: Number(
data.context_window
?.current_usage
?.cache_read_input_tokens ?? 0,
),
cacheWrite: Number(
data.context_window
?.current_usage
?.cache_creation_input_tokens ?? 0,
),
latestCacheHitRate: undefined,
};
const transcriptPath =
data.transcript_path;
const sessionId = String(
data.session_id ?? "unknown",
).replace(
/[^a-zA-Z0-9_-]/g,
"_",
);
if (
!transcriptPath ||
!existsSync(transcriptPath)
) {
return fallback;
}
let transcriptStat;
try {
transcriptStat =
statSync(transcriptPath);
} catch {
return fallback;
}
const cachePath = join(
tmpdir(),
`claude-open-tui-usage-${sessionId}.json`,
);
try {
const cached = JSON.parse(
readFileSync(
cachePath,
"utf8",
),
);
if (
cached.size ===
transcriptStat.size &&
cached.mtimeMs ===
transcriptStat.mtimeMs
) {
return cached.usage;
}
} catch {
// 缓存不存在或已失效。
}
const totals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
latestCacheHitRate: undefined,
};
const seenMessageIds = new Set();
try {
const lines = readFileSync(
transcriptPath,
"utf8",
).split("\n");
for (const line of lines) {
if (!line.trim()) {
continue;
}
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (
entry.type !== "assistant" ||
!entry.message?.usage
) {
continue;
}
const messageId =
entry.message.id ??
entry.uuid;
if (
messageId &&
seenMessageIds.has(messageId)
) {
continue;
}
if (messageId) {
seenMessageIds.add(messageId);
}
const usage =
entry.message.usage;
const input = Number(
usage.input_tokens ??
usage.input ??
0,
);
const output = Number(
usage.output_tokens ??
usage.output ??
0,
);
const cacheRead = Number(
usage.cache_read_input_tokens ??
usage.cacheRead ??
0,
);
const cacheWrite = Number(
usage.cache_creation_input_tokens ??
usage.cacheWrite ??
0,
);
totals.input += input;
totals.output += output;
totals.cacheRead += cacheRead;
totals.cacheWrite += cacheWrite;
const promptTokens =
input +
cacheRead +
cacheWrite;
if (promptTokens > 0) {
totals.latestCacheHitRate =
(
cacheRead /
promptTokens
) * 100;
}
}
writeFileSync(
cachePath,
JSON.stringify({
size: transcriptStat.size,
mtimeMs:
transcriptStat.mtimeMs,
usage: totals,
}),
);
return totals;
} catch {
return fallback;
}
}
function main() {
let data;
try {
data = JSON.parse(
readFileSync(0, "utf8"),
);
} catch {
process.exit(0);
}
const width = Math.max(
STATUSLINE_MIN_COLUMNS,
terminalColumns() - STATUSLINE_RESERVED_COLUMNS,
);
const cwd =
data.workspace?.current_dir ??
data.cwd ??
process.cwd();
const {
git,
runtime,
} = readProjectMeta(cwd);
const contextWindow = Number(
data.context_window
?.context_window_size ?? 0,
);
const contextTokens = Number(
data.context_window
?.total_input_tokens ?? 0,
);
const rawPercent =
data.context_window
?.used_percentage;
const contextPercent =
Number.isFinite(Number(rawPercent))
? Number(rawPercent)
: contextWindow > 0
? (
contextTokens /
contextWindow
) * 100
: 0;
const color =
stressColor(contextPercent);
const contextBlock =
contextWindow > 0
? (
`${color(G.context)} ` +
`${renderBar(contextPercent)} ` +
`${color(`${contextPercent.toFixed(1)}%`)} ` +
`${C.dim("·")} ` +
`${C.text(fmtTokens(contextTokens))}` +
`${C.dim("/")}` +
`${C.text(fmtTokens(contextWindow))}`
)
: "";
const leftSegments = [];
const gitBlock = renderGit(git);
const runtimeBlock = renderRuntime(runtime);
// 路径可按右侧上下文块腾出的左侧真实空位展开,
// 不再硬截到 30 字符(否则中间明明有空也会变成 ~/.../)。
const availableLeft = Math.max(
0,
width -
visibleWidth(contextBlock) -
(contextBlock ? 1 : 0),
);
const otherLeftWidth =
(gitBlock ? visibleWidth(gitBlock) + 1 : 0) +
(runtimeBlock ? visibleWidth(runtimeBlock) + 1 : 0);
// icon + space ≈ 2;再留 1 列缓冲
const maxCwd = Math.max(
12,
availableLeft - otherLeftWidth - 3,
);
leftSegments.push({
text:
`${C.link(G.cwd)} ` +
C.accent(
truncatePath(
formatCwd(cwd),
maxCwd,
),
),
// fitSegmentsByPriority:数值越小越先被压缩;路径最后才动
priority: 3,
});
if (gitBlock) {
leftSegments.push({
text: gitBlock,
priority: 1,
});
}
if (runtimeBlock) {
leftSegments.push({
text: runtimeBlock,
priority: 0,
});
}
const line1Left =
fitSegmentsByPriority(
leftSegments,
availableLeft,
).join(" ");
const line1 = alignRight(
line1Left,
contextBlock,
width,
);
const model = String(
data.model?.id ??
data.model?.display_name ??
"unknown",
);
const effort =
data.effort?.level;
const outputStyle = String(
data.output_style?.name ??
"",
).trim();
const modelParts = [
C.link(G.model),
C.text(model),
];
if (
effort &&
effort !== "off"
) {
modelParts.push(
effortColor(effort)(
`${G.thinking} ${effort}`,
),
);
}
if (outputStyle) {
modelParts.push(
C.muted(
`${G.style} ${outputStyle}`,
),
);
}
const modelBlock =
modelParts.join(
C.dim(" · "),
);
const usage =
transcriptUsage(data);
const hasCacheTokens =
usage.cacheRead > 0 ||
usage.cacheWrite > 0;
const cost = Number(
data.cost?.total_cost_usd ?? 0,
);
const statItems = [
{
text: C.accent(`${G.input} ${fmtTokens(usage.input)}`),
priority: 4,
},
{
text: C.success(`${G.output} ${fmtTokens(usage.output)}`),
priority: 3,
},
];
if (
hasCacheTokens &&
usage.latestCacheHitRate !== undefined
) {
statItems.push({
text: cacheHitColor(
usage.latestCacheHitRate,
)(
`${G.cacheHit} ${usage.latestCacheHitRate.toFixed(0)}%`,
),
priority: 2,
});
}
statItems.push({
text: C.warning(`${G.cost} $${cost.toFixed(3)}`),
priority: 1,
});
const statsBlock = fitJoinedSegmentsByPriority(
statItems,
Math.max(20, Math.floor(width * 0.46)),
);
const line2 = alignRight(
modelBlock,
statsBlock,
width,
);
const [finalLine1, finalLine2] = finalizeStatusLines(
[line1, line2],
width,
);
process.stdout.write(
`${finalLine1}\n${finalLine2}\n`,
);
}
main();

再赋予执行权限:

Terminal window
chmod +x ~/.claude/statusline.mjs

如果你已经在本机维护同一份脚本,也可以直接同步到远端:

Terminal window
# 在能同时访问本机与远端的环境中执行
scp ~/.claude/statusline.mjs user@remote:~/.claude/statusline.mjs
ssh user@remote 'chmod +x ~/.claude/statusline.mjs'

h user@remote ‘chmod +x ~/.claude/statusline.mjs’

### 验证底栏
先做一次手动冒烟测试,确认脚本本身可运行:
```bash
printf '%s' '{
"cwd": "'"$HOME"'/code/demo",
"model": { "id": "claude-sonnet-4", "display_name": "claude-sonnet-4" },
"context_window": {
"context_window_size": 200000,
"total_input_tokens": 45000
},
"cost": { "total_cost_usd": 1.234 },
"session_id": "test"
}' | node ~/.claude/statusline.mjs

正常应输出两行彩色状态文本。若图标显示为 ? 或方块,优先检查终端 Nerd Font。

然后重新进入 Claude Code:

Terminal window
claude

底栏应出现在输入区下方,并大约每 2 秒刷新。切换目录、修改 Git 状态、继续对话后,路径、分支、上下文百分比和 Token 用量会跟着变化。

底栏异常时检查#

现象排查方向
完全没有底栏确认 ~/.claude/settings.jsonstatusLine.command 路径正确,且 Claude Code 已重启
底栏空白或瞬间消失手动执行 node ~/.claude/statusline.mjs,检查脚本是否因 JSON 解析失败直接退出
图标是方块Tabby / 远端终端字体未启用 Nerd Font
行尾错位或截断终端宽度过窄,或字体把 Nerd Font 图标渲染成双列宽
字符发虚、叠字、整体错位将 Tabby 终端前端从 xterm (WebGL) 改为 xterm (canvas)
Git 信息不更新当前目录不是 Git 仓库,或远端 git 不在 PATH
用量长期为 0当前会话 transcript 尚未产生 assistant usage,或 session 缓存尚未建立
与 Herdr 一起使用

底栏脚本运行在 Claude Code 进程内部,不依赖 Herdr 插件系统。只要 Pane 中的 Claude Code 读到了远端 ~/.claude/settings.json,分屏后的每个 Claude Code 实例都会各自渲染底栏。

快捷键速查#

Tabby 与 Claude Code#

操作快捷键
输入换行Shift + Enter
发送消息Enter
上传并插入剪贴板图片Tabby 粘贴快捷键,Windows 默认是 Ctrl + Shift + V

Herdr#

操作快捷键
向右分屏Ctrl + B,然后按 V
向下分屏Ctrl + B,然后按 -
切换 PaneCtrl + B,然后按 H/J/K/L
放大当前 PaneCtrl + B,然后按 Z
断开并保留后台会话Ctrl + B,然后按 Q
查看快捷键Ctrl + B,然后按 ?

完成配置后,SSH 环境中的 Claude Code 不再只是一个能够运行的远程命令行,而会形成完整的开发工作台:

Tabby 负责本地输入和 SSH 连接
Herdr 负责远端会话和 Agent 管理
statusline.mjs 负责 Claude Code 底栏状态
图片插件负责跨 SSH 发送截图
NV Shift Enter 负责自然的多行输入

这套方案适合使用 Windows 本机,通过 Tabby SSH 连接 macOS 或 Linux 服务器,并在远端运行 Claude Code 的开发方式。

参考#

在 SSH 环境中舒服地使用 Claude Code:Tabby + Herdr 完整配置
https://lunary.cc/posts/在-ssh-环境中舒服地使用-claude-codetabby--herdr-完整配置/
作者
鹤望兰
发布于
2026-07-28
许可协议
CC BY-NC-SA 4.0