在 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 机器上。
| 组件 | 安装位置 | 作用 |
|---|---|---|
| Tabby | Windows 本机 | SSH 终端和本地输入入口 |
tabby-clipboard-image | Windows 本机 | 将本地剪贴板图片上传到远端并粘贴为附件或路径 |
tabby-nv-shift-enter | Windows 本机 | 为 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 中执行:
curl -fsSL https://herdr.dev/install.sh | sh安装脚本会根据当前平台和架构下载对应的稳定版二进制,并将其加入 PATH。
使用 Homebrew 安装
已经安装 Homebrew 时,也可以执行:
brew install herdr验证安装:
herdr --versionHerdr 官方为 Linux 和 macOS 提供稳定版本;原生 Windows 版本目前属于预览状态。本文只在远端 macOS 或 Linux 中运行 Herdr。
启动并恢复会话
进入项目目录:
cd "/path/to/project"启动 Herdr:
herdrHerdr 会创建或连接默认的后台会话。然后在 Pane 中启动 Claude Code:
claudeHerdr 会识别 Agent,并在侧栏显示 working、blocked、done 或 idle 等状态。
使用 Herdr 后,可以直接关闭 Tabby,或者主动断开客户端。Herdr 服务和 Pane 中的 Agent 仍会在远端运行。重新连接 SSH 后再次执行:
herdr即可恢复之前的工作现场。
如果需要真正结束整个 Herdr 服务,执行:
herdr server stop常用 Herdr 快捷键
Herdr 的默认前缀键是:
Ctrl + B文档中的 prefix + v 不是同时按下 Ctrl + B + V,而是按顺序操作:
按下 Ctrl + B松开再按 V常用快捷键如下:
| 操作 | 快捷键 |
|---|---|
| 向右分屏 | Ctrl + B,然后按 V |
| 向下分屏 | Ctrl + B,然后按 - |
| 切换 Pane | Ctrl + B,然后按 H/J/K/L |
| 放大或还原当前 Pane | Ctrl + 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 AttachmentFall 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:
Get-Process Tabby -ErrorAction SilentlyContinue | Stop-Process -Force进入插件目录:
Set-Location "$env:APPDATA\tabby\plugins"安装插件:
npm install --no-save tabby-clipboard-image验证入口文件:
Test-Path ` ".\node_modules\tabby-clipboard-image\dist\index.js"正常应返回:
True也可以检查安装版本:
npm ls tabby-clipboard-image --depth=0本文使用的版本为 tabby-clipboard-image@0.1.0。安装后重新启动 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 是否可写:
touch /tmp/tabby-image-test &&rm /tmp/tabby-image-test检查上传后的图片:
file /tmp/clipboard_*.png正常应显示:
PNG image data如果粘贴快捷键仍然只粘贴普通文字,通常说明剪贴板中没有真实图片,或者插件没有取得当前 SSH 会话。建议重新使用 Win + Shift + S 截图后立即粘贴。
如果图片已上传但没有显示为 [Image #1],请确认 Paste mode 为 Native 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。插件管理器正常工作时,不需要手动安装。
手动安装预编译插件包
直接执行:
npm install tabby-nv-shift-enter可能再次触发 tabby-ssh 的 postinstall 错误。
这种情况下,可以只下载并解压已经编译好的插件包,不安装它声明的 Tabby 宿主依赖。
彻底关闭 Tabby:
Get-Process Tabby -ErrorAction SilentlyContinue | Stop-Process -Force然后执行:
$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-enter@1.0.1 --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 | 家目录会显示为 ~/...,过长时按路径段折叠 |
| Git | git status --porcelain=v1 --branch --show-stash | 分支、ahead/behind、冲突、删除、修改、重命名、暂存、未跟踪、stash |
| 运行时 | 工作区特征文件 | 例如 package.json、Cargo.toml、.python-version 等,并尽量读取版本号 |
| 上下文 | context_window | 进度条、占用百分比、已用 Token / 窗口大小 |
| 模型、effort 与 style | model、effort.level、output_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 Font、MesloLGS NF。 - 终端支持真彩色。
- 当前工作区如果是 Git 仓库,远端
PATH中可执行git。
在 Tabby 中确认当前 SSH 配置使用的字体已经切换为 Nerd Font。否则底栏图标会显示为方块或缺字。
写入 settings.json
编辑远端 Claude Code 配置:
nano ~/.claude/settings.json加入或合并下面的 statusLine 字段。如果文件里已有其他配置,只追加这一段即可,不要覆盖整个文件:
{ "statusLine": { "type": "command", "command": "node ~/.claude/statusline.mjs", "padding": 0, "refreshInterval": 2 }}字段含义:
| 字段 | 值 | 作用 |
|---|---|---|
type | command | 通过外部命令渲染底栏 |
command | node ~/.claude/statusline.mjs | 每次刷新时执行的脚本 |
padding | 0 | 去掉底栏额外内边### 安装 statusline.mjs |
将自定义底栏脚本放到远端:
mkdir -p ~/.claudenano ~/.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();再赋予执行权限:
chmod +x ~/.claude/statusline.mjs如果你已经在本机维护同一份脚本,也可以直接同步到远端:
# 在能同时访问本机与远端的环境中执行scp ~/.claude/statusline.mjs user@remote:~/.claude/statusline.mjsssh user@remote 'chmod +x ~/.claude/statusline.mjs'h user@remote ‘chmod +x ~/.claude/statusline.mjs’
### 验证底栏
先做一次手动冒烟测试,确认脚本本身可运行:
```bashprintf '%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:
claude底栏应出现在输入区下方,并大约每 2 秒刷新。切换目录、修改 Git 状态、继续对话后,路径、分支、上下文百分比和 Token 用量会跟着变化。
底栏异常时检查
| 现象 | 排查方向 |
|---|---|
| 完全没有底栏 | 确认 ~/.claude/settings.json 中 statusLine.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,然后按 - |
| 切换 Pane | Ctrl + B,然后按 H/J/K/L |
| 放大当前 Pane | Ctrl + 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 的开发方式。