dsh-oc-tui 0.2.0-pre.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-oc-tui
2
2
 
3
- **The `0.2.0` line is the visual-experience update, and it is out as a pre-release now** (`0.2.0-pre.11`, on npm's `next` channel): real image rendering, native Mermaid art, and full Markdown run through this plugin's own terminal engine. It needed no `chafa`, `mmdc` or `mdcat` wrapper — the rendering stack is pure npm (`sharp` decodes and scales, `lovely-mermaid` lays out Mermaid) — and which dsh (the harness) builds it serves is spelled out in the compatibility note below. If you have suggestions for specific visual features, please share them in the original issue thread or open a new issue with a title containing `Visual Experience Suggestion`
3
+ **`0.2.0` is the visual-experience release** (on npm's `latest` channel): real image rendering, native Mermaid art, and full Markdown run through this plugin's own terminal engine. It needed no `chafa`, `mmdc` or `mdcat` wrapper — the rendering stack is pure npm (`sharp` decodes and scales, `lovely-mermaid` lays out Mermaid) — and which dsh (the harness) builds it serves is spelled out in the compatibility note below. If you have suggestions for specific visual features, please share them in the original issue thread or open a new issue with a title containing `Visual Experience Suggestion`
4
4
 
5
5
  **_Thank you for your support of the `dsh-oc-tui` plugin!_**
6
6
 
@@ -83,7 +83,7 @@ dsh --version
83
83
  pnpm --version
84
84
  ```
85
85
 
86
- **Compatibility.** Verified against dsh `0.2.0-rc.2` and `0.1.2-rc.1` (`0.1.1-rc.2` before that). DSH renamed parts of the session API in 0.1.2 — `Session.events` became `snapshotEvents()` — and this plugin reads whichever accessor the host provides, so one build serves both lines.
86
+ **Compatibility.** Verified against dsh `0.2.1-rc.2`, `0.2.0-rc.2` and `0.1.2-rc.1` (`0.1.1-rc.2` before that). `0.2.1-rc.5` was compared package by package: its core is byte-identical to rc.2, and the one display change — reasoning signatures — is handled here as well. The supported range is declared as `engines.dsh` (`>=0.1.1-rc.2 <0.3.0-0`) in `package.json`: since dsh 0.2.1 the install gate reads that declaration first and ignores the peer list, so a host outside the range is refused at load until its exact version is exempted with `dsh plugin allow-version`. DSH renamed parts of the session API in 0.1.2 — `Session.events` became `snapshotEvents()` — and this plugin reads whichever accessor the host provides, so one build serves both lines.
87
87
 
88
88
  **dsh (the harness) 0.2.0 is a breaking update — upgrade this plugin first.** From dsh `0.2.0-rc.1` the agent-preset packages were split: the roster provider `@deepseek-ai/dsh-agent-presets` (which shipped its own presets) became `@deepseek-ai/dsh-agent-preset-registry`, and a new `@deepseek-ai/dsh-agent-preset` package is only a *declarative* preset row that injects the service the registry provides. A TUI older than `0.1.5` names the old provider, and dsh reports it at every boot:
89
89
 
@@ -112,7 +112,7 @@ npm install -g dsh-oc-tui
112
112
 
113
113
  ### Version channels
114
114
 
115
- npm carries two channels for this package: **`latest`** holds the stable releases, **`next`** the pre-releases (`0.2.0-pre.x`, `-rc`, `-alpha`, `-beta`). Untagged installs — `dsh plugin … add -w dsh-oc-tui` and `npm install -g dsh-oc-tui` above — therefore still give you the newest stable version, never a release candidate. The [awesome-dsh-plugin](https://awesome-dsh-plugin.com/zh/p/rayafriandion/dsh-oc-tui/) marketplace entry links to the same package, so an install started from it lands on the stable channel too.
115
+ npm carries two channels for this package: **`latest`** holds the stable releases, **`next`** the pre-releases (any `-pre`, `-rc`, `-alpha` or `-beta` suffix). Untagged installs — `dsh plugin … add -w dsh-oc-tui` and `npm install -g dsh-oc-tui` above — therefore give you the newest stable version, never a release candidate. Since `0.2.0` the stable channel carries the visual-experience work, so nothing here needs `@next` any more. The [awesome-dsh-plugin](https://awesome-dsh-plugin.com/zh/p/rayafriandion/dsh-oc-tui/) marketplace entry links to the same package, so an install started from it lands on the stable channel too.
116
116
 
117
117
  To run a pre-release, name the tag: `dsh plugin` forwards the spec to pnpm untouched, and pnpm resolves dist-tags.
118
118
 
@@ -224,6 +224,7 @@ It prefers the `dsh` on `PATH` and falls back to `npx --yes @deepseek-ai/dsh`. I
224
224
  | `Ctrl+C` | Clear a non-empty prompt, cancel the running turn, or press twice while idle to exit. |
225
225
  | `Ctrl+P` | Open Settings. |
226
226
  | `Ctrl+E` | Toggle the thinking-intensity slider below the composer. |
227
+ | `Ctrl+G` | Empty page: open the git branch switcher. Inside a session: a note that it is empty-page only. |
227
228
  | `Tab` | Session page: cycle the thinking intensity. Settings page: switch the left menu. |
228
229
  | `Ctrl+N` | New session. |
229
230
  | `Ctrl+D` | In Settings → Manage sessions: delete the focused session (press twice to confirm). |
@@ -240,7 +241,7 @@ It prefers the `dsh` on `PATH` and falls back to `npx --yes @deepseek-ai/dsh`. I
240
241
 
241
242
  ### Slash commands
242
243
 
243
- Built in: `/help` `/settings` `/new` `/resume <id>` `/model <id>` `/provider <route>` `/rewind` `/stats` `/clear` `/cancel` `/quit` (`/exit` also works).
244
+ Built in: `/help` `/settings` `/new` `/resume <id>` `/model <id>` `/provider <route>` `/rewind` `/branch` `/stats` `/clear` `/cancel` `/quit` (`/exit` also works).
244
245
 
245
246
  **Rewind.** `Esc Esc` (or `/rewind`) lists the prompts of the live session. Restoring the **conversation** forks a new session from the events before the chosen prompt — the parent session is left untouched on disk, exactly as the harness's own `session/fork` does — and the picker lands on the most recent prompt, so `Enter` twice rewinds the last turn. `/rewind <n|last> [conversation|code|both]` runs it without the picker. The fork starts with an **empty inbox**: a cut before a turn also cuts the inbox claim that turn performed, so anything the parent had queued — including the prompt you rewound away from — is not delivered again; it stays in the parent's log, and the result line says `dropped N inherited pending input` when there was any. Restoring **files** is best-effort and fenced: it needs a git worktree (anywhere else the rewind reports `files not restored (not a git worktree)` and changes nothing), it rewrites tracked files from `HEAD` without touching the index, and it removes an untracked file only when the transcript's first recorded write to that path is at or after the rewind point. Everything it overwrites or deletes is copied to `$DSH_HOME/rewind-backups/<sessionId>/<timestamp>/` first, and the result line names that directory. Because the log stores no file contents, a tracked file returns to its last commit, not to its exact state at the rewind point.
246
247
 
@@ -248,6 +249,8 @@ Harness commands — `/compact`, `/goal`, `/plan`, … — are forwarded to `ctx
248
249
 
249
250
  `/stats` is the TUI's own command: it toggles the [session stats window](#session-stats-and-the-context-meter) and, like the harness commands, needs a live session.
250
251
 
252
+ **Git branches.** `Ctrl+G` (or `/branch`) on the empty page opens the branch switcher: local branches under one header, remote-tracking branches under another, the current one carrying git's own `*` marker. `Enter` checks the selected branch out — a remote-tracking branch with no local counterpart goes through the tracking branch git's own DWIM would create for it (`origin/feature` → `feature`), and one whose local branch already exists switches to that one instead. `p` pulls updates (`git fetch --all --prune`, then `git pull --ff-only` — a diverged branch is refused rather than merged, because the TUI has no way to resolve the conflict), `r` re-reads the refs, `Esc` closes. The window reports each outcome on its subtitle line and the title screen's `git:` line follows the switch. Checking out a branch rewrites the working tree every session in the process is rooted in, so the window is offered on the empty page only: inside a session the same shortcut and command answer with a note instead of opening it. Outside a git worktree — or with no `git` on `PATH` — the window says so and changes nothing.
253
+
251
254
  ### Interactive prompts
252
255
 
253
256
  **Approvals.** When a tool needs permission, the composer area is replaced by an approval box. The first row carries the action (`Approval · <tool>`) with the key hints riding along when they fit; the explanation the request supplies follows as a `Summary ·` row. `y` allows once, `n` rejects, `Esc` cancels. A request from a `@dsh-std` component can additionally carry `Origin`, `Risk` and `Details` rows — they are drawn when present and omitted when not, a detail marked private shows its label but not its value, and on a narrow terminal the middle yields with a `… N more` marker while the action and the hints always survive. The plugin also honours the effective permission preset, so an auto-approving preset does not prompt at all.
@@ -313,11 +316,11 @@ When the sections outgrow the window a scrollbar appears in the rail's right edg
313
316
  `Ctrl+P` opens a settings menu over the same host settings namespaces as the Web UI, persisted through `ctx.settings` to `$DSH_HOME/settings.yaml`. A left menu splits it into three tabs (`Tab` or click to switch):
314
317
 
315
318
  - **Main** — General (busy-Enter behaviour, permission preset), Sessions (new session, manage sessions), System (provider API hints, update-manager shortcut, settings file path).
316
- - **Model** — the default provider/model/reasoning choice, then one group per provider holding its URL, API key, and model list. Pressing `Enter` on **Models** fetches the provider's advertised catalog (`ctx.llm.discoverModels`) and opens a checkbox window; pressing `Enter` on a listed model makes it the default route.
319
+ - **Model** — the default provider/model/reasoning choice, then one group per configured provider holding its API key, URL and model list, and finally **Add provider**. Pressing `Enter` on **Models** fetches the provider's advertised catalog (`ctx.llm.discoverModels`) and opens a checkbox window; pressing `Enter` on a listed model makes it the default route. Inside that window `e` edits the highlighted entry — id, context window, output tokens, input modalities — and `Del` removes it from the provider, while **Refresh catalog** asks the endpoint again and **Add model by id** covers a model it does not advertise. The first row is a whole-selection toggle (`selected/total`); a failed fetch prints the endpoint's own refusal — `answered 401; check the API key`, `could not reach …` — instead of an empty list, and a hand-declared route's first successful fetch arrives with every model already selected. **Add provider** offers the same two paths the Web UI's add card does: adopt a route the installed adapter already ships, or declare a custom endpoint (route id, wire protocol, endpoint, and at least one model). Its top row filters the catalog as you type; the declaring path can interrogate its endpoint with a key that has not been stored yet, so adding a gateway is one pass instead of save-then-return. **Remove provider** deletes the profile and its managed credential.
317
320
  - **MCP** — see [MCP servers](#mcp-servers).
318
321
  - **Update** — see [In-app updates](#in-app-updates).
319
322
 
320
- Only providers you actually added (present in your user settings layer) are listed; a provider that was never added stays hidden. **Default preset** is shown only when the profile mounts an agent-preset roster, because the choice list is that roster — and this plugin deliberately mounts none (see the compatibility note above), so on a stock `tui` profile the row is absent rather than empty. Where a roster *is* mounted, note that a TUI session composes process-wide from the base, so the stored default applies where a session is created from a preset. Web-UI-only options (`ui-theme` appearance, the Web UI's `locale`) are not shown because they have no effect in the TUI; the TUI's own **Language** row is a separate namespace (`tui-locale`) — see [Language](#language).
323
+ Providers whose profile resolves get a group — including a whole-section route such as DeepSeek, whose profile is the plugin entry's own Config and therefore always exists, so a fresh install can enter its key. A catalog route you have not adopted stays in the add picker instead, and a route authenticated by a sign-in (DeepSeek Account) shows that instead of a key field. **Default preset** is shown only when the profile mounts an agent-preset roster, because the choice list is that roster — and this plugin deliberately mounts none (see the compatibility note above), so on a stock `tui` profile the row is absent rather than empty. Where a roster *is* mounted, note that a TUI session composes process-wide from the base, so the stored default applies where a session is created from a preset. Web-UI-only options (`ui-theme` appearance, the Web UI's `locale`) are not shown because they have no effect in the TUI; the TUI's own **Language** row is a separate namespace (`tui-locale`) — see [Language](#language).
321
324
 
322
325
  ### Language
323
326
 
@@ -526,6 +529,7 @@ The plugin's dsh imports resolve through the shared `$DSH_HOME/profiles/node_mod
526
529
  | `--resume` or Manage sessions unavailable | Both need the shared `sessionQuery` service; keep `@deepseek-ai/dsh-base` first in `dsh.profile.bundles`. |
527
530
  | `no agent factory registered` on `--resume` | A startup race with the agent-loop row; current builds retry it. On an older build, run `/resume <id>` after boot instead. |
528
531
  | Loader errors after updating dsh (`State`, `./internal`) | The global dsh install is a mixed old/new tree. Close every dsh process and reinstall: `npm install -g @deepseek-ai/dsh@<version>`. |
532
+ | `incompatible with dsh <version>` wall at every boot | The host is outside the declared `engines.dsh` range. Update this plugin, hold dsh at a supported version, or grant an exact-version exemption (the refusal prints the command): `dsh plugin --profile tui allow-version dsh-oc-tui@<version> --dsh-version <version> --accept-risk`. |
529
533
  | Source edits have no effect | The installed copy is a tarball; repack and reinstall (see [Development](#development)). |
530
534
 
531
535
  More detail, in Chinese: [docs/用户手册.md](docs/用户手册.md).
@@ -536,6 +540,7 @@ More detail, in Chinese: [docs/用户手册.md](docs/用户手册.md).
536
540
  - The plugin does not hot-reload: the profile's HMR root is the profile directory, so a running TUI keeps the copy it booted with.
537
541
  - `dsh tui` as a bare subcommand needs a shell alias — the stock launcher hardcodes only `web` and `plugin`.
538
542
  - Harness slash commands need a live session; on the title screen the TUI tells you to start a session first.
543
+ - The git branch switcher needs `git` on `PATH` and a git worktree as the workspace; anywhere else it reports that and changes nothing. It is an empty-page affordance: with a session open, `Ctrl+G` and `/branch` only point back at the empty page.
539
544
  - Deferring a question with `Esc` does not cancel the tool call — it delegates, and with no other answerer the tool call fails. Per-question skip (as the Web UI composer offers) is not implemented.
540
545
  - `--resume`, Settings → Manage sessions, the context meter, and the stats strip depend on services mounted by `@deepseek-ai/dsh-base` (`sessionQuery`, `sessionProjections`); a hand-built profile must provide them. The `sessionStats` projection is a web-app-layer row, so the TUI folds those figures from the session log itself when no profile mounts it.
541
546
  - An update restart only waits for the TUI that scheduled it: the "no other dsh process" check runs *before* you confirm, so a `dsh web` window opened afterwards can still overlap the install. Close other dsh windows first.
@@ -556,8 +561,10 @@ lib/ui.js responsive view model + renderer (flat transcript, tool row
556
561
  lib/splash-opencode.js title-screen art (OpenCode-style layout, DeepSeek gradient mark)
557
562
  lib/metrics.js whole-session stats + token usage fold (web stats strip / tokenUsage port)
558
563
  lib/rewind.js rewind picker: conversation fork from the event log plus fenced file restore
564
+ lib/git-branch.js git branch switcher: local/remote listing, checkout, and ff-only pull (git CLI, worktree-fenced)
559
565
  lib/interrupt.js Ctrl+C lifecycle state
560
566
  lib/web-settings.js shared WebUI settings projection (incl. the tui-graphics namespace)
567
+ lib/providers.js provider directory split, the add-provider draft and its write
561
568
  lib/updates.js in-app update manager (npm registry, download + staging, install verification)
562
569
  lib/markdown.js markdown-it token stream -> styled lines (tables, hljs, links)
563
570
  lib/util.js text/display helpers
@@ -68,7 +68,7 @@ dsh --version
68
68
  pnpm --version
69
69
  ```
70
70
 
71
- **兼容性**:本插件已在 dsh `0.2.0-rc.2`、`0.1.2-rc.1`(更早是 `0.1.1-rc.2`)上验证。dsh 0.1.2 重命名了部分会话 API——`Session.events` 改为 `snapshotEvents()`——插件会读取宿主实际提供的那个访问器,因此同一份构建可同时服务这两条版本线。dsh 0.2.0 的破坏性改动与「先升 TUI 再升 dsh」见 [5.8 节末尾](#58-程序内更新)的说明。
71
+ **兼容性**:本插件已在 dsh `0.2.1-rc.2`、`0.2.0-rc.2`、`0.1.2-rc.1`(更早是 `0.1.1-rc.2`)上验证;`0.2.1-rc.5` 已按包逐一核对——核心包与 rc.2 内容一致,唯一的展示层改动(思考签名)也已在本插件内处理。受支持的范围以 `engines.dsh`(`>=0.1.1-rc.2 <0.3.0-0`)声明在 `package.json` 里——dsh 0.2.1 起安装门禁优先读它、不再看 peer 列表;范围外的宿主会被拒绝加载,确需使用时可用 `dsh plugin allow-version` 做精确版本豁免。dsh 0.1.2 重命名了部分会话 API——`Session.events` 改为 `snapshotEvents()`——插件会读取宿主实际提供的那个访问器,因此同一份构建可同时服务这两条版本线。dsh 0.2.0 的破坏性改动与「先升 TUI 再升 dsh」见 [5.8 节末尾](#58-程序内更新)的说明。
72
72
 
73
73
  ## 3. 安装
74
74
 
@@ -90,7 +90,7 @@ npm install -g dsh-oc-tui
90
90
 
91
91
  ### 3.2 版本渠道:stable 与 next
92
92
 
93
- npm 为这个包提供两条渠道:**`latest`** 是稳定版,**`next`** 是预发布(`0.2.0-pre.x`、`-rc`、`-alpha`、`-beta`)。不带 tag 的安装——上面 3.1 的 `dsh plugin … add -w dsh-oc-tui` 和 `npm install -g dsh-oc-tui`——拿到的仍然是最新稳定版,不会是候选版本。[awesome-dsh-plugin](https://awesome-dsh-plugin.com/zh/p/rayafriandion/dsh-oc-tui/) 市场收录指向同一个包,从那里开始的安装同样落在稳定版渠道上。
93
+ npm 为这个包提供两条渠道:**`latest`** 是稳定版,**`next`** 是预发布(带 `-pre`、`-rc`、`-alpha`、`-beta` 后缀的版本)。不带 tag 的安装——上面 3.1 的 `dsh plugin … add -w dsh-oc-tui` 和 `npm install -g dsh-oc-tui`——拿到的是最新稳定版,不会是候选版本。从 `0.2.0` 起稳定版已包含完整的视觉体验更新,不需要再走 `@next`。[awesome-dsh-plugin](https://awesome-dsh-plugin.com/zh/p/rayafriandion/dsh-oc-tui/) 市场收录指向同一个包,从那里开始的安装同样落在稳定版渠道上。
94
94
 
95
95
  想用预发布版本,显式指名 tag:`dsh plugin` 会把规格原样交给 pnpm,pnpm 负责解析 dist-tag。
96
96
 
@@ -209,6 +209,7 @@ dsh-oc-tui --version # 启动器版本
209
209
  | `Ctrl+C` | 清空非空输入框 / 取消进行中的轮次;空闲时连按两次退出 |
210
210
  | `Ctrl+P` | 打开设置菜单 |
211
211
  | `Ctrl+E` | 打开/关闭输入框下方的思考强度滑块 |
212
+ | `Ctrl+G` | 空页面:打开 git 分支切换窗口;会话内:提示该功能仅在空页面可用 |
212
213
  | `Tab` | 会话页面:循环切换思考强度;设置页面:切换左侧菜单(Main/Model/Update) |
213
214
  | `Ctrl+N` | 新建会话 |
214
215
  | `Ctrl+D` | 在 设置 → 管理会话 中删除当前选中的会话(连按两次确认) |
@@ -234,12 +235,14 @@ dsh-oc-tui --version # 启动器版本
234
235
 
235
236
  ### 5.2 斜杠命令
236
237
 
237
- 内建命令:`/help` `/settings` `/new` `/resume <id>` `/model <id>` `/provider <route>` `/rewind` `/stats` `/clear` `/cancel` `/quit`(`/exit` 等效)。
238
+ 内建命令:`/help` `/settings` `/new` `/resume <id>` `/model <id>` `/provider <route>` `/rewind` `/branch` `/stats` `/clear` `/cancel` `/quit`(`/exit` 等效)。
238
239
 
239
240
  **Rewind(回退)**:`Esc Esc`(或 `/rewind`)列出当前会话的提示词。选择"恢复对话"会以所选提示词之前的事件**分叉出一个新会话**,父会话完整留在磁盘上——与 dsh 自身的 `session/fork` 行为一致;选择器默认停在最近一条真实提示词上,因此连按两次 `Enter` 即回退最后一轮。`/rewind <n|last> [conversation|code|both]` 可跳过选择器直接执行。分叉出的新会话从**空 inbox** 开始:切点落在某轮 turn 之前,也会切掉那一轮对 inbox 的认领,因此父会话里排队过的输入(**包括你这次要丢掉的那条提示词**)不会被再次投递,它们仍留在父会话日志里;若有此类输入,结果行会显示 `dropped N inherited pending input`。**恢复文件**是尽力而为且有护栏的:必须在 git 工作区中(否则提示 `files not restored (not a git worktree)` 且不改动任何文件);已跟踪文件用 `HEAD` 覆盖且不触碰索引;只有当日志中该路径的首次写入发生在回退点之后时,才会删除未跟踪文件。所有被覆盖或删除的内容都会先复制到 `$DSH_HOME/rewind-backups/<sessionId>/<时间戳>/`,结果行会给出该目录。由于日志不保存文件内容,已跟踪文件回到的是最近一次提交,而非回退点当时的状态。
240
241
 
241
242
  dsh 的人类命令(`/compact`、`/goal`、`/plan` 等)会转发给 `ctx.commands`,不经过模型轮次执行。**这些命令需要有活动会话**:在标题屏上敲会得到 `/<name>: start a session first` 提示,而不是被静默丢弃。因此想用 `/plan` 进入计划模式,请先随便发一条消息建立会话。
242
243
 
244
+ **Git 分支切换**:在空页面按 `Ctrl+G`(或输入 `/branch`)打开分支切换窗口——本地分支与远程跟踪分支分两节列出,当前分支带 git 自己的 `*` 标记。`Enter` 检出所选分支:没有对应本地分支的远程分支会经由 git 自带的 DWIM 规则创建跟踪分支(`origin/feature` → `feature`);本地已存在同名分支时则直接切到该分支。`p` 拉取更新(先 `git fetch --all --prune`,再 `git pull --ff-only`——本地已分叉时拒绝合并而不是硬合,因为 TUI 没有解决冲突的界面),`r` 重新读取引用,`Esc` 关闭。每个操作的结果都显示在窗口的副标题行,标题屏上的 `git:` 行会跟随切换更新。检出分支会重写工作区,而本进程的所有会话都扎根在工作区上,因此该窗口**只在空页面提供**:会话内按同样的快捷键或输入同样的命令,只会得到一条指向空页面的提示,不会弹出窗口。工作区不是 git 仓库、或 `PATH` 上没有 `git` 时,窗口会如实提示且不做任何改动。
245
+
243
246
  ### 5.3 交互式提示:授权与提问
244
247
 
245
248
  **授权询问**:工具需要许可时,输入框区域显示 `Approval · <工具> · y allow / n deny`。`y` 允许一次、`n` 拒绝、`Esc` 取消。插件同时尊重生效的权限预设——自动批准的预设不会弹询问。
@@ -322,13 +325,38 @@ dsh 的人类命令(`/compact`、`/goal`、`/plan` 等)会转发给 `ctx.com
322
325
  按 `Ctrl+P` 打开。菜单覆盖与 WebUI 相同的 Host 设置命名空间,修改通过 `ctx.settings` 持久化到 `$DSH_HOME/settings.yaml`。左侧菜单栏用 `Tab`(或鼠标点击)在三个标签间切换,`↑/↓` 移动、`Enter` 打开选项列表、`Esc` 返回,分区标题行不可选中:
323
326
 
324
327
  - **Main**:General(Busy Enter 行为、权限预设)、Sessions(新建会话、管理会话)、System(Provider API 配置提示、更新管理器快捷入口、设置文件路径)
325
- - **Model**:默认 provider / model / 思考强度;随后是你添加过的每个 provider 一组,组内为 Provider URL、Provider API key、Models
328
+ - **Model**:默认 provider / model / 思考强度;随后是每个已配置 provider 一组,组内为 API key、Provider URL、Models(`Enter` 打开勾选窗口)、模型行(`Enter` 设为默认)、Add model by id、Remove provider;最后一行是 **Add provider**
326
329
  - **MCP**:见 [5.7.1](#571-mcp-服务器)
327
330
  - **Update**:见 [5.8](#58-程序内更新)
328
331
 
329
- 只显示你在设置文件中**手动添加过**的 provider;系统预设但从未添加的不会显示。
332
+ 判定规则是「这个 provider 的 profile 能不能解析出来」:能解析就成组显示。整段式 provider(如 DeepSeek,`settingsPath` 为空、profile 就是插件条目自己的 Config)永远存在,所以新装的 TUI 里它的 API key 行一定在——旧版本要求「用户层有覆盖」才显示,结果把唯一自带的 provider 藏了起来,那一版根本无处填 key。已安装但从未启用的目录路由不会出现在分组里,它们只出现在添加流程中。以登录鉴权的路由(DeepSeek Account)不显示 key 行,而是一行「已通过 DeepSeek 账号登录」。
333
+
334
+ **添加服务商**:在 Model 页最后的 **Add provider** 按 `Enter`,进入选择器——首行是**搜索行**(`Enter` 开始输入,逐字收窄下面的列表;路由 ID 与显示名都参与匹配),其后是 **自定义 API — 手工声明端点**,再往下是「已安装的服务商」(适配器自带、但你的配置里还没有 profile 的目录路由)。共 40 余条目录路由,靠这一行才扫得动;「自定义」放在列表之前,因为要找它的人不会想先滚过 40 行。两条路径对应 WebUI 添加卡的两个模式:
335
+
336
+ - **启用已安装的服务商**:只需填 API key(可留空,改走该 provider 自己的环境认证)、可选的端点覆盖;Models 留空就沿用内置目录。保存即写出一条空 profile——对目录路由这就是全部所需。
337
+ - **声明自定义服务商**:必填路由 ID(小写字母开头,仅小写字母、数字与连字符)、端点(http/https)、协议、至少一个模型;可选显示名与 API key。**先填显示名也行**——路由 ID 还空着时会按显示名自动补一个(`Acme Gateway` → `acme-gateway`,去掉开头数字因为它也是凭据名的一部分),补出来的值就显示在那一行里,随时可以改。协议列表由适配器自己的 schema 读出(`openai-completions` / `openai-responses` / `anthropic-messages`),不会被填进它无法实现的取值。
338
+
339
+ 表单是草稿:`Enter` 编辑一行、`Esc` 返回、只有 **Save provider** 才落盘。**Models** 行会拿表单里当前的端点与协议(含尚未保存的 key)去问 `/models`,勾选结果写进草稿;端点答不出时用 **Add model by id** 手填。保存顺序是 profile 先写、凭据后写,所以 key 写失败时 provider 已存在,重试不会再建一次。写入位置是 profile 的 `cordis.patch.yml` 里 `llm-pi-ai.providers.<route>`,密钥按 `<ROUTE>_API_KEY` 存进凭据库——和 WebUI 写出来的结构完全一致,两边可以互相接续编辑。
340
+
341
+ **粘贴**:对话框开着时,`Ctrl+V`(终端自己的粘贴,以括号粘贴序列送达)落在**当前正在编辑的字段**上;没有打开字段时,它会直接打开所选行的编辑器并把内容填进去。粘贴不会溜进被对话框遮住的输入框——那是这条路径曾经的问题:粘 API key 时文本进了会话提示词,回车就会当消息发出去。单行字段会把多行粘贴折成一行(密钥额外去掉换行,`sk-a⏎sk-b` 拼成 `sk-ask-b`);图片粘贴在文本字段里被忽略,因为它没有文本形式,而对话框后面的输入框也看不见。
342
+
343
+ **Models 自动获取**:在某个 provider 的 **Models** 行按 `Enter`,TUI 调用 `ctx.llm.discoverModels` 获取该 provider 的完整模型目录(内置路由直接取内置目录,自定义路由则请求其端点),弹出勾选窗口用 `[x]`/`[ ]` 选择(`Enter` 切换、`Esc` 返回)。选中的模型保存到该 provider 的 `models` 配置;在列出的模型上按 `Enter` 可把它设为默认模型。目录里没有的模型用同一组下方的 **Add model by id** 直接补一行。
330
344
 
331
- **Models 自动获取**:在某个 provider 的 **Models** 行按 `Enter`,TUI 调用 `ctx.llm.discoverModels` 获取该 provider 的完整模型目录(内置路由直接取内置目录,自定义路由则请求其端点),弹出勾选窗口用 `[x]`/`[ ]` 选择(`Enter` 切换、`Esc` 返回)。选中的模型保存到该 provider 的 `models` 配置;在列出的模型上按 `Enter` 可把它设为默认模型。
345
+ **模型条目的增删改**:勾选窗口里的每一行右侧会带上该模型已知的容量(如 `256K · 32K`),行上:
346
+
347
+ - `Enter` 切换是否属于该 provider(勾上即写入 `models`);
348
+ - `e` 打开这一条的编辑器——模型 ID(新增时可选)、显示名、上下文窗口、最大输出 tokens、输入类型(纯文本 / 文本 + 图像)。容量可以写 `256K`、`1M`,留空表示沿用路由默认值;保存后按位置替换该行,不会多出一条。
349
+ - `Del` 把它从该 provider 移除(首按提示、再按确认)。删掉最后一条会把 `models` 整个 unset,目录路由因此回到「沿用内置目录」;手写路由没有目录可退,adapter 会拒绝这次写入并把原因显示出来。
350
+
351
+ 窗口底部还有 **Refresh catalog**(重新问一次端点,适合端点刚起来的情况)与 **Add model by id**(端点不报这个模型时手填,同上也能用 `e` 补容量)。未保存的 provider(添加表单里的 Models 行打开的窗口)用同一套键,改动只落在草稿里,**Save provider** 才写盘。
352
+
353
+ **拉取失败会说明原因**:端点的拒绝(`…/models answered 401; check the API key`、`could not reach …`、`pi-ai protocol "…" has no model listing`)会作为窗口第一行的告警显示,而不是只留一句「没有模型」。「没填 key 就点获取」是最常见的 401 来源——现填的 key 即使还没保存也会带上去试探,所以填了就能过。
354
+
355
+ **选择与全选**:窗口第一行是整体的勾选开关,右侧显示 `已选/总数`:
356
+
357
+ - 手写路由第一次拉取成功时**默认全选**(它没有内置目录可继承,`models` 必须显式列出),全选开关就是撤回到空的快捷方式;
358
+ - 目录路由则刻意**默认不选**——`models` 留空表示「沿用适配器目录」,adapter 升级后自动跟随;一旦写死列表就冻结在当天;
359
+ - `Enter`(或用鼠标点该行)切换一行,`Enter` 在开关行上等于一键全选 / 全部取消。
332
360
 
333
361
  **默认 agent 预设** 只有在 profile 挂载了预设名册时才会出现——选项列表就是那份名册,而本插件刻意不挂载(见 5.7),所以默认的 `tui` profile 里这一行是「没有」而不是「空白」。若某一套 profile 确实挂载了名册,请注意 TUI 会话始终从进程级 base 组合生成,该默认值只在按预设创建会话时生效。
334
362
 
@@ -428,6 +456,8 @@ incompatible with dsh 0.2.0-rc.2: peerDependencies {…}. Running it may cause c
428
456
 
429
457
  TUI 仍然能启动,但 Settings → Default preset 不再有名册可选(没有人提供名册时该行直接隐藏)。把 TUI 更到 `0.1.5` 或更高,这段报告就消失:本 bundle **根本不挂载名册行**——因为没有任何一个包名能在两条 dsh 线上同时成立:旧名在 0.2.0 上会触发上面的报告,单数名永远不会激活,注册表名册在没有主机声明预设时激活出来也是空的。需要名册的 dsh 0.2.0 profile 应当自己挂载注册表并声明预设(`dsh-web-app` 正是这么做的)。两个方向都不影响会话数据。
430
458
 
459
+ **dsh 0.2.1 升级注意。** `--from-default-profile` 重建一个**已有自定义配置**时会替换该配置及其插件和设置——tui profile 正是自定义配置,重建之后需要重新 `dsh plugin --profile tui add dsh-oc-tui` 并重设 TUI 选项(会话数据不受影响)。另外 API Key 读取优先级有调整:Web/桌面端(含 TUI 这类 profile 应用)优先使用已保存的凭据,headless 改用环境变量和 `.env`;TUI 设置页显示的凭据检测状态就是宿主实际使用的状态,无需额外操作。
460
+
431
461
  ## 6. 工作原理
432
462
 
433
463
  - 插件是用 `tui` profile 加载的 Cordis 函数插件。`lib/startup.js` 解析本应用的命令行参数并提供 `tuiStartup` 服务;`lib/index.js` 拥有 UI 主循环。
@@ -455,6 +485,7 @@ TUI 仍然能启动,但 Settings → Default preset 不再有名册可选(
455
485
  | `--resume` 或 管理会话 不可用 | 两者都需要共享的 `sessionQuery` 服务;确保 `dsh.profile.bundles` 第一项仍是 `@deepseek-ai/dsh-base`。 |
456
486
  | `--resume` 报 `no agent factory registered` | 与 agent-loop 行的启动竞态;当前构建会重试。旧构建可先启动、再用 `/resume <id>`。 |
457
487
  | 更新 dsh 后启动报 loader 错误(`State`、`./internal`) | 全局 dsh 处于新旧混杂状态:关闭所有 dsh 进程后 `npm install -g @deepseek-ai/dsh@<版本>`。 |
488
+ | 每次启动都打印 `incompatible with dsh <版本>` 的不兼容墙 | 宿主版本落在声明的 `engines.dsh` 范围之外。升级本插件、把 dsh 固定在受支持的版本,或授予精确版本豁免(拒绝信息里会直接给出命令):`dsh plugin --profile tui allow-version dsh-oc-tui@<版本> --dsh-version <版本> --accept-risk`。 |
458
489
  | 改了源码不生效 | profile 里装的是 tarball 副本,需要重新打包再安装(见第 8 节)。 |
459
490
 
460
491
  ## 8. 开发
@@ -518,6 +549,7 @@ dsh --profile tui --dump-config # 先初始化 base profile
518
549
  - 插件不支持热重载:profile 的 HMR 根是 profile 目录,运行中的 TUI 保持它启动时的那份副本。
519
550
  - `dsh tui` 作为裸子命令需要 shell 别名——原版启动器只硬编码了 `web` 与 `plugin`。
520
551
  - dsh 的人类斜杠命令需要活动会话;标题屏上会提示先建立会话。
552
+ - git 分支切换需要 `PATH` 上有 `git`、且工作区是 git 仓库;否则会如实提示且不做任何改动。它是空页面功能:会话已打开时,`Ctrl+G` 与 `/branch` 只会提示回到空页面。
521
553
  - `Esc` 让出问题不会取消工具调用,而是委托;没有其它应答者时该工具调用会失败。WebUI composer 支持的**逐题跳过**尚未实现。
522
554
  - `--resume`、设置 → 管理会话、会话统计条与上下文仪表依赖 `@deepseek-ai/dsh-base` 挂载的服务(`sessionQuery`、`sessionProjections`);手工搭建的 profile 需自行提供。`sessionStats` 投影只由 Web 应用层 bundle 挂载,缺少时 TUI 自行从会话日志折叠同样的数字。
523
555
  - Windows 上的延迟 dsh 安装只等待**调度它的那个 TUI**,不是机器上所有 dsh 进程;执行前请关掉其它 TUI 窗口(以及 `dsh web`)。
@@ -536,6 +568,7 @@ lib/ui.js 响应式视图模型 + 渲染器(平铺转录,含问答
536
568
  lib/splash-opencode.js 标题屏艺术(OpenCode 式排版 + DeepSeek 渐变标识)
537
569
  lib/metrics.js 整场会话统计 + token 用量折叠(Web 统计条 / tokenUsage 投影同规则)
538
570
  lib/rewind.js rewind 选择器:从事件日志分叉会话 + 有 fenced 的文件回滚
571
+ lib/git-branch.js git 分支切换:本地/远程分支列表、检出、仅快进拉取(走 git CLI,限工作区内)
539
572
  lib/interrupt.js Ctrl+C 生命周期状态机
540
573
  lib/web-settings.js WebUI 设置投影(含 tui-graphics 命名空间)
541
574
  lib/updates.js 程序内更新(npm registry + 安装)
package/dsh-plugin.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifestVersion": "0.15",
4
4
  "id": "io.github.rayafriandion.dsh-oc-tui",
5
5
  "name": "dsh-oc-tui",
6
- "version": "0.2.0-pre.13",
6
+ "version": "0.2.0",
7
7
  "license": "LGPL-3.0-or-later",
8
8
  "source": { "repository": "https://github.com/rayafriandion/dsh-oc-tui" },
9
9
  "facets": { "host": { "entry": "lib/facet.js", "apiVersion": "v1alpha1" } },
@@ -52,6 +52,10 @@
52
52
  "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "rewind",
53
53
  "spec": { "title": "Rewind the session to an earlier point",
54
54
  "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
55
+ { "id": "io.github.rayafriandion.dsh-oc-tui.branch",
56
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "branch",
57
+ "spec": { "title": "Switch git branches on the empty page",
58
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
55
59
  { "id": "io.github.rayafriandion.dsh-oc-tui.quit",
56
60
  "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "quit",
57
61
  "spec": { "title": "Leave the TUI", "aliases": ["exit"],
@@ -0,0 +1,120 @@
1
+ // Host diagnostics captured while the TUI owns the alternate screen.
2
+ //
3
+ // Plugins in this process report through `console.warn` / `console.error` when
4
+ // no logger exporter is mounted — the LLM adapters do exactly that for model
5
+ // listing problems. On an ordinary CLI that is fine; here every one of those
6
+ // writes lands on the alt screen the renderer believes it owns, leaving
7
+ // unpainted text behind that the diffing painter never corrects.
8
+ //
9
+ // So the process streams are diverted for as long as the alt screen is up. The
10
+ // text is appended to a log file the user can be pointed at, and replayed to the
11
+ // real stderr on restore, after the alt screen is gone and it can do no harm.
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+
15
+ // Beyond this the in-memory copy stops growing: a plugin stuck in a warn loop
16
+ // must not be able to exhaust the heap. The file keeps the full text either way.
17
+ const MAX_KEPT_BYTES = 256 * 1024
18
+ // The log is truncated once it passes this. A plugin that warns on every frame
19
+ // would otherwise grow the file without bound across a long session.
20
+ const MAX_LOG_BYTES = 1024 * 1024
21
+
22
+ function streamText(chunk, encoding) {
23
+ if (Buffer.isBuffer(chunk)) return chunk.toString('utf8')
24
+ return typeof chunk === 'string' ? chunk : String(chunk)
25
+ }
26
+
27
+ // Intercept both process streams. Returns a handle with the captured text, a
28
+ // live count, and `restore()`.
29
+ //
30
+ // `onFirst` fires exactly once, on the first diverted write: the caller uses it
31
+ // for a single toast rather than one per line. It is called after the write has
32
+ // been recorded, never from inside a console method, so a listener that itself
33
+ // logs cannot recurse into the capture.
34
+ export function captureDiagnostics({ logPath, onFirst } = {}) {
35
+ const streams = [
36
+ { stream: process.stdout, name: 'stdout' },
37
+ { stream: process.stderr, name: 'stderr' },
38
+ ]
39
+ const undo = []
40
+ const state = {
41
+ count: 0,
42
+ bytes: 0,
43
+ kept: [],
44
+ truncated: false,
45
+ restored: false,
46
+ }
47
+
48
+ // Size is read once and then tracked, so a quiet session with no host output
49
+ // never touches the filesystem at all.
50
+ let logBytes = null
51
+
52
+ const record = (name, text) => {
53
+ if (!text) return
54
+ state.count++
55
+ // Stamping each write rather than each line keeps multi-line stack traces
56
+ // attached to the moment they happened without parsing them here.
57
+ const entry = `[${new Date().toISOString()}] [${name}] ${text}`
58
+ if (logPath) {
59
+ const line = entry.endsWith('\n') ? entry : entry + '\n'
60
+ try {
61
+ fs.mkdirSync(path.dirname(logPath), { recursive: true })
62
+ if (logBytes === null) {
63
+ try { logBytes = fs.statSync(logPath).size } catch { logBytes = 0 }
64
+ }
65
+ // Rotate by truncation: this file exists to answer "what did the host
66
+ // complain about just now", so the newest lines are the ones worth
67
+ // keeping.
68
+ if (logBytes > MAX_LOG_BYTES) {
69
+ fs.writeFileSync(logPath, '')
70
+ logBytes = 0
71
+ }
72
+ fs.appendFileSync(logPath, line)
73
+ logBytes += Buffer.byteLength(line)
74
+ } catch {
75
+ // A read-only home directory must not take the TUI down with it; the
76
+ // in-memory copy below still carries the text to restore().
77
+ }
78
+ }
79
+ if (state.bytes < MAX_KEPT_BYTES) {
80
+ state.kept.push(text)
81
+ state.bytes += Buffer.byteLength(text)
82
+ } else {
83
+ state.truncated = true
84
+ }
85
+ if (state.count === 1 && typeof onFirst === 'function') {
86
+ try { onFirst() } catch { /* a listener must not break the capture */ }
87
+ }
88
+ }
89
+
90
+ for (const { stream, name } of streams) {
91
+ const original = stream.write
92
+ if (typeof original !== 'function') continue
93
+ stream.write = function (chunk, encoding, callback) {
94
+ // Diverted writes report success and still fire the callback: a writer
95
+ // that waits for it would otherwise hang the host mid-turn.
96
+ record(name, streamText(chunk, encoding))
97
+ if (typeof encoding === 'function') encoding()
98
+ else if (typeof callback === 'function') callback()
99
+ return true
100
+ }
101
+ undo.push(() => { stream.write = original })
102
+ }
103
+
104
+ return {
105
+ get count() { return state.count },
106
+ get truncated() { return state.truncated },
107
+ // Everything captured, in write order, for the post-exit replay.
108
+ text() { return state.kept.join('') },
109
+ restore() {
110
+ if (state.restored) return
111
+ state.restored = true
112
+ for (let i = undo.length - 1; i >= 0; i--) undo[i]()
113
+ // Replay through the real streams, now that they no longer paint over the
114
+ // alt screen. Nothing here goes through record().
115
+ if (state.kept.length > 0) {
116
+ try { process.stderr.write(state.kept.join('')) } catch { /* closed on shutdown */ }
117
+ }
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,157 @@
1
+ // Git branch switching for the TUI's empty page: listing local and remote
2
+ // branches, checking one out, and pulling updates. Everything shells out to
3
+ // the `git` on PATH through an argument array (no shell is involved), and
4
+ // every entry point is fenced to a real worktree first, so a workspace that
5
+ // is not a repository — or a machine with no git at all — reports that
6
+ // instead of half-applying anything.
7
+ import { spawn } from 'node:child_process'
8
+ import { resolve } from 'node:path'
9
+ import { t } from './i18n.js'
10
+
11
+ // A network operation can hang on an unreachable host long past the point
12
+ // where the user has stopped watching the window, so every spawn carries a
13
+ // bound; a kill resolves as a failure rather than leaving the promise open.
14
+ const GIT_TIMEOUT_MS = 120_000
15
+
16
+ // GIT_TERMINAL_PROMPT=0 turns a credential prompt into an immediate error:
17
+ // the pipes below already keep git off the terminal, and this makes the
18
+ // promise reject instead of blocking on input that can never arrive.
19
+ function git(cwd, args) {
20
+ return new Promise((resolvePromise) => {
21
+ const child = spawn('git', args, {
22
+ cwd,
23
+ windowsHide: true,
24
+ stdio: ['ignore', 'pipe', 'pipe'],
25
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
26
+ })
27
+ const stdout = []
28
+ const stderr = []
29
+ let settled = false
30
+ const finish = (result) => {
31
+ if (settled) return
32
+ settled = true
33
+ resolvePromise(result)
34
+ }
35
+ const timer = setTimeout(() => {
36
+ child.kill()
37
+ finish({ ok: false, spawned: true, stdout: '', stderr: t('git.timedOut') })
38
+ }, GIT_TIMEOUT_MS)
39
+ child.stdout?.on('data', (chunk) => { stdout.push(chunk) })
40
+ child.stderr?.on('data', (chunk) => { stderr.push(chunk) })
41
+ child.on('error', (error) => {
42
+ clearTimeout(timer)
43
+ // A spawn failure (git not on PATH) is not the same as git saying no:
44
+ // `spawned: false` lets callers tell the two apart.
45
+ finish({ ok: false, spawned: false, stdout: '', stderr: error.message })
46
+ })
47
+ child.on('close', (code) => {
48
+ clearTimeout(timer)
49
+ finish({
50
+ ok: code === 0,
51
+ spawned: true,
52
+ stdout: Buffer.concat(stdout).toString('utf8'),
53
+ stderr: Buffer.concat(stderr).toString('utf8'),
54
+ })
55
+ })
56
+ })
57
+ }
58
+
59
+ function lines(text) {
60
+ return String(text ?? '')
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trim())
63
+ .filter((line) => line !== '')
64
+ }
65
+
66
+ // The first line of git's output, which is where its errors put the reason
67
+ // ("error: pathspec ... did not match"). Later lines are hints and blank
68
+ // padding that would only crowd a one-line report.
69
+ function firstLine(text) {
70
+ return lines(text)[0] ?? ''
71
+ }
72
+
73
+ // The last line of the combined output: fetch's "Fetching origin" and pull's
74
+ // "From ..." lead, while the line that answers "did anything change" —
75
+ // "Already up to date.", the fast-forward diffstat — comes last.
76
+ function lastLine(text) {
77
+ const all = lines(text)
78
+ return all.length > 0 ? all[all.length - 1].slice(0, 72) : ''
79
+ }
80
+
81
+ // The worktree root, or a throw that says why there is none. Every public
82
+ // function starts here. `--show-toplevel` is one spawn that answers both
83
+ // questions: it prints the root inside a worktree (an unborn one included)
84
+ // and fails inside a bare repository or outside any repository at all.
85
+ async function worktreeRoot(cwd) {
86
+ const root = await git(resolve(cwd), ['rev-parse', '--show-toplevel'])
87
+ if (!root.spawned) throw new Error(t('git.gitMissing'))
88
+ if (!root.ok || root.stdout.trim() === '') throw new Error(t('git.notRepo'))
89
+ return root.stdout.trim()
90
+ }
91
+
92
+ // Local branches (the current one included, in git's own alphabetical order)
93
+ // and remote-tracking branches. `refs/remotes/*​/HEAD` is a symbolic ref that
94
+ // names no branch of its own, so it is filtered out rather than offered as a
95
+ // checkout target.
96
+ export async function listGitBranches(cwd) {
97
+ const root = await worktreeRoot(cwd)
98
+ const [local, remote, head] = await Promise.all([
99
+ git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
100
+ git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']),
101
+ // symbolic-ref exits non-zero on a detached or unborn HEAD, which is the
102
+ // honest "no current branch" answer the window renders as (detached).
103
+ git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
104
+ ])
105
+ for (const result of [local, remote, head]) {
106
+ if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.listFailed', { error: '' }))
107
+ }
108
+ return {
109
+ root,
110
+ current: head.stdout.trim(),
111
+ local: lines(local.stdout),
112
+ remote: lines(remote.stdout).filter((name) => !/\/HEAD$/.test(name)),
113
+ }
114
+ }
115
+
116
+ // Check out a branch. A remote-tracking ref is switched through the local
117
+ // branch git's own DWIM would create for it (`origin/feature` -> `feature`),
118
+ // unless a local branch of that name already exists — then that one wins, so
119
+ // the user never lands on a second branch tracking the same upstream.
120
+ export async function switchGitBranch(cwd, ref, { remote = false } = {}) {
121
+ const root = await worktreeRoot(cwd)
122
+ let target = ref
123
+ if (remote) {
124
+ const slash = ref.indexOf('/')
125
+ const local = slash < 0 ? ref : ref.slice(slash + 1)
126
+ const locals = await git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
127
+ if (!locals.spawned) throw new Error(t('git.gitMissing'))
128
+ if (!locals.ok) throw new Error(firstLine(locals.stderr))
129
+ const localNames = lines(locals.stdout)
130
+ if (!localNames.includes(local)) {
131
+ const created = await git(root, ['checkout', '-b', local, '--track', ref])
132
+ if (!created.spawned) throw new Error(t('git.gitMissing'))
133
+ if (!created.ok) throw new Error(firstLine(created.stderr) || t('git.switchFailed', { branch: ref, error: '' }))
134
+ return { branch: local, root }
135
+ }
136
+ target = local
137
+ }
138
+ const result = await git(root, ['checkout', target])
139
+ if (!result.spawned) throw new Error(t('git.gitMissing'))
140
+ if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.switchFailed', { branch: target, error: '' }))
141
+ return { branch: target, root }
142
+ }
143
+
144
+ // Manual pull: refresh every remote-tracking ref, then fast-forward the
145
+ // current branch. `--ff-only` is deliberate — a TUI has no way to resolve a
146
+ // merge conflict, so a diverged branch must fail with git's own explanation
147
+ // rather than stop halfway with conflict markers in the worktree.
148
+ export async function pullGitUpdates(cwd) {
149
+ const root = await worktreeRoot(cwd)
150
+ const fetched = await git(root, ['fetch', '--all', '--prune'])
151
+ if (!fetched.spawned) throw new Error(t('git.gitMissing'))
152
+ if (!fetched.ok) throw new Error(firstLine(fetched.stderr) || t('git.pullFailed', { error: '' }))
153
+ const pulled = await git(root, ['pull', '--ff-only'])
154
+ if (!pulled.spawned) throw new Error(t('git.gitMissing'))
155
+ if (!pulled.ok) throw new Error(firstLine(pulled.stderr) || t('git.pullFailed', { error: '' }))
156
+ return { summary: lastLine(fetched.stderr + '\n' + fetched.stdout + '\n' + pulled.stdout + '\n' + pulled.stderr) }
157
+ }
package/lib/image.js CHANGED
@@ -97,8 +97,12 @@ export function renderHalfblock(rgba, pxW, pxH, cellsW, cellsH, { bgHex = '00000
97
97
  for (let cx = 0; cx < cellsW; cx++) {
98
98
  const x0 = cx * cellPxW
99
99
  const y0 = cy * cellPxH
100
+ // The lower half takes whatever the upper half left over: an odd cell
101
+ // height would otherwise leave its last pixel row unsampled, and every
102
+ // cell would read a shade lighter than the pixels under it.
103
+ const lowerH = Math.max(1, cellPxH - halfH)
100
104
  const top = blockColor(x0, y0, cellPxW, halfH)
101
- const bottom = blockColor(x0, y0 + halfH, cellPxW, halfH)
105
+ const bottom = blockColor(x0, y0 + halfH, cellPxW, lowerH)
102
106
  let seg
103
107
  if (top === bottom) seg = { text: ' ', style: { fg: top, bg: top } }
104
108
  else seg = { text: '▀', style: { fg: top, bg: bottom } }