在终端里与 Harness 对话并实时查看工具调用
30/100
综合分
0
GitHub 星标
267
npm 周下载
—/5
用户评分
数据截至 2026/9/28 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add moqi-tuinpm 源 · moqi-tui 的发布者账号已校验与本仓库一致
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:需留意
是什么
dsh 原生插件 · ui
装得上吗
实装验证未通过(runtime_boot_failed),装前请到仓库确认最近更新与 issue;本次验证针对插件 v0.5.0,当前版本为 v0.4.8,该结论可能已不适用
L4 · 真实安装等级说明 →安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 34 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
⚠︎ 实装验证未通过(runtime_boot_failed · 2026/10/29) ——可能是验证环境差异,装前建议到 GitHub 仓库确认最近更新与 issue。
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查✓ 自动检查通过展开查看检查明细 ↓
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✓npm 包moqi-tui @ 0.4.8
✓Node 引擎要求 >=22 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/8 08:45:51
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 107 处,展示前 97 条)
- 写入剪贴板lib/index.js:525
clipboard: (url) => this.writeClipboard(url).ok, - 读取本地文件lib/index.js:1622
return await readFile(resolve(path), 'utf8'); - 删除本地文件lib/index.js:2082
void unlink(recording.wavPath).catch(() => { - 删除本地文件lib/index.js:2108
void unlink(recording.wavPath).catch(() => { - 写入剪贴板lib/index.js:2423
const result = this.writeClipboard(command); - 执行外部命令固定命令lib/index.js:2446
const child = spawn('ssh', jumpArgv(session), { stdio: 'inherit' }); - 执行外部命令固定命令lib/index.js:2623
const child = spawn('ssh', dispatchArgv(device, profile, task), { - 执行外部命令固定命令lib/index.js:2691
const child = spawn('ssh', ['-o', 'BatchMode=yes', row.host, remote], { - 写入本地文件lib/index.js:3107
await mkdir(dirname(path), { recursive: true }); - 写入本地文件lib/index.js:3169
await mkdir(dirname(file), { recursive: true, mode: 0o700 }); - 写入剪贴板lib/index.js:3576
const result = this.writeClipboard(text); - 读取本地文件lib/index.js:3822
data = new Uint8Array(await readFile(join(this.tab.workspace.cwd, path))); - 写入剪贴板lib/index.js:4012
const result = this.writeClipboard(messageText(message)); - 写入剪贴板lib/index.js:4034
const result = this.writeClipboard(messageText(message)); - 写入剪贴板lib/index.js:4049
writeClipboard(text) { - 写入本地文件lib/index.js:4082
await writeFile(file, markdown, 'utf8'); - 发起网络请求lib/index.js:4105
const response = await fetch(`${stripTrailingSlashes(registry)}/${packageName}`); - 执行外部命令固定命令lib/index.js:4126
const child = spawn('npm', ['install', '-g', `${packageName}@${latest}`], { - 执行外部命令来自配置/变量lib/index.js:4211
const child = spawn(program, args, { stdio: 'inherit' }); - 写入本地文件lib/index.js:4234
await writeFile(file, `${this.composer.value()}\n`); - 读取本地文件lib/index.js:4238
const edited = (await readFile(file, 'utf8')).replace(/ $/, ''); - 删除本地文件lib/index.js:4251
await unlink(file).catch(() => { }); - 读取本地文件bin/moqi.mjs:30
const { name } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) - 读取本地文件bin/moqi.mjs:52
const { version } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) - 执行外部命令来自配置/变量bin/moqi.mjs:78
execFileSync(process.execPath, [resolve(root, 'scripts', 'install-profile.mjs'), 'tui'], { - 执行外部命令来自配置/变量bin/moqi.mjs:93
execFileSync(file, [...prefix, '--profile', 'tui', ...args], { stdio: 'inherit' }) - 读取本地文件lib/file-index.js:59
names = readdirSync(directory); - 读取本地文件lib/file-index.js:77
stats = statSync(full); - 读取本地文件lib/file-index.js:104
names = readdirSync(base); - 读取本地文件lib/file-index.js:116
stats = statSync(join(base, name)); - 读取本地文件lib/cross-find.js:96
raw = readFileSync(path); - 读取本地文件lib/cross-find.js:113
projects = readdirSync(root); - 读取本地文件lib/cross-find.js:122
entries = readdirSync(projectDir); - 读取本地文件lib/cross-find.js:132
if (!statSync(dir).isDirectory()) - 读取本地文件lib/cross-find.js:134
found.push({ project, dir, id, mtime: statSync(dir).mtimeMs }); - 读取本地文件lib/cross-find.js:148
entries = readdirSync(dir); - 执行外部命令来自配置/变量lib/local-platform.js:38
const run = spawnSync(command, args, { - 执行外部命令来自配置/变量lib/local-platform.js:74
const run = spawnSync(command, args, { stdio: 'ignore', timeout: 3000 }); - 读取本地文件lib/sessions-store.js:102
projects = await readdir(root, { withFileTypes: true }); - 读取本地文件lib/sessions-store.js:112
const contents = await readdir(candidate); - 删除本地文件递归lib/sessions-store.js:138
await rm(dir, { recursive: true }); - 读取本地文件lib/git-workspace.js:62
accessSync(candidate, constants.X_OK); - 读取本地文件lib/git-workspace.js:63
if (statSync(candidate).isFile()) - 执行外部命令来自配置/变量lib/git-workspace.js:86
execFile(gitBinary(), ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { - 读取本地文件lib/git-workspace.js:128
if (existsSync(join(gitDir, marker))) - 读取本地文件lib/git-workspace.js:239
if (existsSync(path)) - 读取本地文件lib/git-workspace.js:521
if (!existsSync(path)) - 删除本地文件lib/git-workspace.js:538
rmdirSync(dirname(path)); - 读取本地文件lib/permissions.js:710
text = await readFile(path, 'utf8'); - 写入本地文件lib/permissions.js:779
await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - 写入本地文件lib/permissions.js:782
await writeFile(scratch, formatRules(rules), { mode: 0o600 }); - 写入本地文件lib/permissions.js:783
await rename(scratch, path); - 删除本地文件lib/permissions.js:786
await rm(scratch, { force: true }); - 写入本地文件lib/memory.js:94
mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - 写入本地文件lib/memory.js:97
writeFileSync(scratch, content, { encoding: 'utf8', mode }); - 写入本地文件lib/memory.js:98
renameSync(scratch, path); - 删除本地文件lib/memory.js:101
rmSync(scratch, { force: true }); - 读取本地文件lib/memory.js:108
return readFileSync(path, 'utf8'); - 删除本地文件lib/memory.js:217
rmSync(path, { force: true }); - 读取本地文件lib/memory.js:230
entries = readdirSync(dir); - 读取本地文件lib/memory.js:324
statSync(path); - 读取本地文件lib/memory.js:336
return statSync(path).isFile(); - 读取本地文件lib/memory.js:411
mode = statSync(path).mode & 0o777; - 读取本地文件lib/plugins.js:114
raw = readFileSync(join(dir, 'package.json'), 'utf8'); - 写入本地文件lib/plugins.js:139
writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`); - 执行外部命令来自配置/变量lib/plugins.js:273
execFile(PACKAGE_MANAGER, - 读取本地文件lib/persist.js:238
raw = await readFile(statePath(env), 'utf8'); - 写入本地文件lib/persist.js:299
await mkdir(dirname(target), { recursive: true }); - 写入本地文件lib/persist.js:300
await writeFile(temporary, payload, 'utf8'); - 写入本地文件lib/persist.js:301
await rename(temporary, target); - 写入本地文件lib/persist.js:317
mkdirSync(dirname(target), { recursive: true }); - 写入本地文件lib/persist.js:318
writeFileSync(temporary, payload, 'utf8'); - 写入本地文件lib/persist.js:319
renameSync(temporary, target); - 读取本地文件lib/version.js:16
const manifest = JSON.parse(readFileSync(new URL(candidate, here), 'utf8')); - 写入本地文件lib/presence.js:56
mkdirSync(this.dir, { recursive: true, mode: 0o700 }); - 写入本地文件lib/presence.js:79
writeFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 }); - 删除本地文件lib/presence.js:93
rmSync(path, { force: true }); - 删除本地文件lib/presence.js:108
rmSync(path, { force: true }); - 读取本地文件lib/presence.js:121
names = readdirSync(dir); - 读取本地文件lib/presence.js:131
const parsed = JSON.parse(readFileSync(join(dir, name), 'utf8')); - 执行外部命令固定命令lib/fleet-sources.js:88
execFile('ssh', - 读取本地文件lib/voice.js:301
accessSync(join(directory, command), constants.X_OK); - 读取本地文件lib/voice.js:313
accessSync(path, constants.R_OK); - 执行外部命令来自配置/变量lib/voice.js:344
const child = spawn(setup.recorder.command, setup.recorder.args(wavPath), { - 执行外部命令来自配置/变量lib/voice.js:397
execFile(setup.binary, whisperArgs(setup, wavPath), { timeout: TRANSCRIBE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }, (error, stdout, - 读取本地文件scripts/harness-root.mjs:35
if (!statSync(path).isFile()) return false - 读取本地文件scripts/harness-root.mjs:36
if (platform !== 'win32') accessSync(path, constants.X_OK) - 读取本地文件scripts/harness-root.mjs:96
if (fromEnvironment !== undefined && existsSync(join(fromEnvironment, 'package.json'))) { - 读取本地文件scripts/harness-root.mjs:103
return existsSync(join(root, 'package.json')) ? root : undefined - 读取本地文件scripts/harness-root.mjs:107
if (existsSync(join(current, 'package.json'))) return current - 读取本地文件scripts/harness-root.mjs:125
manifest = JSON.parse(readFileSync(join(dshRoot, 'package.json'), 'utf8')) - 写入本地文件scripts/harness-root.mjs:165
mkdirSync(target, { recursive: true }) - 删除本地文件递归scripts/harness-root.mjs:173
rmSync(to, { recursive: true, force: true }) - 写入本地文件scripts/harness-root.mjs:353
writeFileSync(workspacePath, text) - 删除本地文件递归scripts/harness-root.mjs:365
rmSync(join(scopeDir, name.slice(SCOPE.length + 1)), { recursive: true, force: true }) - 删除本地文件递归scripts/harness-root.mjs:367
if (readdirSync(scopeDir).length === 0) rmSync(scopeDir, { recursive: true, force: true }) - 删除本地文件scripts/harness-root.mjs:370
rmSync(lockPath, { force: true })
扫描于 2026/11/1(0 天前) · 范围 入口闭包 + 全包扫描 · 来源 npm 发布包
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-agent@deepseek-ai/dsh-agent-default-model@deepseek-ai/dsh-attachment@deepseek-ai/dsh-authorization@deepseek-ai/dsh-brand@deepseek-ai/dsh-cmdline@deepseek-ai/dsh-credentials@deepseek-ai/dsh-llm@deepseek-ai/dsh-session@deepseek-ai/dsh-session-query@deepseek-ai/schemastery给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 实时查看工具调用
- 模型选择器
- 并发会话
Moqi——你与 harness 之间心照不宣的默契。DeepSeek Harness 的终端应用,打包成一个 bundle:交错式记录、模型选择器、并发会话、后台智能体、键盘优先。
最近更新
在 GitHub 查看全部 →v0.5.02026-10-19
权限规则、智能体记忆与会话隔离——智能体问得更少,跨会话记住事情,并在自己的分支或工作树上干活。
· 新增
· 审批会记住你的回答。审批面板显示确切命令(从会话日志读取,绝不显示摘要),并提供 1 本次允许、2 本会话允许、3 始终允许——对本项目或按 tab 对所有地方——以及 4 拒绝。像 bash(git log *) 这样的规则之后会直接应答匹配的调用,不再弹面板,并在状态行标出自己;Harness 仍会审计每个决定。规则保存在 $DSH_HOME/moqi/permissions/ 下,绝不放进仓库。匹配失败即拒绝:复合命令必须每一部分都匹配,替换、重定向(/dev/null 除外)和输入重定向一律询问;来自沙箱升级的规则会写明它可授予的模式。
· /permissions 按作用域列出生效的规则,经 y/n 确认后撤销一条,并在 $EDITOR 中打开项目或全局规则文件;文件解析失败会报告,其规则一律失效,绝不猜测。
· shift+tab 循环切换会话模式:只读 → 工作区可写 → 计划,使用 Harness 自带的权限预设和计划模式。计划模式保留工作区可写沙箱,以便获批的计划能被执行;完全访问永远不是其中一步。页脚会标出工作区可写以外的任何模式。
· 智能体记忆。memory_save、memory_read 和 memory_forget 在 $DSH_HOME/memory/(全局)和 $DSH_HOME/memory/projects// 下跨会话保存事实,每条事实一个文件,外加一个 MEMORY.md 索引。每个会话都把索引作为运行时上下文携带:在任何地方保存的记忆都会在下次请求时到达每个打开的会话,列表未变则不产生开销,系统提示词永不重写。
· /memory 显示 Harness 在此加载的指令文件,与记忆和索引文件并列,可在 $EDITOR 中打开其中任何一个,用 /memory forget 遗忘一条。/memory off|on 为所有会话切换记忆并会被记住;--no-memory(或 MOQI_MEMORY=off)只对一次运行生效。关闭会隐藏列表和工具;不会删除任何东西。
· # 快速添加。一行 # 笔记绝不会发给模型:选择器会把它存为项目或全局记忆,或追加到项目或全局的 AGENTS.md。
· 会话在自己的分支上工作。git 仓库中的新会话就地启动在 moqi/- 上——不复制。/worktree 在 $DSH_HOME/worktrees/ 下、仓库之外的一个独立检出中打开会话;/branch 切换当前会话;/isolation off|branch|worktree 和 --isolation 选择默认值。/close 只删除没有自己提交的分支和干净的工作树,启动时会清理没有打开的会话在用的残留。页脚显示分支。
· 智能体知道自己的工作区。隔离会话会拿到按其真实状态写成的规则:提交到这个分支,别动基线,以及要么「这个检出是共享的——绝不切换、暂存、重置或变基」,要么「这是全新的工作树——先安装依赖」。
· 在会话列表中按 k 关闭会话。在这个应用拥有的行上,k 会像 /close 一样关闭该会话。其他进程中的会话或对等端上的会话则不动:没有按会话的控制通道,唯一的杠杆——拥有它的进程——会结束它持有的所有会话。
· 变更
· fleet 现在叫 Sessions。跨设备总览从「fleet」改名为 Sessions:ctrl+s,/fleet 现在是 /sessions。列表本身不变——本地会话和对等端,按设备分组。
· /stack 现在是 /tiled(ctrl+t)。堆叠视图就是平铺视图:ctrl+t 切换它,alt+方向键移动焦点,alt+shift+方向键移动窗格。ctrl+t 以前切换推理显示,该功能仍可通过 /thinking 使用。
· @ 补全、附件和 /fork 跟随会话自己的目录,因此工作树会话会补全并附加自己的文件。
· 移除
· ctrl+c 会话菜单。第一次 ctrl+c 现在只进入两步退出的待确认状态;Sessions 视图是列出所有会话的唯一地方。多余的打开会话选择器(及其 x 关闭行操作)已移除;ctrl+n 和 /close 仍可管理标签页。
· 修复
· 平铺视图下只有一个会话时审批面板不可见。保存了平铺视图且只打开一个会话时,所有审批面板都被丢弃,智能体在等一个没人看得见的回答。
· 带参数的 $EDITOR。像 omarchy-launch-editor --inline 或 code --wait 这样的值被当成一个程序名运行并失败;现在按命令行运行,文件作为一个参数安全传入。
· 审批的命令取自屏幕上的会话,而非发起请求的
v0.4.22026-10-01
· 安全
· 配置档安装器会拒绝可能跳出 profiles 目录的名字。它拦住了 / 和 \,却放过了 ..,于是配置档名字能往上写一层;现在改用扁平、不含点的允许列表,并加上解析后路径的包含检查。
· CI 安装和子进程加固了。工作流安装会传 --ignore-scripts(npm 已固定版本;pnpm 和 Harness 安装则有意保留脚本),开发脚本用绝对路径调用 /bin/sh 和 /usr/bin/env git。
· 变更
· Sonar 待办清单只剩可维护性异味。所有 BUG 和 VULNERABILITY 问题都已解决;修掉了一个死三元、一个没有初始值的 reduce、一个多余的 void,以及 CSI 正则里的控制字符,死导入和按长度索引的读取也清掉了。
v0.4.12026-10-01
· 变更
· TuiApp 的状态现在放在各司其职的控制器里。回合生命周期移到 TurnRunner,打开的会话条移到 SessionTabs,记录搜索移到 SearchState,登录移到 LoginFlow,用量和集群的节点列表移到 tui/state 和 tui/fleet;确认框和选择器按键各归自己的模块。会话通过会话列表模块列出,harness 到界面的适配器放在 tui-adapt,桌面桥接放在 local-platform。TuiApp 只剩这些接缝上的入口点,决策记录在 docs/adr/0001-state-owning-controllers.md。
· 修复
· 会话日志统一走一条解码路径。readLog 重复了 decodeLogBytes 已有的 zstd 检测与解压,两者可能走偏;现在 readLog 只管文件相关的事,解码交给 decodeLogBytes,日志名不对也能按内容解码,不会失败。
· 未命名的选择器按键不再落到 enter。一次重构把选择器默认导航分支直接放在 case enter 上面,少了 break,方向键既移动选中项又激活刚聚焦的那一行——在 /JevLoop 里方向键切换开关,enter 反倒像没反应。
· 删掉 textOfRecord 里一个走不到的角色判断。
· 新增
· 特征测试锁定跨会话日志读取上限:maxBytes 截断前的文本能找到,之后的找不到;超过上限的压缩日志跳过,被截断的纯文本日志仍算已扫描;混合存储下扫描与跳过计数保持正确。
· 仓库约定文档:AGENTS.md、带初始词条的 CONTEXT.md 术语表,以及重构后结构的白板图和 ADR 索引。
数据来自仓库的 GitHub Release,可能与插件实际版本不一致。
README
由 DeepSeek 最新模型翻译生成Moqi
默契 — 你与你的 harness 之间无需言说的理解。
一个用于 DeepSeek Harness 的终端应用,打包为 Harness bundle。它是对早期独立 Go 客户端的重建,重新实现为一等的 dsh profile,从而驱动真正的 Harness agent,而不是私有的 HTTP API。
这个名字正是这件事的关键:一个好的终端 agent 是那种你不再需要向它解释自己的 agent。Moqi 让对话按发生的顺序排列,把每次工具调用放在它被调用的位置,并且不碍事。
◆ moqi tail docker logs local harness
▌ how do i tail the last 50 lines of a container log?
展开完整文档(README 剩余 1 节)
Moqi
默契 — 你与你的 harness 之间无需言说的理解。
一个用于 DeepSeek Harness 的终端应用,打包为 Harness bundle。它是对早期独立 Go 客户端的重建,重新实现为一等的 dsh profile,从而驱动真正的 Harness agent,而不是私有的 HTTP API。
这个名字正是这件事的关键:一个好的终端 agent 是那种你不再需要向它解释自己的 agent。Moqi 让对话按发生的顺序排列,把每次工具调用放在它被调用的位置,并且不碍事。
◆ moqi tail docker logs local harness
▌ how do i tail the last 50 lines of a container log?
✓ shell docker ps
Use docker logs with --tail and -f :
sh
│ docker logs --tail 50 -f webui
• --since 10m — only the last ten minutes
• -t — prefix each line with a timestamp
╭──────────────────────────────────────────────────────────────────────────────╮
│ Ask the harness… (/ for commands) │ ╰──────────────────────────────────────────────────────────────────────────────╯ deepseek-chat · ctx 1.5K/65K 2% · ↑1.2K ↓312 / commands · ctrl+c menu
为什么是一个 bundle
随附的 dsh bundle 有 dsh-base、dsh-web-app、dsh-headless、 dsh-sdk-app、dsh-sdk-minimal 和 dsh-acp-app —— 没有终端应用。
CLI 自己的 README 甚至把 dsh --profile tui 称为一种假设(“假设 tui profile 已安装”)。这个包就是那个 profile。
它挂载在 dsh-base 之上,没有 Host、HTTP 服务器或浏览器插件:终端是唯一的界面。
安装
需要在 PATH 上有一个可用的 dsh(npm install -g @deepseek-ai/dsh)以及 Node 22+。
从 npm 安装 —— 一条命令,然后启动器会安装该 profile 并把终端交给 dsh:
npm install -g moqi-tui
moqi
moqi install 只会刷新该 profile,而
dsh plugin --profile tui add moqi-tui 也可以。应用内的 /update
会检查 npm 并升级全局安装。
安装程序还会把已安装的 Harness 自己的 @deepseek-ai 包链接到应用中。这不是可选的簿记工作:该 profile 会从应用被安装的位置链接它,因此 Node 会从应用自己的目录解析应用的导入,而那些包在其他情况下并不存在于那里 —— 应用会在启动时因 ERR_MODULE_NOT_FOUND 崩溃。链接 harness 的副本(而不是安装第二套)也保证了只有一个 @deepseek-ai/cordis,因为两份副本会是两个不同的 Service 类。
从源码安装 —— 克隆、构建,并将 profile 链接到该检出:
git clone https://github.com/JWE24-code/moqi ~/Projects/moqi
cd ~/Projects/moqi
npm install
npm run build # emits lib/
npm run install-profile # creates $DSH_HOME/profiles/tui and links this checkout
dsh --profile tui
install-profile 写入的是 profile 目录本身,而不是复制模板,因为该 profile 对此包的依赖必须是指向仓库实际所在位置的绝对路径。它会创建:
$DSH_HOME/profiles/tui/ # $DSH_HOME 默认为 ~/.dsh package.json # dsh.profile.bundles + 指向此检出的 link: cordis.patch.yml # 你自己的补丁层,最后组合 pnpm-workspace.yaml # nodeLinker: hoisted, autoInstallPeers: false
以及该 profile 组合的 bundle 顺序:
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "moqi-tui"] } }
传入一个名称即可安装到不同的 profile 下:npm run install-profile -- chat。
针对已安装的 Harness 进行类型检查
@deepseek-ai/ 导入是可选的 peer:Harness 在运行时从其自身的安装锚点解析它们,因此这里有意不将它们作为依赖项。若要针对你实际运行的确切构建进行类型检查:
npm run link-types # 将已安装的 dsh 的 @deepseek-ai 包建立符号链接
npm run typecheck
标志
| 标志 | 含义 |
| --resume | 打开一个已持久化的会话,而不是启动新会话 |
| --model | 为本次运行选择的模型 |
| --thinking | 启动时显示推理输出 |
| --context-limit | 覆盖上下文预算;默认值为模型自身的容量 |
| --mouse | 报告鼠标事件;现在是默认行为,接受它是为了让传递该标志的别名继续可用 |
| --no-mouse | 禁用鼠标报告;默认情况下滚轮滚动、标签栏可点击,shift 为终端选择文本 |
| --no-bell | 会话结束时保持静默 |
| --vim | 在编辑器中启用模态 vim 编辑:esc 进入普通模式,i 进入插入模式 |
| --peer | 要包含在设备群概览中的设备;可重复 |
| --no-restore | 以一个空会话启动,而不是重新打开上次的会话 |
| --version | 打印应用版本 |
MOQI_CONTEXT_LIMIT 设置相同的预算;MOQI_THEME=light\|dark 覆盖背景检测;NO_COLOR 禁用样式。
按键
| 按键 | 操作 |
| enter | 发送 · 引导进入正在运行的回复 · ctrl+j 插入换行 |
| ↑ / ↓ | 在编辑器第一行 / 最后一行时,召回更早的提示 |
| / | 命令面板,每次显示 3 行,超出则滚动 · tab 接受 · esc 关闭 |
| @ | 在工作区中进行文件补全 · tab/enter 接受 · esc 关闭 |
| ? | 在空编辑器上打开按键参考 |
| esc | 中断正在流式传输的回复 |
| esc(单独按下) | 在 50 毫秒宽限期后被识别,因此单独按下绝不会被误认为是某个序列的第一个字节 |
| alt+e | 在 $VISUAL/$EDITOR 中编辑草稿 · 非零退出会保留它 |
| alt+↑/alt+↓ | 选择一条对话记录轮次(金色条)· esc 清除 |
| alt+c | 通过 OSC 52 复制选中的轮次 |
| tab | 当回复正在流式传输时:将提示排队到其后 |
| ctrl+enter | 中断回复并立即发送(需要终端支持上报该按键) |
| ctrl+n / ctrl+r / ctrl+t | 新会话 · 恢复 · 切换思考 |
| pgup/pgdn | 滚动一页 · ctrl+↑/ctrl+↓ 滚动半页 |
| shift+↑/shift+↓ | 滚动一行 · ctrl+g 跳回最新位置 |
| ctrl+o | 显示或隐藏每次工具调用的结果,显示在调用本身下方 |
| ctrl+x | 压缩会话 |
| ctrl+b | 展开或折叠后台代理条 |
| ctrl+y | 将最后一条回复复制到剪贴板 |
| ctrl+f | 设备群总览:所有设备上的会话 |
| n / N | 搜索打开且输入框为空时,下一个 / 上一个匹配项 |
| alt+1…alt+9 | 跳转到某个会话 · alt+n/alt+p 循环切换 · 输入框为空时 tab 循环切换 |
| ctrl+a/ctrl+e/home/end、ctrl+w、ctrl+k | 行首/行尾、删除单词、删除至行尾 |
| ctrl+u | 清空输入行(readline) |
| ctrl+d | 向前删除 · alt+b/alt+f、ctrl+←/ctrl+→ 按单词移动 |
| ctrl+c | 会话菜单 · 1.5 秒内再次按下退出 |
在列表(/model、/theme、/resume)中:输入以筛选,enter 选择,esc 关闭; ctrl+n/ctrl+p 或方向键移动,pgup/pgdn 移动十项,home/end 跳转,ctrl+u 清空筛选。
引导、排队与中断正在运行的回复
在活动会话仍在回复时输入的提示词有三个去向,每个按键对应一个:
- enter 引导 — 提示词被送入正在运行的回合,并落在其下一个步骤边界处,因此代理会在回答中途改变方向。被引导的提示词在记录中以暗色渲染,以便如实呈现交错情况。
- tab 排队 — 提示词在流式输出块下方等待,以暗色显示,页脚会显示计数(2 queued — sends when the reply finishes)。当一个回合在没有中断的情况下完成时,下一个排队的提示词会自动按顺序发送到同一会话中——即使你在此期间切换了标签页。
- ctrl+enter 中断并发送 — 正在运行的回复停止,提示词立即发送(与 /interrupt 对队列所做的相同)。
用 esc 中断会保留队列;它会在下一次回合干净完成时清空,/interrupt 会停止回复并立即清空队列,而 /unqueue 会丢弃队列。
@ 文件补全
在单词开头输入 @ 会打开工作区的模糊选择器——与模型选择器使用相同的子序列筛选,较浅的路径优先。包含 / 的查询(@src/tu)则改为列出该目录;选择目录会进入其中。enter 或 tab 接受,esc 仅关闭菜单。正文中的 @(user@host)永远不会触发它。
选择图片(png/jpeg/webp/gif)会通过 Harness 附件服务将其暂存为持久附件,并插入一个 [Image #N path] 标记;发送时该标记会从文本中移除,图片作为内容块一并发送,记录中会有一行 🖼 name WxH。如果没有附件服务,该路径而是以纯文本形式插入。
工具调用,以及它们发生的位置
一个智能体回合是一个序列:它说些什么,运行一个工具,再就返回的结果说些什么。转录就是这样写的——每次调用都写在它被发起的位置,位于其前后散文之间的一行:
Let me check what is running.
✓ bash docker ps
Only webui is up, so its log is the one to read.
⠹ bash docker logs --tail 50 webui 8s
进行中的调用带有旋转指示器和它自己的已用时间;已完成的调用带有 ✓,或带有 ✗ 及其错误。ctrl+o 会在产生该调用的调用下方添加每次调用的结果:
✓ bash docker ps
↳ webui postgres
如果一个回合的调用返回了可展示的内容,它会在末尾说明一次,而不是宣传一个展开后什么也显示不出来的操作:
ctrl+o for detail
这取代了早先的设计:早先的设计将一个回合的散文保存为一个字符串,将其调用保存为一个单独的列表,然后把所有调用绘制在所有文本上方。那丢弃了读者最需要的一件事——下一句话是关于哪次调用的——而且由于散文片段被拼接在一起,彼此之间没有任何内容,来自一次调用两侧的两个段落会作为一段连续的文本出现。现在顺序是模型的一部分(src/tui/state.ts 中的 Segment),而不是渲染器试图重建的东西。
滚动
滚轮滚动转录,点击会话栏中的标签会切换到该标签——鼠标报告默认开启,因为这两件事正是大多数伸手去拿鼠标的人真正想要的。代价是真实但很小,而且是单方面的:当报告开启时,终端会抑制自己的文本选择,因此选择文本进行复制需要按住 shift(而应用自带的 alt+c / ctrl+y 复制则不需要)。传入 --no-mouse 可将选择恢复为普通拖动。当滚动离开最新输出时,状态栏会提示返回的方式(ctrl+g)。
搜索转录
/find 搜索整个对话(不区分大小写),并将第一个匹配项滚动到视图中,状态栏中会显示 match 1/12 计数器。在空的输入框中,n 跳转到下一个匹配项,N 跳转到上一个匹配项——与分页器使用的字母相同——而 esc 会清除搜索,这样 n 又会输入一个 n(再按一次 esc 会中断流式回复;最便宜的可关闭项会先关闭)。每次跳转都会重新计算匹配项,因此仍在流式传入的回复只会增加可供搜索的行,而不会变得过时。
复制答案
/copy(或 ctrl+y)会将最后一条回复复制到系统剪贴板,然后 alt+↑/alt+↓ 再配合 alt+c 可以复制你选中的任意回合。非常长的答案会被截断为终端愿意接受的长度。
使用两种通道,因为单独任何一种都不够。OSC 52 转义序列是终端自身拥有的通道:没有依赖,没有外部进程,而且它是在 SSH 中仍然有效的方式,因为在那种情况下本地运行的任何东西都无法访问你正在使用的剪贴板。
实际上,仔细想想。在 tmux 或 GNU screen 内部,转义序列首先会被包裹在该多路复用器自己的透传语法中——单独的裸 OSC 52 无法通过其中任何一个到达真正的终端,因为除非恰好设置了 allow-passthrough,否则 tmux 会丢弃它,而 screen 只会转发它识别为自己的 DCS 字符串。但 Wayland 合成器只根据输入焦点序列号授予剪贴板所有权,因此终端可以接受一个格式完全正确的转义序列,却仍然不改变选区——复制报告成功,而剪贴板上什么都没有。所以当本地辅助程序存在时(wl-copy、xclip、xsel、pbcopy),文本也会送到那里,而那个才会落到桌面上。缺少辅助程序不是错误;它只是让 OSC 52 去做它擅长的工作。
用你的声音口述
npm run setup-voice
然后在应用中按 ctrl+v,说话,再按一次。转录文本会放入编辑器中供你阅读和编辑——它绝不会替你发送,因为一个听错的提示词自己发送出去,比完全没有口述还要糟糕。
一切都发生在你的机器上:音频由 arecord 或 sox 录制为临时的 16 kHz 单声道 wav,并由本地的 whisper.cpp 二进制文件转录。没有音频离开这台机器,也没有 API 密钥。因此,口述只在有麦克风的地方有效——通过 SSH 就没有麦克风。
setup-voice 就是全部:它用你的系统包管理器安装录音程序和 whisper.cpp(只询问一次你的密码),将 base.en 权重下载到 ~/.cache/whisper/,并且可以安全地重复运行,跳过已经就位的部分。--print-only 会显示它将做什么而不实际执行。它唯一无法完成的平台是 Debian 和 Ubuntu,因为它们打包的是 Python 实现而不是 whisper.cpp;它会如实说明,而不是猜测。
这些刻意不作为 npm 依赖项——可执行文件是原生的,权重是 140MB 的下载——这就是为什么它们是设置步骤,而不是 npm install 的一部分。
要指向你自己的构建或权重,--voice-bin 和 --voice-model 优先,然后是 MOQI_WHISPER_BIN 和 MOQI_WHISPER_MODEL,然后是在 PATH 以及 ~/.cache/whisper、~/.local/share/whisper 和两个 share/whisper.cpp 目录中搜索。--voice-lang 或 MOQI_WHISPER_LANG 设置语言;没有设置时,由 whisper 决定。
当缺少某个部分时,页脚会指出是哪一个以及修复它的命令,而不是报告语音不可用。
哪些内容会持久保存
已发送的提示词、思考偏好和所选配色方案会保存到 $DSH_HOME/tui-state.json($DSH_HOME 默认为 ~/.dsh),并在下次启动时恢复。模型选择通过 Harness 自己的 saveSelection 保存,而不是这个文件。写入是原子的且尽力而为:只读的主目录意味着应用会像以前一样运行,只是重启后没有记忆。
已发送的提示、思考偏好以及你打开过的会话会保存到 $DSH_HOME/tui-state.json($DSH_HOME 默认为 ~/.dsh),并在下次启动时恢复。
配置文件级别的模型默认值通过 Harness 自身的 saveSelection 保存,而不是这个文件; /model 切换所选择的按会话模型是标签页的一部分,会随标签页一起恢复。
重新打开是刻意保守的。只记住会话 id——每个对话记录都从 Harness 自己的会话存储中重新读取——而该存储不再持有的 id 会被无声跳过,因为 /delete 以及任何其他触碰 $DSH_HOME 的操作都可能在两次运行之间将其清除。如果什么都没有恢复,你会得到一个全新的会话,和以前完全一样。传入 --no-restore 可始终干净启动,传入 --resume 可指定一个会话,后者优先于前两者。
写入是原子的且尽力而为:只读的主目录意味着应用和以前完全一样运行,只是重启后没有记忆。
/usage 账本位于同一个文件中,并以相同方式在重启后保留,因此提供商的累计总量是生命周期总量,而不是按会话的总量;参见速率、缓存、用量和后台任务。
所有设备的统一列表
dsh --profile tui --peer laptop --peer workstation
ctrl+f(或 /fleet)显示所有设备上的每个 dsh 会话,按机器分组,最紧急的排在最前面,并带有状态标记和每个心跳的时长。
Fleet
2 running, 1 ready across 2 devices
workstation (this device)
⠹ rebuild the search index deepseek-chat 3s
· draft the release notes deepseek-chat 12m
laptop
● summarise yesterday glm-4.7 8s
↑↓ move · enter open · r refresh · esc back 3 sessions
每个设备会为每个打开的会话在 $DSH_HOME/tui-presence/ 下写入一条小型 JSON 记录,通过心跳刷新,并在退出时删除。对等设备通过单条非交互式 ssh 命令读取,因此不会新增任何端口监听,也不会添加任何凭据——SSH 本身就是边界。不再刷新的记录会被读取为 stale,而不是永远声称它正在运行。
当此应用已经拥有该会话时,enter 会切换到该会话。对于远程会话,它会把终端交给真正的 ssh -t,运行该设备的 tui 配置文件并恢复该会话——相同的按键、相同的屏幕,就像它在本地一样。离开该远程会话(它自己的 /close 或 /exit,或直接断开连接)会返回到此处的 fleet 概览,并重新绘制。这需要此进程在两端都实际连接到终端;否则(管道输出、非交互式运行)它会退回到复制命令,和以往一样。
把终端交给运行同一个应用的第二个嵌套副本的 ssh -t,这超出了 $VISUAL 曾被要求的范围,并且它暴露了两个真实的 bug,在回程:一个游离的 SIGHUP——子进程接管 tty 时的一个已知隐患——被误读为终端本身挂断,从而关闭了整个应用;而交接后遗留的陈旧 diff 缓存意味着屏幕恢复时除了新的状态行外一片空白,因为它比较的是实际终端已不再持有的帧。两者都在每个终端交接必经的同一个接缝处修复,因此 alt+e 的编辑器往返也因同一修复而加固:
sh
ssh -t laptop 'dsh --profile tui --resume session-…'
p 更进一步,无需离开即可阅读:它通过同一 SSH 通道获取对端的会话日志,并将最近几轮显示为只读预览,在此处解码。对端不写入任何内容,不新增任何监听,且路径使用存储自身的段编码器构建,因此恶意的存在记录无法触及自身会话目录之外。
d 是派发而非附加:编辑器的文本被发送到该对端的 headless 配置文件(--dispatch-profile 可更改它),它回答一个任务后退出。提示词已为远程 shell 加引号,且 SSH 以 BatchMode 运行,因此密码提示永远不会吞掉终端;对端的回答以覆盖层形式返回,而它留下的会话仍归对端恢复。
关于为何使用存在文件而非会话存储,请参见 docs/fleet-overview.md。
从另一台设备访问 sh ./scripts/serve-tailnet.sh on # web UI on your tailnet, over TLS ./scripts/serve-tailnet.sh status ./scripts/serve-tailnet.sh off
dsh web 仍绑定到 127.0.0.1——上游声明 --host 0.0.0.0 不受支持,
且那将意味着所有接口,而不仅仅是 tailnet——而 tailscale serve 在前端终止 TLS,并将机器的 tailnet 名称传递给 --trusted-host,以便浏览器信任围栏接受它。
它经过身份验证:来自 tailnet 且无凭据的请求返回 401,伪造的 Host 返回 403。它还会以你的身份运行 shell 命令,因此脚本每次都会打印警告。请参见 docs/remote-access.md。
同时多个会话
ctrl+n 在当前会话旁边打开另一个会话,而不是替换它,因此长时间运行的回合在你开始其他事情时继续进行。当打开多个会话时,标题下方会出现一个栏:
· 1 tail docker logs │ ⠹ 2 vlan plan │ ● 3 skills question
⠹ 表示进行中的回合,● 表示你尚未阅读的已完成回答,· 表示已读。alt+1…alt+9 直接跳转到某个会话,alt+n/alt+p 循环切换, /sessions 打开一个选择器——它还带有 + Ask the harness in a new session* 条目,因此启动新会话不依赖于已经知道 ctrl+n——而 /close 关闭当前会话。在该选择器中某一行上按 x 会关闭那个会话而不离开列表,因此一次整理多个会话不再是切换-然后-/close-然后-重新打开选择器的循环;最后一个会话不能以同样的方式关闭,/close 已经具备相同的防护。
铃声。 当一个会话的回合结束时,终端铃声会响起——这正是同时运行多个会话的意义所在:你启动一个,然后去做别的事情,完成时会收到通知。你已经在查看的会话会被标记为已读,而不会反复提醒。后台任务完成时会响起同样的铃声,并附带一行标明其名称的状态信息;从对等端返回的集群调度也是如此——任何用户已不再等待的事情都会被通知,而不仅仅是回合。--no-bell 可以关闭声音。
后台代理
对话记录只显示前台代理,因此当某个回合委派给子代理时,否则会在机器忙碌时看起来处于空闲状态。除当前代理之外的活动代理会显示在输入框上方的一条栏中:
⠹ 2 agents 1 running · research, verify ctrl+b
ctrl+b 会将其展开为一个列表,显示每个代理的深度和存在时长。它由 agent/created、agent/status 和 agent/disposed 生命周期事件提供数据,并且当窗口太短时,这条栏是第一个被折叠的界面元素——对话记录始终优先。
选择模型
/model 会在已挂载适配器所能提供的所有模型上打开一个选择器,按提供商分组,并标记正在使用的模型:
Models
› g53
z.ai (GLM coding plan)
glm-5.3 GLM-5.3
glm-5.3-flash GLM-5.3-Flash
↑↓ move · enter select · esc back 2/7
过滤器是子序列匹配,因此 g53 可以找到 glm-5.3。/model 会跳过选择器;当两条路由提供相同的 id 时,请使用 provider/model。
切换会针对同一个会话重新解析代理,因此对话在切换后仍然保留,并且该选择会保存为新会话的默认值。模型是按会话的:在一个对话中切换不会影响其他任何标签页,它们仍使用原本的模型,而页脚的模型和上下文栏始终描述屏幕上显示的会话。新会话会从打开它的会话的模型开始,然后独立分化。
登录——Claude Pro/Max、ChatGPT/Codex 及其他
/providers 会在 ctx.authorization 知道如何获取的每一种凭据上打开一个选择器——这是一种普通 API 密钥无法替代的人工引导登录,因为获取它意味着一次对话:打开这个页面,粘贴那个代码,选择一个账户。此应用不添加任何自己的提供商知识;它渲染所有已注册的流程,就像 /plugins 列出组成配置文件的任何包一样。在实践中,挂载 @deepseek-ai/dsh-llm-pi-ai——z.ai 等编码计划路由已经经过的同一个适配器——会为其附带登录信息的每个提供商注册一个流程,包括 Anthropic (Claude Pro/Max) 和 OpenAI Codex (ChatGPT Plus/Pro),从插件挂载的那一刻起,无论是否已为其配置路由。
选择一个只有一种方法的条目会立即启动它;多于一种方法则会打开一个方法选择器优先显示第一种(OAuth、粘贴的密钥等等——最优先的列在最前面)。接下来发生什么取决于该流程要求什么,并渲染为一个面板,在流程结束前独占键盘:
Sign in — GitHub Copilot
Enter this code on the verification page to finish signing in.
Open: https://github.com/login/device
Code: WXYZ-1234
enter opens the browser · esc cancels the sign-in
提示一出现,页面就会进入剪贴板,而不仅仅是在 enter 请求打开它时才如此——你实际所在的终端可能并不是那台浏览器能够访问到的机器(比如 SSH 会话、远程主机),把它粘贴到那里是 enter 无法提供的后备方案。对于像这样一条单纯的提示,按 enter 也会用平台自带的启动器(xdg-open/open/start)打开其页面,取两者中更快的那一个;一旦流程继续到它自己的问题,代码和页面会留在屏幕上,这正是设备码流程所需要的。需要答案的流程——那个粘贴的代码、要从列表中挑选的账户——会以同样的方式提示;enter 提交,如果流程能够恢复,esc 只拒绝那个问题,如果还没有提出任何问题,则撤回整个尝试。成功或取消会作为一条普通的状态行出现,而存储的凭据比应用更长寿:登录一次就足以用于之后的每个会话,直到你通过管理该凭据存储的任何界面登出。
登录是为路由进行身份验证;它本身并不会向 /model 添加路由。那是配置文件级别的 dsh-llm-pi-ai 配置,与任何其他路由一样(完整字段列表见其自己的 README): yaml
- name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
anthropic: {}
openai-codex: {}
两者都不需要 apiKeyEnv——存储的登录会在那里配置的任何密钥之下为其路由进行身份验证,因此一旦运行过 /providers,空配置就足够了。
首次运行
在全新安装时,应用会打开一个简短的设置列表:界面语言、配色方案、提供商登录(/providers),以及语音控制所需的那一条 shell 命令。每一行都会打开选择器或显示它所命名的步骤,关闭列表——在最后一行按 enter 或按 esc——会记录这台机器已经看过它;它不会再未经请求地返回。
配色方案
/theme 会在应用自带的配色方案上打开一个选择器;/theme 会直接切换。在选择器中移动会用高亮显示的配色方案重绘整个应用——用方向键浏览就是在试穿——而 esc 会恢复之前生效的方案;enter 会保留你最终停留的那个。同样的预览也适用于 /lang。而 /theme 会补全配色方案名称,先补共享前缀,然后打开选择器;/lang 对语言也是如此。
| 主题 | |
| moqi | 默认——应用自有的:Deep Ink 上的水墨,朱砂用于警告 |
| ayu | 暖中性色,橙色点缀 |
| catppuccin | 柔和粉彩,护眼舒适(Latte / Mocha) |
| contrast | 纯黑/白底上的高饱和色调;极致清晰度 |
| dracula | 紫色上的鲜艳霓虹;深色为正典,浅色为衍生 |
| everforest | 柔和有机绿,低眩光 |
| gruvbox | 温暖复古大地色调,中等对比度 |
| kanagawa | 水墨画风;wave 深色,lotus 浅色 |
| material | Android 的调色板;Darker 深色,Lighter 浅色 |
| modus | 经 WCAG 对比度校验的配对;Operandi 浅色,Vivendi 深色 |
| monokai | 经典鲜艳编辑器配色;深色为正典 |
| nord | 冷峻北极蓝,低饱和度 |
| one | Atom 的经典配色;One Light 与 One Dark |
| paper | 一页纸加一抹红;ink 深色孪生 |
| phosphor | 绿色 CRT 辉光;琥珀色用于警告 |
| rose-pine | 柔和墨色背景上的淡紫;前默认配色 |
| solarized | Schoonover 的均衡配对 |
| synthwave | 靛蓝上的亮粉;outrun 日落 |
| tokyo-night | 城市夜色蓝;Night 深色,Day 浅色 |
| tomorrow | 柔和中性色;色相作调味 |
| mono | 灰度,最大对比度,完全无色彩编码 |
每个调色板都定义了浅色和深色两种变体,因为哪个调色板生效以及它绘制在哪种背景上是两个独立的问题。MOQI_THEME=light|dark 仍然强制指定变体(否则由 COLORFGBG 约定决定,深色为回退),而 NO_COLOR 或 TERM=dumb 仍然会完全关闭颜色——在这些情况下主题无事可做,选择任何一个都不会改变什么。
mono 是无障碍选项:它舍弃色相而非试图保留它们,因此成功与失败不再以颜色区分。应用中没有任何东西仅依赖颜色——失败的工具调用无论如何都会打印 ✗ 及其错误文本——因此,在基于色相的调色板不可读之处,剩下的内容仍然清晰可辨。contrast 是另一个无障碍角落:它保留色相,但将每种色相推向纯色基底上的饱和极端,而 modus 是中间路线——一对按已发布 WCAG 对比度保证构建的配色。
moqi 是应用自有的默认配色:一幅中国水墨画,名字正来源于此(墨气)。Deep Ink 为底,Xuan Paper 为文字,Ink Wash 用于选中和边框,Slate Smoke 用于弱化层;强调色保持同一套语汇——朱砂用于警告,如同印章承载画家的印记,靛蓝晕染用于强调色,青瓷、竹绿和赭石用于语义槽位。浅色变体是同一幅画绘于纸上:同样的墨,强调色压深以在宣纸上承载。
当调色板同时发布两种变体时,两者均按发布原样使用(例外情况已在表中记录:Dracula、Monokai 和 Nord 没有自己的浅色变体,因此以同样方式从其色相衍生一个);paper、phosphor、synthwave 和 contrast 是本应用自有的,为已发布调色板未覆盖的风格而构建——极简纸张、绿色 CRT、outrun 日落,以及最大对比度色彩。
该选择与其余持久状态一同保存,并在第一帧之前应用,因此它能在重启后保留。切换会一次性重绘整个屏幕,因为调色板变化会改变几乎每个单元格的颜色。
The theme is per session, the same way the model is: /theme in one
conversation leaves every other tab on the palette it already had, switching
tabs repaints in whichever one that tab is on, and a new session starts from
the theme of the one it was opened from rather than the stored default — which is still what a session started without one, like the very first tab of a fresh launch, falls back to. A restored session brings its own theme back too; one this build no longer ships (renamed or removed since) falls back to the current default instead of leaving the tab on nothing.
Vim mode
--vim at launch turns the composer modal. It starts in INSERT — enabling vim never changes what typing does — and esc switches to NORMAL, where the footer shows the mode and bare keys follow vim:
| NORMAL key | |
| h / l / 0 / ^ / $ / w / b | motion, with vim's word-start w rather than readline's end-of-word |
| i / I / a / A / o / O | enter INSERT at, before, after, or on a new line |
| x / X / dd / d$ / d0 / dw | delete a character, a line, to the end, to the start, a word |
| u | undo the last vim edit (100 deep) |
| anything unbound | swallowed, so a stray j cannot type |
While vim mode is on, esc belongs to the editor: ctrl+c is the interrupt,
which is also what the footer's mode badge is there to remind you of. The mode
is session-scoped and not persisted, and the vim layer only ever touches the
composer — a panel, picker, or fleet screen owns the keyboard when it is open.
Interface language
/lang switches the interface between English and Simplified Chinese and
remembers the choice across restarts. The translated surface is the chrome you
read: the welcome, the full key reference, the trust panels, and the footer
hints. Operational status lines stay English on purpose — they are diagnostics that change with every release, and a half-translated diagnostic is worse than an English one. A missing key falls back to English and then to its own name, so nothing ever renders blank.
MCP servers
/mcp shows which MCP servers' tools are mounted here, grouped by server, by
reading the tool registry for bridge-prefixed names (mcp__server__tool,
server/tool). Servers are declared by composition, not at runtime, so the
pane says where to add one instead of pretending to manage them live — and it recognizes MCP patterns narrowly enough that a path like src/tui/state.ts is never mistaken for a server.
Extending the terminal
The app provides ctx.tuiHost, a service other plugins extend it with:
ts
const dispose = ctx.tuiHost.registerShortcut({
combo: 'ctrl+shift+g', label: 'git status', handler: () => { /* … / },
})
ctx.tuiHost.setStatusLine('2 agents spinning')
A shortcut must carry ctrl or alt; a combination the app already uses is
refused rather than ordered, so a plugin can never swallow the quit
confirmation or a scroll key. registerShortcut and setStatusLine both
return disposers, and the status line is one row — replaced, not stacked, last registration wins — that the layout surrenders first when the window is short.
Searching across sessions is built in: /find --sessions reads the
stored session logs (plain or zstd) under $DSH_HOME/sessions, shows every
matching line with its project and speaker, and opens the session on enter.
The store is read-only here; a compressed log on a Node too old to decode it is
reported as skipped, never as a wrong answer.
Rate, cache, usage, and background jobs
The footer carries what the provider reports: prompt and completion tokens, the
context bar, output tokens per second for the last settled turn, and the share
of the prompt that came from the provider's cache. Each is displayed only when
it is real — an unmeasurable rate or a cache hit on an empty prompt is omitted rather than faked.
That footer is one turn's own numbers, on the session on screen — it says nothing about what came before, or what another provider has been spending in another tab. /usage is a full-screen colored dashboard instead, in two halves: what each provider's plan has left, and what this app has actually spent.
Usage
Plans & limits
DeepSeek
balance 55.88 USD (55.88 topped up) available
✓ off-peak now (half price) — peak resumes in 1d 11h off-peak is also the faster window: less queueing under load
z.ai (GLM coding plan) — GLM Coding Lite
Session (5h) ░░░░░░░░░░░░░░░░░░░░░░░░ 1% 26/2,000 resets in 3h 45m
Week (7d) ███████████████░░░░░░░░░ 62% 6,170/10,000 resets in 4d 5h
renews in 64d 10h — 43.2 quarterly
Claude (Pro/Max)
Session (5h) █████████████████████░░░ 89% resets in 3h 4m
Week (7d) ██████░░░░░░░░░░░░░░░░░░ 25% resets in 6d 11h
week's allowance went to: Claude Code 100%
Token spend — session (5h) zai ████████████████████████ 100% 16,623 ↑16,616 ↓7 8% cached
Token spend — week (7d) zai ████████████████████████ 100% 16,623 ↑16,616 ↓7 8% cached
Token spend — lifetime zai ████████████████████████ 100% 16,623 ↑16,616 ↓7 8% cached
esc back
Plans and limits, as the provider reports them
A local tally of tokens cannot say what a plan has left. Only the provider knows
what a prepaid balance is down to, how much of a 5-hour window is gone, or when
either resets — so /usage asks it, using the credentials already in the Harness credential store. Nothing is read from a file here and no secret is ever printed: a token goes into an Authorization header and nowhere else.
| Route | What it reports | Read with |
| DeepSeek | prepaid balance, granted vs topped up, availability, peak-window state | DEEPSEEK_API_KEY |
| z.ai | plan tier, 5-hour and weekly credit windows, renewal date and price | ZAI_API_KEY |
| Claude (Pro/Max) | 5-hour session and 7-day week utilization, the provider's own severity per window, which surface spent the week | the sign-in /providers stored |
| OpenAI Codex (ChatGPT) | plan tier, 5-hour and weekly utilization, remaining credits | the sign-in /providers stored |
Every probe runs concurrently and every one of them resolves: a provider that is
unset, down, or slow costs its own block a line of explanation and leaves the
rest of the pane intact, because the comparison across providers is the whole
point of it. The pane paints immediately with what it already knows and fills in
the plan half as answers land. A reading less than a minute old is reused, so
closing the pane and reopening it to re-check a number answers from memory
instead of re-hitting every provider.
When a provider states how pressed a window is in its own words, that word
colors the bar. Anthropic's usage report carries a severity per limit — normal, warning, critical — and it wins over the local percentage thresholds used for providers that only report numbers, because the provider knows where the real cliff sits for the plan and model in use; critical at 60% is red there where a percentage rule would still call it roomy. Anthropic's report also names which surfaces spent the week's allowance — Claude Code versus chat versus everything else — and the block says so, because "the week is nearly spent" only becomes a decision when it says what the week went on.
The Codex report is the least contractual of the four: OpenAI publishes no
contract for it, and its figures have already moved once — from x-codex- response headers to the dedicated path the probe reads now. The parser is written to the schema independent reverse-engineered trackers agree on, with its one genuine trap handled: Codex states reset moments in Unix seconds, where every other provider here states milliseconds.
The parsers refuse rather than improvise. A field that is missing or of the wrong
type yields "could not be read", never a zero dressed up as a measurement — which matters more than it sounds: z.ai reports a window's limit in a field called usage and its consumption in currentValue, so the obvious reading of that payload would show a plan as fully spent while it was 1% used. The z.ai parser matches those two names exactly and cross-checks them against the row's own remaining, so a future rename surfaces as a refusal instead of silently inverting the bars.
DeepSeek's own published peak/off-peak schedule — standard pricing 01:00–04:00 and 06:00–10:00 UTC on weekdays, half price every other hour including all of both weekend days — rides along with its balance, so the pane doubles as a reminder of whether the clock favors answering now or waiting. Two separate facts are worth stating, because they bite differently: pricing is predictable and countdown-able, while throughput is the one that surprises people. DeepSeek enforces no per-account request limit and does not reject requests for load; at peak it holds the connection open instead, so what you experience is not an error but a turn that takes far longer than usual. Chinese public holidays are also off-peak by DeepSeek's own page but are not modeled here, for want of a holiday calendar to check against.
Token spend, as the Harness billed it
The bottom half is every provider this app has actually routed a turn to, in
three rolling windows, kept across a restart the same way the composer history
is. The numbers are the Harness's own billed counts, read from its
tokenUsage session projection: four separately-priced buckets, already
retry-aware, so a retried attempt counts as the second billed attempt it is.
They are deliberately not the footer's ↑/↓ pair, which is context
pressure — the size of the prompt the next request would send. Conflating the two is a mistake this app made and has since corrected: it used to subtract one context size from another and record the difference as spend. That could not be right, because context pressure is not cumulative. A turn whose context had shrunk since the last one produced a negative difference, clamped to zero, and recorded a full prompt's worth of real spend as nothing at all; a turn that grew the context recorded a number resembling neither. Bumping the persisted state version discards those old figures rather than carrying them forward under names that would imply they had ever been right.
Session (5h) and week (7d) are rolling windows — the same shape
Anthropic's Claude Pro/Max and z.ai's GLM coding plan both rate-limit on — computed from a timestamped log kept alongside the lifetime ledger, pruned past 7 days on every write. Lifetime never forgets, and its own busiest-first order is what keeps a provider's color the same in every section it appears in, even the ones it has aged out of. Each bar is that provider's share of every token spent in that window, not of the busiest provider in it — two providers within a few points of each other read as two bars close in length, not one full bar and a shorter one exaggerating the gap.
Each row splits prompt from output, because they are priced differently
everywhere and one total hides which way a route is expensive, and reports the
share of its prompt tokens the provider served from cache when there is one to
report — the one figure here you can act on, since a rate that collapses is usually a cache that stopped being hit. A turn whose usage could not be read adds nothing rather than a phantom zero-token row, and its spend is not lost: it lands the next time a reading succeeds. /usage reset clears the ledger and the rolling-window log alike — there is no undo, the same as /delete.
/jobs lists what ran or is still running in the background for this session — state, elapsed time, and the producer's own detail line — with running jobs first and finished ones newest first. /jobs kill stops one. A profile with no job registry says so instead of showing an empty list.
Rewinding and forking
/rewind lists every prompt in the conversation; picking one forks the session
at the start of that prompt's turn, restores the prompt into the composer, and
opens the fork beside the original. The original is untouched, so trying a
different wording costs nothing — and the first prompt cannot be rewound past, because there would be nothing left to inherit.
/fork copies the whole conversation into a resumable twin, cut at the last
completed turn so the seed is always a balanced prefix. /tree shows the
family: the lineage of forks this session belongs to, oldest ancestor first.
Forks are real Harness sessions (parentSession plus a seeded prefix), so they
appear in /resume, survive restarts, and can themselves be rewound or forked.
When the agent stops to ask
Three moments hand the keyboard to a panel in place of the transcript, and all
three answer through the Harness's own seams — the approval/request and user-questions/request waterfalls — so no answer is faked and a headless mount fails closed rather than swallowing a prompt it cannot show.
Tool approval. When the permission layer needs a decision, the panel shows
the tool, the exact command from the tool call already in the transcript, and
the asker's reason: 1 allows once, 2 or esc denies. The protocol has no
persistent grant, so nothing offers one.
ask_user_question. Options navigate with ↑/↓, space toggles a
multi-select, enter answers and advances, tab moves to the free-text line,
and esc steps back a question before it cancels the set (ASK_CANCELLED).
Typing on an option row answers with that option plus your text, the way a
form does.
Answering by voice. With push-to-talk configured, ctrl+v while an
approval panel is open records a take and reads it: an unambiguous "allow" or
"deny" (or 允许 / 拒绝) decides the request, while anything ambiguous leaves the
panel waiting — the microphone can never grant a tool call on a misheard sentence, and a denial wins when both words appear. The same precedence applies to esc: it cancels a live recording before it denies anything.
Plan review. exit_plan_mode renders the plan as markdown with its own
Approve / Keep-planning options. Approving never carries feedback — the protocol reads feedback as "keep planning" — so typing while feedback is not a decision is kept as feedback only on a declining answer.
Commands
The palette merges two sources, so it shows whatever the Harness has actually
registered — /compact from command-compact, plus anything a plugin adds — alongside the app's own:
| Command | Owner |
| /compact, and any other plugin command | ctx.commands (the Harness registry) |
| /new, /sessions, /close, /resume, /delete, /rename, /model, /theme, /plugins, /thinking, /tools, /usage, /export, /find, /unqueue, /interrupt, /copy, /rewind, /fork, /tree, /jobs, /mcp, /lang, /providers, /dispatch, /fleet, /peer, /update, /help, /exit (/quit) | this app |
Unknown commands are dispatched to ctx.commands.execute() and only reported
as unknown if the registry also rejects them.
How it maps onto the Harness
| Feature | Service |
| Streaming text, reasoning, tool activity, token usage | agent/assistant-stream frames |
| Sending a turn | agent.followup(createUserMessage(...)) then agent.whenIdle() |
| New / resumed sessions | ctx.agents.create() / ctx.agents.resume() |
| Session list for /resume | ctx.sessionQuery (optional; the picker degrades if absent) |
| Persistence | ctx.sessions.flush() (optional) |
| Slash commands | ctx.commands |
| Transcript on resume | the session log, projected from user/message and assistant/message events |
Optional services are probed rather than injected, so a profile without
persistence or session query still runs — the affected command just reports that the service is missing.
Layout
src/
index.ts the app plugin: Harness wiring, key dispatch, commands
startup.ts the cmdline provider (--resume/--model/--thinking/...)
persist.ts durable history, preferences, and open sessions under $DSH_HOME
usage.ts billed-token ledger, rolling windows, and DeepSeek's peak hours
credits.ts asking each provider what its plan has left (credentials + network)
sessions-store.ts session storage paths and deletion under $DSH_HOME
version.ts reads the package version for --version and /update
tui/
screen.ts raw mode, alternate screen, per-line diffed painting
keys.ts escape-sequence decoding, chunk-tolerant
view.ts frame composition and layout arithmetic
state.ts composer, palette, picker, history, token formatting
stream.ts projects assistant-stream chunks onto the transcript
export.ts transcript to markdown for /export
usage-view.ts the /usage dashboard: plan limits above, token spend below
credits.ts provider-response parsers and plan drawing, pure and tested
osc52.ts the clipboard escape, wrapped for tmux/screen when one is in the middle
markdown.ts markdown to ANSI plus a small syntax highlighter
text.ts ANSI-aware width, wrap, truncate
theme.ts adaptive palette and SGR styling
themes.ts the named palettes /theme chooses between
src/tui/ imports nothing from the Harness and nothing from npm, which is why
it can be tested without a profile. src/tui-host.ts is the one module that
does import Cordis, because it is the seam (exported as
moqi-tui/tui-host); the shortcut registry and status line it
delegates to are plain classes in src/tui-host-core.ts, which the suites
import instead, so both are tested without a context — and re-exported from the seam, so a plugin still needs the one import.
That split is load-bearing rather than tidy: npm test runs from a bare
npm ci, where @deepseek-ai/ does not resolve at all, so a suite that
reaches the Harness cannot even load. tests/offline-imports-smoke.ts walks
the import graph of every suite and fails if one does, because a development
checkout has run npm run link-types and would otherwise never notice.
Tests
sh
npm test # render, queue, persist, stream, export, sessions, fleet, theme, patch, pty
npm test # render + queue + persist + stream + pty (370 + 8 + 41 + 20 + 13)
npm run test:pty # just the pty round trip, for a quick loop (needs script(1))
node --experimental-strip-types tests/preview.ts [normal|palette|picker|stream|think]
The smoke test renders real frames at sizes from 20x8 to 200x60 and asserts
the invariants the screen driver depends on: the frame never exceeds the
window, no line exceeds the width, and the cursor always lands inside the
composer. It also exercises the input-history recall and transcript-search
matching; sibling scripts cover queue rendering, the persistence round-trip
against a temporary $DSH_HOME, the stream projection (a synthetic model
reply replayed through tui/stream.ts), and the color palettes — every theme is checked for ten well-formed colors in both variants, /theme is checked to actually change the bytes style() emits and to restore the default exactly, and a child process re-runs the suite under NO_COLOR to prove it still suppresses everything. The pty harness drives the real Screen, key decoding, and frame renderer through an actual pseudo-terminal — raw mode, the alternate screen, split escape sequences, and the two-step ctrl+c — so the terminal layer is proven by a round trip, not types alone. preview.ts prints a frame so a layout change can be eyeballed. CI runs the whole suite on Node 22 and 24 plus a typecheck against the real Harness packages.
Performance
Rendering is a per-line diff over a zero-dependency renderer, and each settled
transcript turn's lines are cached by identity, width, and the two view toggles
that change them (ctrl+o and /thinking), so a frame re-renders only what
changed. Measured with npm run bench (Node 26, 200x60 window, five segments
per assistant turn):
| Transcript | Before the message cache | Now |
| 400 messages | 25.3 ms/frame | 0.20 ms/frame |
A full scroll or a spinner tick therefore costs a fraction of the 80 ms it has
between paints, which is what makes a long session stay smooth. The benchmark
prints numbers instead of asserting them; the test suite only asserts an
order-of-magnitude bound, so machine noise cannot fail a build while a cache
regression still would.
Status and caveats
- Verified against real dsh installs (0.1.5-rc.3 and 0.1.7-rc.2; CI
typechecks latest and next):
- npm run link-types && npm run typecheck passes clean against both lines.
- dsh --profile tui --dump-config composes the tree, showing dsh-base
patched by this bundle and both tui-startup and tui-app mounted.
- dsh --profile tui --help prints this app's own flags, so the startup
provider parses the real command line.
- dsh --profile tui </dev/null boots the bundle and exits on the non-TTY
guard.
- 34 suites, 2722 assertions, covering rendering (including a pty round
trip through the real screen, decoder, and frame renderer), streaming
projection, queueing, steering, persistence, the usage ledger and its
colored dashboard, session storage, cross-session search, the panels
(including a running sign-in), the plugin seam, i18n, the fleet, and the
render cache.
- The boot-to-model turn is now automated, on demand. npm run test:live
(MOQI_LIVE=1) boots dsh --profile tui under script(1), types a
prompt, and asserts that the model's answer reaches a painted frame before
quitting with the two-step ctrl+c. It needs credentials and costs a model
call, so it is deliberately not part of npm test; a manual GitHub workflow
runs it when a key is configured. The offline pty suite additionally drives
the real Screen, key decoder, and renderer through the @ menu, an
approval panel, a questionnaire, and a language switch — which is how a space that never matched the panel's toggle was caught.
- ctx.sessionQuery listing is probed. The service is documented as
offering "filtered lists" without a stable method name, so /resume and
/tree try listSessions, list, then querySessions and report cleanly
when none exist.
- Interrupt is best-effort. esc aborts the app's wait and calls
interrupt()/abort() on the agent if either exists; whatever streamed is
still committed.
- /mcp reads, it does not manage. The MCP client is configured by
composition, so the pane reports the bridge-prefixed tools that are actually
mounted and where to declare a server — there is no runtime add/remove.
- /providers knows no provider by name. It renders whatever ctx.authorization
flows are registered; Claude Pro/Max and ChatGPT/Codex show up once
dsh-llm-pi-ai is mounted, the same as any other OAuth provider it or
another plugin adds a login for. The service is optional and probed like
ctx.sessionQuery, so a profile without it reports that plainly instead of
the command doing nothing.
- Published to npm as moqi-tui.
The repository carries the dsh-plugin
topic, which is the whole of the dshfind listing
mechanism: its marketplace indexes public repositories by that topic and
syncs daily, so there is no listing step to remember per release — a repository appears within about a day of the topic being added.
Release steps
Publishing is automated: run the gates below locally, then publish a GitHub
Release whose tag matches package.json (v0.3.0 for 0.3.0) and the
Publish to npm workflow runs them again on a
clean machine before publishing with provenance. It is triggered by the
Release rather than by a push, because npm rejects an existing version and a
workflow that bumped one for you would turn an ordinary merge into a release
nobody decided on. The one-time npm-side setup — trusted publishing, so no long-lived token is stored — and the dry-run path are in docs/releasing.md. sh
npm test # 34 suites, including the pty round trip
npm run test:live # a real model turn through the TUI (needs credentials)
npm run test:package # packs, installs into a clean prefix + DSH_HOME, boots
npm run build # and commit lib/ — see below
npm publish # prepublishOnly re-runs build + typecheck + npm test
lib/ is committed, and has to be rebuilt and committed with any source
change.* The dshfind registry inspects this repository's public source tree
and requires the manifest's main to be a file that is actually in it; build
output that only appears after npm run build fails its check with
missing_file. The cost of that is a build artifact in git, and the risk is a
stale one — if lib/ lags src/, the registry describes different code than
npm ships. tsc output is deterministic for a given source and compiler, so
npm run build && git diff --exit-code lib says whether the tree is honest.
prepublishOnly runs all four gates, so publishing needs dsh on PATH — a broken artifact must fail the publish rather than reach the registry.
test:package exists because the suites all run from the source checkout,
where link-types has already made the Harness resolvable — which is exactly how a tarball that could not resolve @deepseek-ai/* once passed every test and still crashed on boot. It now fails the release instead.
License
MIT.预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
JWE24-code/moqi
在终端里与 Harness 对话并实时查看工具调用
相关插件 · UI / 主题
破甲增强版JeffKeeping/dsh-purge-plus
dsh-purge 的非官方分支fork:适配 DeepSeek Harness 0.2.1-rc.2、破甲内核…
★ 387已汉化DeepSeek 客户端
71.9
无代码业务系统平台nocobase/nocobase
NocoBase 是什么
★ 24,798已汉化DSH
71.8
1Panel 服务器面板1Panel-dev/1Panel
全球 250 万+ 自托管用户共同喜爱。
★ 37,327已汉化DSH
71.5
终端编码代理esengine/DeepSeek-Reasonix
· 指南 · ACP · 扩展开发 · 规格 · 官方网站 · Discord · QQ
★ 36,021已汉化DSH
71.2
工作状态行动态ccch1mneyyy/working-activity
作者:chimney@ccch1mneyyy。社区项目,非 DeepSeek 官方项目。
★ 664已汉化DSH
71.2
多模型配置管家farion1231/cc-switch
跨平台桌面一体化助手,支持 Claude Code、Codex、OpenCode、DeepSeek Harness 等工具,唯一官网 ccswitch.io
★ 150,724已汉化DSH
71.1