dsh-neotui 0.4.0 → 0.4.2
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/CHANGELOG.md +15 -0
- package/README.md +6 -2
- package/bin/dsh-tui.js +5 -1
- package/package.json +1 -1
- package/src/config.js +17 -2
- package/src/file-picker.js +11 -3
- package/src/index.js +1 -1
- package/src/keybindings.js +5 -2
- package/src/logo.js +43 -0
- package/src/panels.js +94 -4
- package/src/theme.js +142 -3
- package/src/views.js +462 -65
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2 — 2026-08-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 欢迎页改版:40×19 半块渲染的 DeepSeek 吉祥物 logo(真实色 255 色调色板 + 逐格上下半色,数据内嵌 `src/logo.js`,去背景、无残块、裁剪掉原图底部黑条);品牌行用 **`█▀▄` 半块像素字** 绘制(`DEEPSEEK` / `DSH NEOTUI` 两行大字标,5×7 像素字模),**每约 3.5 秒一道斜向高光扫过字标**(0.9s 扫程,纯 TUI 渲染并随主题语义色,扫过字标全宽含末尾 `I`;`DSH NEOTUI` 用深灰底保证白色高光可见);**logo 三态** `Ctrl+R` buffer:预设(内置)/ 自定义(复用 Ctrl+O 与新建工作区的 `UploadPicker` 单选 `{palette, grid}` 40×19 JSON)/ 关闭(仅标题),持久化到配置文件;版本更新提示收敛到底部(仅「可更新/失败/检查中」时显示,可点击检查,**最新则不提示**);模式选择从内联 4 列表收敛为底部提示「模式: <当前> · F9 打开模式选择(支持自定义)」——与 WebUI 鼓励自定义预设的方向一致;矮视口(<32 行或 <62 列)回退为普通文本品牌行。
|
|
8
|
+
- 启动动画(经典游戏启动器风格,**opt-in meme**):纯黑 → 白色从屏幕中心圆形扩散至全屏(1s)→ 四行健康游戏忠告逐句淡入(2s)→ 忠告淡出的同时 `DEEPSEEK` 半块大字交叉淡入(2s)→ 底部闪烁「按任意键进入游戏」,任意键进入主界面——全程纯 ANSI(`█▀▄`),无图片协议;默认关闭,`--launcher-anime`(或 `DSH_TUI_LAUNCHER_ANIME=1`)显式启用,`DSH_TUI_NO_SPLASH=1` 强制关闭;`--script` 模式不播放,窄小终端自动跳过。
|
|
9
|
+
|
|
10
|
+
## 0.4.1 — 2026-08-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- 11 套常用配色主题:dark、light、gruvbox 之外新增 nord、solarized-dark、solarized-light、dracula、onedark、catppuccin-mocha、tokyonight、monokai(全套语义键补齐;新增回归测试保证每个主题键集完整、循环覆盖全部方案)。
|
|
15
|
+
- 主题改为**选择器**而非轮换:`Ctrl+D`、`/theme` 或命令面板打开「外观 · 配色方案」面板,每行渲染该方案的色板(面板/用户/强调/成功/警告/错误六色),**光标移动即对整套 TUI 即时预览**(`setThemePreview`,不持久化,标题栏提示「预览: <name>」),`Enter` 或双击应用后**停留**继续调整(控制类 buffer 不再因选择而退出),`Esc` 恢复已提交主题并关闭;快捷键可重映射(`themePicker`)。
|
|
16
|
+
- pane 焦点切换拆分为两个独立命令:`panePrev`(`Ctrl+←`)与 `paneNext`(`Ctrl+→`)——与 `[` / `]` 一样各自可重映射,不再共用一个主/备槽位绑定;旧配置里的 `homeSwitch`(如 `"Ctrl+Left/Right"` 或自定义方向映射)自动迁移为两个方向。
|
|
17
|
+
|
|
3
18
|
## 0.4.0 — 2026-08-21
|
|
4
19
|
|
|
5
20
|
### Changed
|
package/README.md
CHANGED
|
@@ -155,7 +155,8 @@ Kitty graphics 可用时,图片在文件选择器和附件预览中等比例
|
|
|
155
155
|
| NORMAL | `Ctrl+W` | 工作区 |
|
|
156
156
|
| NORMAL | `Ctrl+Shift+W` | 添加工作区 |
|
|
157
157
|
| NORMAL | `Ctrl+T` | 轨迹视图 |
|
|
158
|
-
| NORMAL | `Ctrl+←`
|
|
158
|
+
| NORMAL | `Ctrl+←` | pane 焦点移到上一窗格(工作区栏 / 对话 / 轨迹循环) |
|
|
159
|
+
| NORMAL | `Ctrl+→` | pane 焦点移到下一窗格(两个方向独立可重映射) |
|
|
159
160
|
| NORMAL | `Ctrl+E` | 按 step 快速跳转 |
|
|
160
161
|
| NORMAL | `Ctrl+J` | 后台任务与 Subagent |
|
|
161
162
|
| NORMAL | `Ctrl+N` | 排队命令详情 |
|
|
@@ -167,6 +168,7 @@ Kitty graphics 可用时,图片在文件选择器和附件预览中等比例
|
|
|
167
168
|
| NORMAL | `Ctrl+H` | Skills |
|
|
168
169
|
| NORMAL | `Ctrl+K` | 用默认编辑器(`$EDITOR` / `$VISUAL` / `vi`)打开 `tui-config.json`;编辑完成后快捷键立即重载 |
|
|
169
170
|
| NORMAL | `Ctrl+Shift+C` | 复制正文选区/当前块(与 `y` 等价) |
|
|
171
|
+
| NORMAL | `Ctrl+D` | 配色主题选择器(`/theme` 同义) |
|
|
170
172
|
| NORMAL | `Ctrl+C` | 双击退出(第一次按仅提示) |
|
|
171
173
|
| ALL | `Ctrl+Q` | 退出 |
|
|
172
174
|
| 正文块 | `↑` / `↓`、`j` / `k` | 上下选择正文块,两端停留不环绕;`Ctrl+↑/↓` 只滚动视口 |
|
|
@@ -208,7 +210,9 @@ TUI 支持:
|
|
|
208
210
|
- 工具审批、AskUser 单选/多选、Plan Review;
|
|
209
211
|
- Goal、TODO、后台任务和 Subagent;
|
|
210
212
|
- 独立插件清单及 `/` 筛选;
|
|
211
|
-
-
|
|
213
|
+
- 空白会话欢迎页:40×19 半块渲染的 DeepSeek 吉祥物 logo(真实色、无背景);品牌行用 **`█▀▄` 半块像素字**绘制(`DEEPSEEK` / `DSH NEOTUI` 两行大字标),**每约 3.5 秒一道斜向高光扫过字标**(纯 TUI 渲染,随主题语义色);**logo 三态**(`Ctrl+R` 打开 buffer):预设(内置)/ 自定义(Yazi 风格文件选择器单选 JSON,格式 `{palette, grid}` 40×19)/ 关闭(仅标题),选择持久化到配置文件;版本更新提示收敛到底部(`DeepSeek Harness 可更新 v…` / `dsh-neotui 可更新 v…`,可点击检查,**最新时不显示**);模式选择为底部提示(`F9 打开模式选择(支持自定义)`);矮视口(<29 行或 <60 列)回退为普通文本品牌行;
|
|
214
|
+
- 11 套配色主题:dark、light、gruvbox、nord、solarized-dark、solarized-light、dracula、onedark、catppuccin-mocha、tokyonight、monokai;`Ctrl+D`、`/theme` 或命令面板打开**配色选择器**——每行带该方案的色板(面板/用户/强调/成功/警告/错误六色),**光标移动即对整套 TUI 即时预览**(未持久化,标题栏显示「预览: <name>」),`Enter` 或双击应用并停留继续调整,`Esc` 恢复已提交主题并关闭;选择持久化到 `$DSH_HOME/tui-theme.txt`。
|
|
215
|
+
- 启动动画(经典游戏启动器风格,**默认关闭的 meme**):纯黑 → 白色从屏幕中心圆扩散到全屏(约 1s)→ 四行健康游戏忠告("抵制不良游戏,拒绝盗版游戏。"等,逐句淡入,约 2s)→ 忠告淡出同时 `DEEPSEEK` 半块大字交叉淡入(约 2s)→ 底部闪烁「按任意键进入游戏」,**按任意键进入**。全程纯 ANSI(`█▀▄`);`dsh-tui --launcher-anime`(或 `DSH_TUI_LAUNCHER_ANIME=1`)显式启用,`DSH_TUI_NO_SPLASH=1` 强制关闭,`--script` 模式不播放。
|
|
212
216
|
|
|
213
217
|
工作区(`Ctrl+W`)、设置(`Ctrl+S`)、模型供应商(模型选择器里的 `⚙ 管理供应商…`)、子代理(`Ctrl+A`)和技能(`Ctrl+H`)都是**全屏模态 Buffer**:打开后覆盖整个界面,`Esc` 逐级返回并最终关闭。它们不再占用“标签页模式”,因此关闭后 `Ctrl+←/→` 的 pane 聚焦立即恢复,两者互不冲突。轨迹仍是 pane 序列的一部分,由 `Ctrl+T` 或 `Ctrl+←/→` 进入。
|
|
214
218
|
|
package/bin/dsh-tui.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Usage:
|
|
4
4
|
// dsh-tui interactive mode (needs a real terminal)
|
|
5
5
|
// dsh-tui --base http://host:port
|
|
6
|
+
// dsh-tui --launcher-anime opt-in white fade-in boot animation
|
|
6
7
|
// dsh-tui --script <file> scripted mode: feed events from a file, dump frames
|
|
7
8
|
// dsh-tui --plain with --script: dump plain text frames (no ANSI)
|
|
8
9
|
import { Term, detectKitty } from "../src/term.js";
|
|
@@ -29,9 +30,12 @@ async function main() {
|
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
// ---- interactive ----
|
|
33
|
+
// The boot animation is opt-in meme fuel: --launcher-anime enables it
|
|
34
|
+
// (DSH_TUI_LAUNCHER_ANIME=1 works too); it stays OFF by default.
|
|
35
|
+
const launcherAnime = has("--launcher-anime") || process.env.DSH_TUI_LAUNCHER_ANIME === "1";
|
|
32
36
|
const screen = new Screen(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
33
37
|
const api = new Api({ base, log, onFrame: () => {}, onHostFrame: () => {} });
|
|
34
|
-
const app = new App({ screen, term: null, api, log });
|
|
38
|
+
const app = new App({ screen, term: null, api, log, launcherAnime });
|
|
35
39
|
const term = new Term({
|
|
36
40
|
output: process.stdout,
|
|
37
41
|
kitty: detectKitty(),
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -90,13 +90,28 @@ export function rememberSearchQuery(query) {
|
|
|
90
90
|
* bindings (e.g. an old sessionFilter "/" override removes Ctrl+F). */
|
|
91
91
|
const LEGACY_KEY_VALUES = {
|
|
92
92
|
sessionFilter: { mode: "normal", key: "/" },
|
|
93
|
-
homeSwitch: { mode: "normal", key: "Ctrl+Left/Right" },
|
|
94
93
|
skills: { mode: "normal", key: "Ctrl+K" },
|
|
95
94
|
};
|
|
96
95
|
|
|
96
|
+
/** Split one legacy two-slot binding into two directional functions.
|
|
97
|
+
* pre-0.4.x pane focus was a single homeSwitch with key/key2; a stored
|
|
98
|
+
* custom remap now powers panePrev/paneNext independently. */
|
|
99
|
+
function splitLegacyHomeSwitch(stored) {
|
|
100
|
+
const n = normalizeKeyBinding(stored);
|
|
101
|
+
if (!n.key || !n.key2 || !validateKeySpec(n.key).ok || !validateKeySpec(n.key2).ok) return null;
|
|
102
|
+
return {
|
|
103
|
+
panePrev: { mode: n.mode, key: n.key, key2: "" },
|
|
104
|
+
paneNext: { mode: n.mode, key: n.key2, key2: "" },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
/** Effective two-slot keybindings: defaults merged per id with user overrides. */
|
|
98
109
|
export function keyBindings() {
|
|
99
|
-
const
|
|
110
|
+
const stored = loadTuiConfig().keyBindings ?? {};
|
|
111
|
+
const overrides = { ...stored };
|
|
112
|
+
if (stored.homeSwitch && !Object.hasOwn(stored, "panePrev") && !Object.hasOwn(stored, "paneNext")) {
|
|
113
|
+
Object.assign(overrides, splitLegacyHomeSwitch(stored.homeSwitch) ?? {});
|
|
114
|
+
}
|
|
100
115
|
const merged = {};
|
|
101
116
|
for (const id of Object.keys(DEFAULT_KEYBINDINGS)) {
|
|
102
117
|
const def = DEFAULT_KEYBINDINGS[id];
|
package/src/file-picker.js
CHANGED
|
@@ -49,10 +49,10 @@ class YnPopup extends Popup {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export class UploadPicker extends Widget {
|
|
52
|
-
constructor(app, { startPath, onUpload, onCancel, selectDirectories = false, onPickDirectory = null }) {
|
|
52
|
+
constructor(app, { startPath, onUpload, onCancel, selectDirectories = false, onPickDirectory = null, single = false, onPickFile = null }) {
|
|
53
53
|
const w = Math.min(app.screen.w - 4, 120), h = Math.min(app.screen.h - 4, 34);
|
|
54
54
|
super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
|
|
55
|
-
this.app = app; this.path = startPath; this.onUpload = onUpload; this.onCancel = onCancel; this.selectDirectories = selectDirectories; this.onPickDirectory = onPickDirectory;
|
|
55
|
+
this.app = app; this.path = startPath; this.onUpload = onUpload; this.onCancel = onCancel; this.selectDirectories = selectDirectories; this.onPickDirectory = onPickDirectory; this.single = single; this.onPickFile = onPickFile;
|
|
56
56
|
this.all = []; this.sel = 0; this.selected = new Map(); this.filter = ''; this.filterInput = null; this.showHidden = false; this.pathPopup = null; this.imagePreview = null;
|
|
57
57
|
this.load();
|
|
58
58
|
}
|
|
@@ -81,6 +81,7 @@ export class UploadPicker extends Widget {
|
|
|
81
81
|
this.confirmDirectory(it.path); return;
|
|
82
82
|
}
|
|
83
83
|
if (it.dir) { this.app.toast('不可选择文件夹'); return; }
|
|
84
|
+
if (this.single) { this.clearKitty(); this.onPickFile?.(it.path); return; }
|
|
84
85
|
if (this.selected.has(it.path)) this.selected.delete(it.path); else this.selected.set(it.path, it);
|
|
85
86
|
this.app.redraw();
|
|
86
87
|
}
|
|
@@ -187,7 +188,14 @@ export class UploadPicker extends Widget {
|
|
|
187
188
|
if (ev.name === 'down') { this.clearKitty(); this.sel = wrapIndex(this.sel + 1, this.items().length); return true; }
|
|
188
189
|
if (ev.name === 'left') { this.goParent(); return true; }
|
|
189
190
|
if (ev.name === 'right') { this.enterDir(); return true; }
|
|
190
|
-
if (ev.name === 'enter') {
|
|
191
|
+
if (ev.name === 'enter') {
|
|
192
|
+
if (this.single) {
|
|
193
|
+
const it = this.current();
|
|
194
|
+
if (it?.dir) { this.enterDir(); return true; }
|
|
195
|
+
if (it) { this.clearKitty(); this.onPickFile?.(it.path); return true; }
|
|
196
|
+
}
|
|
197
|
+
this.confirmUpload(); return true;
|
|
198
|
+
}
|
|
191
199
|
if (ev.name === 'char' && ev.key === ' ') { this.toggle(); return true; }
|
|
192
200
|
if (ev.name === 'char' && ev.key === '/') { this.startFilter(); return true; }
|
|
193
201
|
return false;
|
package/src/index.js
CHANGED
|
@@ -23,7 +23,7 @@ export function launchTui(opts = {}) {
|
|
|
23
23
|
}
|
|
24
24
|
const screen = new Screen(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
25
25
|
const api = new Api({ base: opts.base ?? "http://127.0.0.1:1", log, onFrame: () => {}, onHostFrame: () => {} });
|
|
26
|
-
const app = new App({ screen, term: null, api, log });
|
|
26
|
+
const app = new App({ screen, term: null, api, log, launcherAnime: opts.launcherAnime ?? process.env.DSH_TUI_LAUNCHER_ANIME === "1" });
|
|
27
27
|
const term = new Term({
|
|
28
28
|
output: process.stdout,
|
|
29
29
|
kitty: detectKitty(),
|
package/src/keybindings.js
CHANGED
|
@@ -32,7 +32,8 @@ export const DEFAULT_KEYBINDINGS = {
|
|
|
32
32
|
panel: { mode: "all", key: "Ctrl+Space", key2: "F7" },
|
|
33
33
|
model: { mode: "normal", key: "Ctrl+M", key2: "" },
|
|
34
34
|
trajectory: { mode: "normal", key: "Ctrl+T", key2: "" },
|
|
35
|
-
|
|
35
|
+
panePrev: { mode: "normal", key: "Ctrl+Left", key2: "" },
|
|
36
|
+
paneNext: { mode: "normal", key: "Ctrl+Right", key2: "" },
|
|
36
37
|
permissionRotate: { mode: "normal", key: "F8", key2: "" },
|
|
37
38
|
workspace: { mode: "normal", key: "Ctrl+W", key2: "" },
|
|
38
39
|
settings: { mode: "normal", key: "Ctrl+S", key2: "" },
|
|
@@ -49,13 +50,15 @@ export const DEFAULT_KEYBINDINGS = {
|
|
|
49
50
|
addWorkspace: { mode: "normal", key: "Ctrl+Shift+W", key2: "" },
|
|
50
51
|
commandPalette: { mode: "normal", key: "Ctrl+P", key2: "" },
|
|
51
52
|
modePicker: { mode: "normal", key: "F9", key2: "" },
|
|
53
|
+
themePicker: { mode: "normal", key: "Ctrl+D", key2: "" },
|
|
54
|
+
logoPicker: { mode: "normal", key: "Ctrl+R", key2: "" },
|
|
52
55
|
quitDouble: { mode: "normal", key: "Ctrl+C", key2: "" },
|
|
53
56
|
quit: { mode: "all", key: "Ctrl+Q", key2: "" },
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
/** App-level dispatch precedence: the first matching binding wins. */
|
|
57
60
|
export const KEYBINDING_ORDER = [
|
|
58
|
-
"sessionFilter", "panel", "
|
|
61
|
+
"sessionFilter", "panel", "panePrev", "paneNext", "permissionRotate", "editConfig", "addWorkspace", "commandPalette", "modePicker", "themePicker", "logoPicker", "copySelection", "quitDouble", "quit",
|
|
59
62
|
"model", "trajectory", "workspace", "settings", "subagent", "skills", "goal",
|
|
60
63
|
"jobs", "queue", "busyEnter", "attachments", "stepJump", "sidebar",
|
|
61
64
|
];
|
package/src/logo.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// logo.js — Welcome-page logo data (DeepSeek mascot face, 40x19 half-block).
|
|
2
|
+
// Generated from the source artwork: background removed, black bar cropped out,
|
|
3
|
+
// 1:2 terminal-cell sampling, colors quantized to 255 entries (0 = empty cell).
|
|
4
|
+
// Each grid cell encodes [top, bottom] palette indices (one hex byte each);
|
|
5
|
+
// rendering uses the half-block glyph: top half = fg color, bottom = bg color.
|
|
6
|
+
export const LOGO_COLS = 40;
|
|
7
|
+
export const LOGO_ROWS = 19;
|
|
8
|
+
export const LOGO_PALETTE = [
|
|
9
|
+
"fdfffc","fdfdfd","fdfcfd","fefbfd","fcfafb","fef5f5","fcebdd","fdeade",
|
|
10
|
+
"fdead9","ffe9df","ffe9dc","fde9de","fde8db","fee7dd","ffe5df","fee3dd",
|
|
11
|
+
"fde5d9","fee3d7","ffe2de","fafefa","f7fdfb","fafcfc","fbfbfc","f8fbff",
|
|
12
|
+
"fbfafc","faf8fb","f8f9fb","f4faf7","f7f2f0","faeae1","fae6dc","f9e3d8",
|
|
13
|
+
"f2f4f5","f4e7df","daebf3","fee1d6","fbe0d6","ffded6","fddbd0","fdd8ce",
|
|
14
|
+
"f6dfd6","f4dbd3","e9dbdc","dbdbe0","fcd6cc","f5d6cd","fdd1c6","e3d3da",
|
|
15
|
+
"d5d1e3","e8c7c3","d1d1e8","cfcedb","d0cce2","cfcddc","c7cfe3","c6cce0",
|
|
16
|
+
"afdbf3","a7cdf2","cec9dc","c9c9da","cac6dc","c8c7d8","c6c8de","c0c8e6",
|
|
17
|
+
"c5c5da","ecb9b8","d6b6bb","ebadaa","deadb0","c2c1d4","b8bedd","bfb6c7",
|
|
18
|
+
"cdaaac","a1b5e6","adb6cf","9bb3e2","a4aed6","9bb0e2","95b1e6","92aedf",
|
|
19
|
+
"96abda","92aad7","f8a099","e6a4a5","d6a7b0","d89190","cd8b8a","a5a5bf",
|
|
20
|
+
"97a3cb","96a8da","a493a4","92a9d7","8ea8d4","92a5d3","90a1cf","8e9bcb",
|
|
21
|
+
"8e94bc","849ecb","809bc8","8498c3","8793c2","7894bd","8491be","818cbb",
|
|
22
|
+
"748dba","7f8bbc","7a87b7","7789bb","7489bc","7186b9","6e88b6","6d86b6",
|
|
23
|
+
"7f83ac","7684b5","7583b8","7383b5","7281b2","6f83ba","6f83b6","6e81b7",
|
|
24
|
+
"6f80b4","6b84b8","6b82b3","6b81b7","6b80b7","6883b4","6880b8","6381ab",
|
|
25
|
+
"b36865","897c98","777d9d","6e7fb3","6d7aab","6d7593","6a7fb9","697db9",
|
|
26
|
+
"697fb4","6a7cb2","6a78ae","677eb6","677db4","667cb5","677dae","677cb0",
|
|
27
|
+
"687ab3","677aa9","6772a6","657eb3","637eb5","647cb1","657bb2","627bb4",
|
|
28
|
+
"657ab3","6579ae","627ab1","6279ab","6478b0","6377ab","6278af","6276ad",
|
|
29
|
+
"6373ae","626c9a","5e7caf","6079b1","5e77a8","5e76a2","5675a8","5e72a9",
|
|
30
|
+
"5b71a1","5870a9","5b6fa5","5f6ca8","566da6","596c9f","586b8d","546d9d",
|
|
31
|
+
"536ba0","506aa1","54679f","52679e","53659f","53649d","52609c","5c6797",
|
|
32
|
+
"536697","556298","5a617d","50659e","4b649a","4a6198","4d6099","4b5e97",
|
|
33
|
+
"4b5e8a","7e4c49","615068","694047","4d598b","4e486c","475a92","47558d",
|
|
34
|
+
"475389","474d80","455a94","445894","445793","435791","445884","43558a",
|
|
35
|
+
"43528c","434776","41568f","40538e","3f548b","3f547e","3a4d86","3a4a74",
|
|
36
|
+
"38477f","3b4477","34437d","3f4366","3c3e63","304175","2d3d76","293b73",
|
|
37
|
+
"5c353d","433348","4f2c33","482128","323568","2d2e56","322847","2d1e31",
|
|
38
|
+
"21376c","20316b","1d2e66","232b5a","1b2b60","1c2759","222445","241f3f",
|
|
39
|
+
"1c1a29","152859","102458","132051","131f44","0b1e51","131a41","0c1b4e",
|
|
40
|
+
"081a52","0c1547","101432","041546","070e39","00113c","06071d",
|
|
41
|
+
];
|
|
42
|
+
export const LOGO_GRID =
|
|
43
|
+
"000000000000000000000000000000000000000000640016151705360503348282dcbebfbdb5ed3c2101f53c5c4e5d52038f051904033c1700bb000000000000000000000000000000000000000000000000000000000000000000000000000000210021c3050286031c354c1b7ffa7ff4bfbfb7c6eb33a1c0e0a3d79a897c8763e92c5d032c1b3501461818d61800860000000000000000000000000000000000000000000000000000000000001c14171d021702333f5062959599a4d9d78edfb28e95959598958ea78eaddb6d7676879b94958d94fa9c31fe043b0534191a00160000000000000000000000000000000000000000000000000000152317193401315c514e8d9898a4e9998e998ed78ea495989598959895a495989595959595957f7a9595967e6f51d89216fb01823e3500bf00000000000000000000000000000000000000000000bb193405331af44e645c525c89a0a16d99a999878e6d98999899999f989b98a498a598a88c6f995ca45d977c4e969a8e62e25a853e6086343635663b000000000000000000000000000000000000dcd819053b14f54c929d9f8e9d88a0a05e9fa64eedad8a8f525c5c5e5c62609a4e9a5c90978a9d8a4c9597989098f39b998e2a8b0f0fc3263be33b3b353100c100000000000000000000000000000000d947863b82627b98989898988ef29c8e9b999b8e87919d8a9095a3888d878d9794878c7d887dbe3b8c7c958795888e8d99d99899a59b29ac0b0ae40a35e1e0ba000a000d220be2200000000000000000eec0334a9a99a4a498959895cccb8e87998788e5e65b7c747674786c786db979766b776c6f6a43ca85c67dc78785c329bb247b1096e49d93bab72eb80c2e490e8128492710442e420000000000fc00ae3a9a9cd5d599a49895999588cbcb76c0736ac613dc0dd893936b6a656a676a6568657585686532090c0b45076a45726cc3c70f720b7511322007c60de10d08490c0a27c242c4c25600000000f600f900dfd0f9a199aa8e8c897d7b77cbbfe645f04907060b1e322665a26b4865686565ca60e749e71e0b0b0b0b0b0b0d20320ee62a756879737584a2b342b920e111440c0a1009c207420a0000000000000000f2c88b9dcbcc8d8d7d766e73acee32e4f12bff01ddebe3db81587107553d6b82615558ef072b2ac43022ff27ff2ee42a0a1368656b689284ada8b4b6dfdee2da07e10b420b250b2200000000005fbac9d0c88d4edfcb9c88a57d6b73f067e12a1b82d715f3233a230122070b07070b0707070b070b0b1124272d2f2f2f2f2d2d316167f6ab6a7984b7f9b8d7fe59da59d959d95fdad9c3f4005f60c8c9c9c9d7c9f24af4f8f8c7fa789eae7967ba4a752ee8282f2e2d272724120a0b0a0b1e0b09070a070c071207241228242d24612532f0b96b758451ebbfc7b3ea4dd559ee474040f540faf7dec940fe4059404737595959deed4ed0d5bcc791b67279ee7449c41e11272524110e070b0d0bc20b2e07e107810781070c0b0a0b0a0913f14379b675d1d1d1d38fcef9cdcdd3bcaad3aaaadeab000000000000000000aebff8c7becb768ab3cecef9d1d1adeac073d36bd161a2e21e0a0a0b0a090a810b535353535353530a0a0a0a0a0b1e0aafbabdf6d0d49d7c908a8deacecceaa8d5ce9eaaccf3d6abf6004a00fefeeefe3939fac0628f96d5d5b5cdd4ceb6f98a90a18abccec8d1c5b129b40ab80a200a0a0a0a110c0d0c0c0a0cc407e1b8adb2b1b39e73ab89908d4ec7f2ccccd5d3ce169a03ce9284ce4ffef44fbeae393939f4b7908f8f8ad3b302d41bf9e0d1335afc9062907c7cd5aba8aae9aaa7b0335744e45708571d5757545744e1e2b847a7a5abe07d5d517e7eedde3f333c1514172c041b02027b75d07b4ff7b0a63a524c7e8f7ad77ab3b91905191704194603373e4cf75c706e6fab9ca5a9a54b3f3f4641453c5438544945345b3c3c3c413deb47e9fd6f666f694747462cbb23ad14bdb2b0381605e6e7e6dcd2d25d6669fc8f696db9b9b0b9fb0517171b2c051a34463440dd4ca66666efef863d3cfb3dfb3df73d48ddf04bf03fe63cff3ddd3ed21cdc413e66636980f4803e191a05a61bbb211482021bef05ddfbd2d2";
|
package/src/panels.js
CHANGED
|
@@ -9,7 +9,7 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { join, basename, extname, dirname } from "node:path";
|
|
10
10
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
11
11
|
|
|
12
|
-
import { T,
|
|
12
|
+
import { T, THEMES, setTheme, setThemePreview, clearThemePreview, themeName, renderedThemeName } from "./theme.js";
|
|
13
13
|
import { loadTuiConfig, saveTuiConfig, userPrefix, userName, foldDefaults, keyBindings, setKeyBinding, resetKeyBinding } from "./config.js";
|
|
14
14
|
import { validateKeySpec, describeSpec } from "./keybindings.js";
|
|
15
15
|
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
@@ -361,6 +361,96 @@ export function buildPermissionPicker(app) {
|
|
|
361
361
|
});
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/** Theme selector with live preview: moving the cursor re-renders the whole
|
|
365
|
+
* UI in the hovered scheme (nothing persisted until Enter), and Enter commits
|
|
366
|
+
* while keeping the buffer open — a control buffer must not exit on pick. */
|
|
367
|
+
export class ThemePickerBuffer extends Widget {
|
|
368
|
+
constructor(app) {
|
|
369
|
+
const names = Object.keys(THEMES);
|
|
370
|
+
const w = Math.max(1, Math.min(64, app.screen.w - 4)), h = Math.max(1, Math.min(names.length + 4, app.screen.h - 4));
|
|
371
|
+
super({ x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)), w, h });
|
|
372
|
+
this.app = app;
|
|
373
|
+
this.names = names;
|
|
374
|
+
this.sel = Math.max(0, names.indexOf(themeName()));
|
|
375
|
+
this.scroll = 0;
|
|
376
|
+
}
|
|
377
|
+
#nameOf(idx) { return this.names[idx]; }
|
|
378
|
+
/** Select + preview: the whole TUI re-renders in the hovered scheme. */
|
|
379
|
+
#move(idx) {
|
|
380
|
+
this.sel = wrapIndex(idx, this.names.length);
|
|
381
|
+
setThemePreview(this.#nameOf(this.sel) ?? themeName());
|
|
382
|
+
this.app.redraw();
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/** Commit the previewed theme and KEEP the buffer open for further tweaks. */
|
|
386
|
+
#commit() {
|
|
387
|
+
const name = this.#nameOf(this.sel);
|
|
388
|
+
if (!name) return;
|
|
389
|
+
if (setTheme(name)) this.app.toast(`已应用主题: ${name}(Esc 关闭)`);
|
|
390
|
+
this.app.redraw();
|
|
391
|
+
}
|
|
392
|
+
onMouse(ev) {
|
|
393
|
+
if (ev.kind === "wheel-up") return this.#move(this.sel - 1);
|
|
394
|
+
if (ev.kind === "wheel-down") return this.#move(this.sel + 1);
|
|
395
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
396
|
+
const idx = this.scroll + (ev.y - this.y - 2);
|
|
397
|
+
if (idx < 0 || idx >= this.names.length) return true;
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
this.#move(idx);
|
|
400
|
+
if (this.lastClick && now - this.lastClick < 400) this.#commit();
|
|
401
|
+
else this.lastClick = now;
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
onKey(ev) {
|
|
407
|
+
if (ev.type !== "key") return false;
|
|
408
|
+
if (ev.name === "up") return this.#move(this.sel - 1);
|
|
409
|
+
if (ev.name === "down") return this.#move(this.sel + 1);
|
|
410
|
+
if (ev.name === "pgup") { this.sel = Math.max(0, this.sel - (this.h - 3)); setThemePreview(this.#nameOf(this.sel)); this.app.redraw(); return true; }
|
|
411
|
+
if (ev.name === "pgdn") { this.sel = Math.min(this.names.length - 1, this.sel + (this.h - 3)); setThemePreview(this.#nameOf(this.sel)); this.app.redraw(); return true; }
|
|
412
|
+
if (ev.name === "home") return this.#move(0);
|
|
413
|
+
if (ev.name === "end") return this.#move(this.names.length - 1);
|
|
414
|
+
if (ev.name === "enter") { this.#commit(); return true; }
|
|
415
|
+
if (ev.name === "escape") {
|
|
416
|
+
// Cancel: drop the preview (revert to the committed theme), then close.
|
|
417
|
+
clearThemePreview();
|
|
418
|
+
this.app.overlay = null;
|
|
419
|
+
this.app.redraw();
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
render(s) {
|
|
425
|
+
s.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
426
|
+
const previewing = renderedThemeName() !== themeName();
|
|
427
|
+
s.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 },
|
|
428
|
+
`外观 · 配色方案${previewing ? ` · 预览: ${renderedThemeName()}` : ""}`);
|
|
429
|
+
s.text(this.x + 2, this.y + this.h - 1, "↑/↓ 即时预览 · Enter 应用(停留) · 双击应用 · Esc 恢复并关闭", { fg: K.FAINT, bg: T.BG2 });
|
|
430
|
+
const lh = Math.max(1, this.h - 4);
|
|
431
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
432
|
+
if (this.sel >= this.scroll + lh) this.scroll = this.sel - lh + 1;
|
|
433
|
+
for (let i = 0; i < lh; i++) {
|
|
434
|
+
const idx = this.scroll + i;
|
|
435
|
+
const name = this.#nameOf(idx);
|
|
436
|
+
const y = this.y + 2 + i;
|
|
437
|
+
if (!name) continue;
|
|
438
|
+
const palette = THEMES[name];
|
|
439
|
+
const sel = idx === this.sel, current = name === themeName();
|
|
440
|
+
s.fillRect(this.x + 1, y, this.x + this.w - 2, y, " ", { bg: sel ? T.MENUSEL : T.BG2 });
|
|
441
|
+
// live palette strip: panel / user / accent / ok / warn / err
|
|
442
|
+
let px = this.x + 2;
|
|
443
|
+
for (const key of ["PANEL", "USERBG", "ACCENT", "OK", "WARN", "ERR"]) {
|
|
444
|
+
s.text(px, y, " ", { bg: palette[key] ?? -1, fg: palette[key] ?? -1 });
|
|
445
|
+
px += 2;
|
|
446
|
+
if (px > this.x + this.w - 4) break;
|
|
447
|
+
}
|
|
448
|
+
s.text(px + 1, y, truncate(name, this.w - (px - this.x) - 8), { fg: sel ? 0xffffff : K.TXT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 });
|
|
449
|
+
if (current) s.text(this.x + this.w - 2 - strWidth("当前"), y, "当前", { fg: K.OK, bg: sel ? T.MENUSEL : T.BG2 });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
364
454
|
export class ArchivePanel extends Popup {
|
|
365
455
|
constructor(app) {
|
|
366
456
|
const w = Math.min(90, app.screen.w - 4), h = Math.min(28, app.screen.h - 4);
|
|
@@ -453,7 +543,7 @@ export function buildCommandPalette(app) {
|
|
|
453
543
|
{ label: "刷新会话列表", action: () => app.refreshSessions(), keywords: "refresh reload" },
|
|
454
544
|
{ label: "查看归档会话", action: () => { app.overlay = new ArchivePanel(app); app.redraw(); }, keywords: "archive archived sessions history" },
|
|
455
545
|
{ label: "打开原始配置文件", action: async () => { try { const r = await app.api.call("settings.openDocument"); app.toast(r.opened ? "已在系统编辑器打开配置" : `配置文件: ${r.path}`); } catch (e) { app.toast(`打开配置失败: ${e.message}`); } }, keywords: "settings config raw document" },
|
|
456
|
-
{ label: "
|
|
546
|
+
{ label: "选择配色主题", action: () => { app.overlay = new ThemePickerBuffer(app); app.redraw(); }, keywords: "theme color scheme palette" },
|
|
457
547
|
{ label: "复制当前会话 ID", action: () => app.copyText(app.currentSession ?? ""), keywords: "copy id" },
|
|
458
548
|
{ label: "退出", hint: "q", action: () => app.stop(), keywords: "quit exit" },
|
|
459
549
|
];
|
|
@@ -1659,7 +1749,7 @@ export class ControlPanel extends Widget {
|
|
|
1659
1749
|
const b=keyBindings();
|
|
1660
1750
|
const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${describeSpec(b[id]?.key)}\t${describeSpec(b[id]?.key2)}`,desc,null,id];
|
|
1661
1751
|
return [
|
|
1662
|
-
row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","跨会话搜索"),row("newSession","新建会话"),row("top","跳到首个正文块"),row("bottom","跳到最新正文块"),row("prevQuestion","上一提问的终点"),row("nextQuestion","下一提问的终点"),row("expandInput","输入栏 展开/折叠"),row("copyInput","复制输入栏选区"),row("undoInput","输入撤销"),row("redoInput","输入重做"),row("insertFilePicker","输入中打开文件选择器"),row("pasteImage","输入中粘贴剪贴板图片"),row("copySelection","复制正文选区"),row("addWorkspace","添加工作区"),row("commandPalette","命令面板"),row("modePicker","模式选择器"),row("quitDouble","双击 Ctrl+C 退出"),row("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("
|
|
1752
|
+
row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","跨会话搜索"),row("newSession","新建会话"),row("top","跳到首个正文块"),row("bottom","跳到最新正文块"),row("prevQuestion","上一提问的终点"),row("nextQuestion","下一提问的终点"),row("expandInput","输入栏 展开/折叠"),row("copyInput","复制输入栏选区"),row("undoInput","输入撤销"),row("redoInput","输入重做"),row("insertFilePicker","输入中打开文件选择器"),row("pasteImage","输入中粘贴剪贴板图片"),row("copySelection","复制正文选区"),row("addWorkspace","添加工作区"),row("commandPalette","命令面板"),row("modePicker","模式选择器"),row("themePicker","配色主题选择器"),row("logoPicker","欢迎页 logo(预设/自定义/关闭)"),row("quitDouble","双击 Ctrl+C 退出"),row("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("panePrev","pane 焦点 ← 上一窗格"),row("paneNext","pane 焦点 → 下一窗格"),row("permissionRotate","权限模式轮换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("queue","后台队列"),row("busyEnter","运行中 Enter 策略"),row("attachments","附件管理"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("editConfig","编辑配置文件(默认编辑器)"),row("quit","退出"),
|
|
1663
1753
|
];
|
|
1664
1754
|
}
|
|
1665
1755
|
items() {
|
|
@@ -1682,7 +1772,7 @@ export class ControlPanel extends Widget {
|
|
|
1682
1772
|
["模式(Agent 预设)", "标准 / PTC / 极简 / 创造", () => { this.app.overlay = buildModePicker(this.app); this.app.redraw(); }],
|
|
1683
1773
|
["权限(沙箱 + 审批)", "只读 / 工作区写入 / 完全访问", () => { this.app.overlay = buildPermissionPicker(this.app); this.app.redraw(); }],
|
|
1684
1774
|
["完整设置(JSON 编辑器)", "所有命名空间的原始值", () => { this.app.closeOverlay(); this.app.showSettingsBuffer ? this.app.showSettingsBuffer() : this.app.setMode?.("settings"); }],
|
|
1685
|
-
["
|
|
1775
|
+
["选择配色主题", Object.keys(THEMES).join(" / "), () => { this.app.overlay = new ThemePickerBuffer(this.app); this.app.redraw(); }],
|
|
1686
1776
|
["侧栏显示/隐藏", "nvim 式整体收起", () => this.app.toggleSidebar()],
|
|
1687
1777
|
["导出当前会话日志", "下载 ZIP", () => { const sess = this.app.sessions.find((x) => x.sessionId === this.app.currentSession); if (sess) { this.app.closeOverlay(); this.app.exportSession(sess); } }],
|
|
1688
1778
|
["复制会话 ID", "", () => this.app.copyText(this.app.currentSession ?? "")],
|
package/src/theme.js
CHANGED
|
@@ -46,6 +46,126 @@ export const THEMES = {
|
|
|
46
46
|
SELBG: 0x504945, SELFG: 0xfbf1c7, CURSORBG: 0x665c54, CURSORFG: 0xfbf1c7,
|
|
47
47
|
MENUBG: 0x32302f, MENUSEL: 0x504945, SCROLLTHUMB: 0x83a598, SCROLLTRACK: 0x504945,
|
|
48
48
|
},
|
|
49
|
+
nord: {
|
|
50
|
+
name: "nord",
|
|
51
|
+
BG: 0x2e3440, BG2: 0x272c36, PANEL: 0x3b4252, STATUSBG: 0x3b4252, CARD: 0x3b4252,
|
|
52
|
+
USERBG: 0x434c5e, THINKBG: 0x323845, TOOLBG: 0x363c4c, TOOLOK: 0x37493f, TOOLERR: 0x46363b,
|
|
53
|
+
BORDER: 0x4c566a, BORDER2: 0x5c6a7f,
|
|
54
|
+
TXT: 0xd8dee9, DIM: 0x8c97a9, FAINT: 0x6b7689, BOLD: 0xeceff4,
|
|
55
|
+
ACCENT: 0x88c0d0, ACCENT2: 0x81a1c1, HEADING: 0x81a1c1,
|
|
56
|
+
LINK: 0x88c0d0, CODE: 0x8fbcbb, CODEBG: 0x3b4252,
|
|
57
|
+
OK: 0xa3be8c, WARN: 0xebcb8b, ERR: 0xbf616a,
|
|
58
|
+
PURPLE: 0xb48ead, RED: 0xbf616a, GREEN: 0xa3be8c, PINK: 0xd08770, GREENG: 0xa3be8c,
|
|
59
|
+
KEYWORD: 0xb48ead, STRING: 0xa3be8c, NUMBER: 0xebcb8b,
|
|
60
|
+
TABLEHEAD: 0xeceff4, TABLESEP: 0x4c566a, QUOTE: 0x7b88a1, QUOTEFG: 0xd8dee9,
|
|
61
|
+
SELBG: 0x4c566a, SELFG: 0xeceff4, CURSORBG: 0x4c566a, CURSORFG: 0xeceff4,
|
|
62
|
+
MENUBG: 0x3b4252, MENUSEL: 0x4c566a, SCROLLTHUMB: 0x88c0d0, SCROLLTRACK: 0x4c566a,
|
|
63
|
+
},
|
|
64
|
+
"solarized-dark": {
|
|
65
|
+
name: "solarized-dark",
|
|
66
|
+
BG: 0x002b36, BG2: 0x073642, PANEL: 0x073642, STATUSBG: 0x073642, CARD: 0x073642,
|
|
67
|
+
USERBG: 0x0e4550, THINKBG: 0x0a3540, TOOLBG: 0x0c3a44, TOOLOK: 0x103b2f, TOOLERR: 0x3b2525,
|
|
68
|
+
BORDER: 0x40575e, BORDER2: 0x586e75,
|
|
69
|
+
TXT: 0x93a1a1, DIM: 0x586e75, FAINT: 0x40575e, BOLD: 0xeee8d5,
|
|
70
|
+
ACCENT: 0x268bd2, ACCENT2: 0x2aa198, HEADING: 0x6c71c4,
|
|
71
|
+
LINK: 0x268bd2, CODE: 0x2aa198, CODEBG: 0x073642,
|
|
72
|
+
OK: 0x859900, WARN: 0xb58900, ERR: 0xdc322f,
|
|
73
|
+
PURPLE: 0x6c71c4, RED: 0xdc322f, GREEN: 0x859900, PINK: 0xd33682, GREENG: 0x859900,
|
|
74
|
+
KEYWORD: 0x6c71c4, STRING: 0x2aa198, NUMBER: 0xcb4b16,
|
|
75
|
+
TABLEHEAD: 0x93a1a1, TABLESEP: 0x586e75, QUOTE: 0x586e75, QUOTEFG: 0x93a1a1,
|
|
76
|
+
SELBG: 0x0e4752, SELFG: 0xeee8d5, CURSORBG: 0x073642, CURSORFG: 0x93a1a1,
|
|
77
|
+
MENUBG: 0x073642, MENUSEL: 0x0e4752, SCROLLTHUMB: 0x268bd2, SCROLLTRACK: 0x40575e,
|
|
78
|
+
},
|
|
79
|
+
"solarized-light": {
|
|
80
|
+
name: "solarized-light",
|
|
81
|
+
BG: 0xfdf6e3, BG2: 0xeee8d5, PANEL: 0xeee8d5, STATUSBG: 0xeee8d5, CARD: 0xeee8d5,
|
|
82
|
+
USERBG: 0xe8e0cc, THINKBG: 0xf2ecda, TOOLBG: 0xe8e4d4, TOOLOK: 0xe3ebda, TOOLERR: 0xf2e0dd,
|
|
83
|
+
BORDER: 0xd5cfc3, BORDER2: 0xbeb9ab,
|
|
84
|
+
TXT: 0x586e75, DIM: 0x839496, FAINT: 0x93a1a1, BOLD: 0x073642,
|
|
85
|
+
ACCENT: 0x268bd2, ACCENT2: 0x2aa198, HEADING: 0x6c71c4,
|
|
86
|
+
LINK: 0x268bd2, CODE: 0x2aa198, CODEBG: 0xeee8d5,
|
|
87
|
+
OK: 0x859900, WARN: 0xb58900, ERR: 0xdc322f,
|
|
88
|
+
PURPLE: 0x6c71c4, RED: 0xdc322f, GREEN: 0x859900, PINK: 0xd33682, GREENG: 0x859900,
|
|
89
|
+
KEYWORD: 0x6c71c4, STRING: 0x2aa198, NUMBER: 0xcb4b16,
|
|
90
|
+
TABLEHEAD: 0x073642, TABLESEP: 0xbeb9ab, QUOTE: 0x839496, QUOTEFG: 0x586e75,
|
|
91
|
+
SELBG: 0xc9e1e7, SELFG: 0x073642, CURSORBG: 0xd3e4ea, CURSORFG: 0x073642,
|
|
92
|
+
MENUBG: 0xeee8d5, MENUSEL: 0xc9e1e7, SCROLLTHUMB: 0x268bd2, SCROLLTRACK: 0xd5cfc3,
|
|
93
|
+
},
|
|
94
|
+
dracula: {
|
|
95
|
+
name: "dracula",
|
|
96
|
+
BG: 0x282a36, BG2: 0x21222c, PANEL: 0x282a36, STATUSBG: 0x21222c, CARD: 0x282a36,
|
|
97
|
+
USERBG: 0x313340, THINKBG: 0x262732, TOOLBG: 0x2c2e3d, TOOLOK: 0x2e3b35, TOOLERR: 0x3b2e33,
|
|
98
|
+
BORDER: 0x44475a, BORDER2: 0x6272a4,
|
|
99
|
+
TXT: 0xf8f8f2, DIM: 0xa9b1c6, FAINT: 0x6272a4, BOLD: 0xffffff,
|
|
100
|
+
ACCENT: 0xbd93f9, ACCENT2: 0xff79c6, HEADING: 0x8be9fd,
|
|
101
|
+
LINK: 0x8be9fd, CODE: 0x8be9fd, CODEBG: 0x21222c,
|
|
102
|
+
OK: 0x50fa7b, WARN: 0xffb86c, ERR: 0xff5555,
|
|
103
|
+
PURPLE: 0xbd93f9, RED: 0xff5555, GREEN: 0x50fa7b, PINK: 0xff79c6, GREENG: 0x50fa7b,
|
|
104
|
+
KEYWORD: 0xff79c6, STRING: 0xf1fa8c, NUMBER: 0xbd93f9,
|
|
105
|
+
TABLEHEAD: 0xf8f8f2, TABLESEP: 0x44475a, QUOTE: 0x6272a4, QUOTEFG: 0xf8f8f2,
|
|
106
|
+
SELBG: 0x44475a, SELFG: 0xffffff, CURSORBG: 0x44475a, CURSORFG: 0xffffff,
|
|
107
|
+
MENUBG: 0x21222c, MENUSEL: 0x44475a, SCROLLTHUMB: 0xbd93f9, SCROLLTRACK: 0x44475a,
|
|
108
|
+
},
|
|
109
|
+
onedark: {
|
|
110
|
+
name: "onedark",
|
|
111
|
+
BG: 0x282c34, BG2: 0x21252b, PANEL: 0x282c34, STATUSBG: 0x21252b, CARD: 0x282c34,
|
|
112
|
+
USERBG: 0x2c313a, THINKBG: 0x262b33, TOOLBG: 0x29303c, TOOLOK: 0x2e3b30, TOOLERR: 0x3b2e30,
|
|
113
|
+
BORDER: 0x3b4048, BORDER2: 0x4b5263,
|
|
114
|
+
TXT: 0xabb2bf, DIM: 0x7f848e, FAINT: 0x5c6370, BOLD: 0xffffff,
|
|
115
|
+
ACCENT: 0x61afef, ACCENT2: 0x56b6c2, HEADING: 0xc678dd,
|
|
116
|
+
LINK: 0x61afef, CODE: 0x56b6c2, CODEBG: 0x21252b,
|
|
117
|
+
OK: 0x98c379, WARN: 0xe5c07b, ERR: 0xe06c75,
|
|
118
|
+
PURPLE: 0xc678dd, RED: 0xe06c75, GREEN: 0x98c379, PINK: 0xe06c75, GREENG: 0x98c379,
|
|
119
|
+
KEYWORD: 0xc678dd, STRING: 0x98c379, NUMBER: 0xd19a66,
|
|
120
|
+
TABLEHEAD: 0xabb2bf, TABLESEP: 0x4b5263, QUOTE: 0x7f848e, QUOTEFG: 0xabb2bf,
|
|
121
|
+
SELBG: 0x3e4451, SELFG: 0xffffff, CURSORBG: 0x3e4451, CURSORFG: 0xffffff,
|
|
122
|
+
MENUBG: 0x21252b, MENUSEL: 0x3e4451, SCROLLTHUMB: 0x61afef, SCROLLTRACK: 0x3b4048,
|
|
123
|
+
},
|
|
124
|
+
"catppuccin-mocha": {
|
|
125
|
+
name: "catppuccin-mocha",
|
|
126
|
+
BG: 0x1e1e2e, BG2: 0x181825, PANEL: 0x1e1e2e, STATUSBG: 0x181825, CARD: 0x1e1e2e,
|
|
127
|
+
USERBG: 0x26263a, THINKBG: 0x1d1d2c, TOOLBG: 0x252541, TOOLOK: 0x2a3b33, TOOLERR: 0x3b2a33,
|
|
128
|
+
BORDER: 0x45475a, BORDER2: 0x585b70,
|
|
129
|
+
TXT: 0xcdd6f4, DIM: 0xa6adc8, FAINT: 0x7f849c, BOLD: 0xf5e0dc,
|
|
130
|
+
ACCENT: 0x89b4fa, ACCENT2: 0x89dceb, HEADING: 0xcba6f7,
|
|
131
|
+
LINK: 0x89b4fa, CODE: 0x94e2d5, CODEBG: 0x181825,
|
|
132
|
+
OK: 0xa6e3a1, WARN: 0xf9e2af, ERR: 0xf38ba8,
|
|
133
|
+
PURPLE: 0xcba6f7, RED: 0xf38ba8, GREEN: 0xa6e3a1, PINK: 0xf5c2e7, GREENG: 0xa6e3a1,
|
|
134
|
+
KEYWORD: 0xcba6f7, STRING: 0xa6e3a1, NUMBER: 0xfab387,
|
|
135
|
+
TABLEHEAD: 0xcdd6f4, TABLESEP: 0x585b70, QUOTE: 0x7f849c, QUOTEFG: 0xcdd6f4,
|
|
136
|
+
SELBG: 0x45475a, SELFG: 0xffffff, CURSORBG: 0x45475a, CURSORFG: 0xffffff,
|
|
137
|
+
MENUBG: 0x181825, MENUSEL: 0x45475a, SCROLLTHUMB: 0x89b4fa, SCROLLTRACK: 0x45475a,
|
|
138
|
+
},
|
|
139
|
+
tokyonight: {
|
|
140
|
+
name: "tokyonight",
|
|
141
|
+
BG: 0x1a1b26, BG2: 0x16161e, PANEL: 0x1a1b26, STATUSBG: 0x16161e, CARD: 0x1a1b26,
|
|
142
|
+
USERBG: 0x1f2130, THINKBG: 0x191a24, TOOLBG: 0x202231, TOOLOK: 0x22312c, TOOLERR: 0x312224,
|
|
143
|
+
BORDER: 0x303649, BORDER2: 0x3b4261,
|
|
144
|
+
TXT: 0xc0caf5, DIM: 0x787c99, FAINT: 0x565f89, BOLD: 0xffffff,
|
|
145
|
+
ACCENT: 0x7aa2f7, ACCENT2: 0x7dcfff, HEADING: 0xbb9af7,
|
|
146
|
+
LINK: 0x7aa2f7, CODE: 0x73daca, CODEBG: 0x16161e,
|
|
147
|
+
OK: 0x9ece6a, WARN: 0xe0af68, ERR: 0xf7768e,
|
|
148
|
+
PURPLE: 0xbb9af7, RED: 0xf7768e, GREEN: 0x9ece6a, PINK: 0xf7768e, GREENG: 0x9ece6a,
|
|
149
|
+
KEYWORD: 0xbb9af7, STRING: 0x9ece6a, NUMBER: 0xff9e64,
|
|
150
|
+
TABLEHEAD: 0xc0caf5, TABLESEP: 0x3b4261, QUOTE: 0x787c99, QUOTEFG: 0xc0caf5,
|
|
151
|
+
SELBG: 0x2a3045, SELFG: 0xffffff, CURSORBG: 0x2a3045, CURSORFG: 0xffffff,
|
|
152
|
+
MENUBG: 0x16161e, MENUSEL: 0x2a3045, SCROLLTHUMB: 0x7aa2f7, SCROLLTRACK: 0x303649,
|
|
153
|
+
},
|
|
154
|
+
monokai: {
|
|
155
|
+
name: "monokai",
|
|
156
|
+
BG: 0x272822, BG2: 0x1f201b, PANEL: 0x1f201b, STATUSBG: 0x1f201b, CARD: 0x1f201b,
|
|
157
|
+
USERBG: 0x2d2e24, THINKBG: 0x24251e, TOOLBG: 0x2b2c23, TOOLOK: 0x2c3123, TOOLERR: 0x322322,
|
|
158
|
+
BORDER: 0x3e3d32, BORDER2: 0x55544a,
|
|
159
|
+
TXT: 0xf8f8f2, DIM: 0xcfcfc2, FAINT: 0x8b8b7e, BOLD: 0xffffff,
|
|
160
|
+
ACCENT: 0x66d9ef, ACCENT2: 0xa6e22e, HEADING: 0xa6e22e,
|
|
161
|
+
LINK: 0x66d9ef, CODE: 0xa6e22e, CODEBG: 0x1f201b,
|
|
162
|
+
OK: 0xa6e22e, WARN: 0xfd971f, ERR: 0xf92672,
|
|
163
|
+
PURPLE: 0xae81ff, RED: 0xf92672, GREEN: 0xa6e22e, PINK: 0xf92672, GREENG: 0xa6e22e,
|
|
164
|
+
KEYWORD: 0xf92672, STRING: 0xe6db74, NUMBER: 0xae81ff,
|
|
165
|
+
TABLEHEAD: 0xf8f8f2, TABLESEP: 0x55544a, QUOTE: 0x8b8b7e, QUOTEFG: 0xcfcfc2,
|
|
166
|
+
SELBG: 0x49483e, SELFG: 0xffffff, CURSORBG: 0x49483e, CURSORFG: 0xffffff,
|
|
167
|
+
MENUBG: 0x1f201b, MENUSEL: 0x49483e, SCROLLTHUMB: 0x66d9ef, SCROLLTRACK: 0x3e3d32,
|
|
168
|
+
},
|
|
49
169
|
};
|
|
50
170
|
|
|
51
171
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
@@ -57,16 +177,20 @@ function themeFile() {
|
|
|
57
177
|
}
|
|
58
178
|
|
|
59
179
|
let current = "gruvbox";
|
|
60
|
-
const ORDER = ["dark", "light", "gruvbox"];
|
|
180
|
+
const ORDER = ["dark", "light", "gruvbox", "nord", "solarized-dark", "solarized-light", "dracula", "onedark", "catppuccin-mocha", "tokyonight", "monokai"];
|
|
181
|
+
|
|
182
|
+
// Live preview: the T proxy renders `preview` (when set) without persisting.
|
|
183
|
+
// The theme picker previews on cursor movement and commits on Enter.
|
|
184
|
+
let preview = null;
|
|
61
185
|
|
|
62
186
|
try {
|
|
63
187
|
const saved = readFileSync(themeFile(), "utf8").trim();
|
|
64
188
|
if (THEMES[saved]) current = saved;
|
|
65
189
|
} catch { /* first run */ }
|
|
66
190
|
|
|
67
|
-
/** Live theme accessor: T.ACCENT etc. reads the active palette. */
|
|
191
|
+
/** Live theme accessor: T.ACCENT etc. reads the active (or previewed) palette. */
|
|
68
192
|
export const T = new Proxy({}, {
|
|
69
|
-
get(_t, key) { return THEMES[current][key]; },
|
|
193
|
+
get(_t, key) { return THEMES[preview ?? current][key]; },
|
|
70
194
|
});
|
|
71
195
|
|
|
72
196
|
function persist() {
|
|
@@ -76,6 +200,21 @@ function persist() {
|
|
|
76
200
|
} catch {}
|
|
77
201
|
}
|
|
78
202
|
|
|
203
|
+
/** Temporarily render with `name` (never persisted). Returns false when unknown. */
|
|
204
|
+
export function setThemePreview(name) {
|
|
205
|
+
if (!THEMES[name]) return false;
|
|
206
|
+
preview = name;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Drop the preview and render the committed theme again. */
|
|
211
|
+
export function clearThemePreview() {
|
|
212
|
+
preview = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Theme the T proxy currently renders (preview wins over the committed one). */
|
|
216
|
+
export function renderedThemeName() { return preview ?? current; }
|
|
217
|
+
|
|
79
218
|
export function setTheme(name) {
|
|
80
219
|
if (THEMES[name]) { current = name; persist(); return true; }
|
|
81
220
|
return false;
|
package/src/views.js
CHANGED
|
@@ -13,12 +13,13 @@ import { bindingMatchFor, matchKeyBinding, CHAT_BINDING_ORDER, SIDEBAR_BINDING_O
|
|
|
13
13
|
export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
|
|
14
14
|
import {
|
|
15
15
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
16
|
-
modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
|
|
16
|
+
modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker, AttachmentPanel,
|
|
17
17
|
ImagePopup, kittyCapable, buildGoalPopup, GoalPanel, SettingsPanel, SubagentPanel,
|
|
18
|
-
SkillsPanel, ControlPanel, JobsPanel, QueuePanel, ModelPanel, fmtMs,
|
|
18
|
+
SkillsPanel, ControlPanel, JobsPanel, QueuePanel, ModelPanel, fmtMs, ThemePickerBuffer,
|
|
19
19
|
} from "./panels.js";
|
|
20
20
|
|
|
21
21
|
import { T, themeName, cycleTheme } from "./theme.js";
|
|
22
|
+
import { LOGO_COLS, LOGO_ROWS, LOGO_PALETTE, LOGO_GRID } from "./logo.js";
|
|
22
23
|
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
23
24
|
const K = new Proxy({}, { get(_k, key) { return T[key]; } });
|
|
24
25
|
|
|
@@ -193,6 +194,144 @@ function compareSemver(left, right) {
|
|
|
193
194
|
return 0;
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
// ---- Boot splash (retro game-launcher animation) ----
|
|
198
|
+
|
|
199
|
+
const SPLASH_WHITE = 0xffffff;
|
|
200
|
+
const SPLASH_BLACK = 0x000000;
|
|
201
|
+
const SPLASH_BLUE = 0x2b5bd7; // DEEPSEEK wordmark color on white
|
|
202
|
+
const SPLASH_INK = 0x14181d; // caution-lines ink
|
|
203
|
+
const SPLASH_HINT = 0x2f3540; // bottom hint ink
|
|
204
|
+
/** Classic four-line healthy-gaming warning, one sentence per line. */
|
|
205
|
+
const SPLASH_LINES = [
|
|
206
|
+
"抵制不良游戏,拒绝盗版游戏。",
|
|
207
|
+
"注意自我保护,谨防受骗上当。",
|
|
208
|
+
"适度游戏益脑,沉迷游戏伤身。",
|
|
209
|
+
"合理安排时间,享受健康生活。",
|
|
210
|
+
];
|
|
211
|
+
// 5x6 pixel glyphs rendered with half-blocks (3 terminal rows per letter);
|
|
212
|
+
// "█" full, "▀" upper half, "▄" lower half — pure-TUI, no image protocol.
|
|
213
|
+
const SPLASH_GLYPHS = {
|
|
214
|
+
D: ["11110", "10001", "10001", "10001", "10001", "11110"],
|
|
215
|
+
E: ["11111", "10000", "11110", "10000", "10000", "11111"],
|
|
216
|
+
P: ["11110", "10001", "10001", "11110", "10000", "10000"],
|
|
217
|
+
S: ["01111", "10000", "01110", "00001", "00001", "11110"],
|
|
218
|
+
K: ["10001", "10010", "10100", "11000", "10100", "10001"],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
function lerpColor(a, b, t) {
|
|
222
|
+
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
|
|
223
|
+
const br = (b >> 16) & 255, bg2 = (b >> 8) & 255, bb = b & 255;
|
|
224
|
+
return ((Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg2 - ag) * t) << 8) | Math.round(ab + (bb - ab) * t)) >>> 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- Welcome brand wordmarks (TUI-drawn half-block letters) ----
|
|
228
|
+
|
|
229
|
+
/** 5x7 pixel font for the welcome brand lines (7 pixel rows = 4 terminal
|
|
230
|
+
* rows with ▀/▄ half-blocks, the last row single-half). */
|
|
231
|
+
const BRAND_GLYPHS = {
|
|
232
|
+
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
|
233
|
+
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
|
234
|
+
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
|
235
|
+
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
|
|
236
|
+
I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"], // 工-style serif I
|
|
237
|
+
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
|
|
238
|
+
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
|
|
239
|
+
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
|
240
|
+
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
|
|
241
|
+
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
|
|
242
|
+
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
|
|
243
|
+
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
|
|
244
|
+
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Draw a half-block wordmark (5-wide glyphs, 6-col advance). `bandX`
|
|
248
|
+
* (nullable) places a diagonal shimmer sweep: pixels near the band mix
|
|
249
|
+
* toward white, tilted ~0.7 cols/row. */
|
|
250
|
+
function drawHalfBlockWordmark(screen, x0, y0, word, color, bandX, bg) {
|
|
251
|
+
const mix = (col, px, py) => {
|
|
252
|
+
if (bandX == null) return col;
|
|
253
|
+
const d = Math.abs((px + py * 0.6) - bandX);
|
|
254
|
+
if (d > 2.5) return col;
|
|
255
|
+
return lerpColor(col, 0xffffff, ((2.5 - d) / 2.5) * 0.7);
|
|
256
|
+
};
|
|
257
|
+
let cx = x0;
|
|
258
|
+
for (const ch of word) {
|
|
259
|
+
if (ch === " ") { cx += 5; continue; }
|
|
260
|
+
const rows = BRAND_GLYPHS[ch] ?? ["00000", "00000", "00000", "00000", "00000", "00000", "00000"];
|
|
261
|
+
for (let pr = 0; pr < 7; pr += 2) {
|
|
262
|
+
const topBits = rows[pr], botBits = rows[pr + 1] ?? "00000";
|
|
263
|
+
const y = y0 + pr / 2;
|
|
264
|
+
for (let px = 0; px < 5; px++) {
|
|
265
|
+
const t = topBits[px] === "1", b = botBits[px] === "1";
|
|
266
|
+
if (!t && !b) continue;
|
|
267
|
+
const fc = mix(color, cx + px, y);
|
|
268
|
+
if (t && b) screen.put(cx + px, y, "▀", { fg: fc, bg: fc });
|
|
269
|
+
else if (t) screen.put(cx + px, y, "▀", { fg: fc, bg });
|
|
270
|
+
else screen.put(cx + px, y, "▄", { fg: fc, bg });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
cx += 6;
|
|
274
|
+
}
|
|
275
|
+
return cx - x0 - 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One frame of the opt-in boot animation. Phases are driven by opaque
|
|
279
|
+
* numbers so the renderer stays pure and testable:
|
|
280
|
+
* diffuse 0..1 white circle expanding from the screen center over black;
|
|
281
|
+
* lineAlpha[4] per-line opacity of the healthy-gaming warning;
|
|
282
|
+
* wordAlpha 0..1 DEEPSEEK wordmark crossfading in;
|
|
283
|
+
* blink show/hide the 按任意键进入游戏 prompt (toggling = blinking). */
|
|
284
|
+
export function drawBootSplash(screen, { diffuse = 0, lineAlpha = [0, 0, 0, 0], wordAlpha = 0, blink = false } = {}) {
|
|
285
|
+
const w = screen.w, h = screen.h;
|
|
286
|
+
const p = Math.max(0, Math.min(1, diffuse));
|
|
287
|
+
const cx = (w - 1) / 2, cy = (h - 1) / 2;
|
|
288
|
+
const maxR = Math.hypot(cx, cy * 2);
|
|
289
|
+
for (let y = 0; y < h; y++) {
|
|
290
|
+
for (let x = 0; x < w; x++) {
|
|
291
|
+
const r = Math.hypot(x - cx, (y - cy) * 2);
|
|
292
|
+
screen.put(x, y, " ", { bg: p >= 1 || r <= maxR * p ? SPLASH_WHITE : SPLASH_BLACK });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const inkFor = (a) => lerpColor(SPLASH_WHITE, SPLASH_INK, Math.max(0, Math.min(1, a)));
|
|
296
|
+
for (let i = 0; i < SPLASH_LINES.length; i++) {
|
|
297
|
+
const a = Math.max(0, Math.min(1, lineAlpha[i] ?? 0));
|
|
298
|
+
if (a <= 0) continue;
|
|
299
|
+
const spaced = [...SPLASH_LINES[i]].join(" ");
|
|
300
|
+
const x = Math.max(0, Math.floor((w - strWidth(spaced)) / 2));
|
|
301
|
+
const y = Math.max(1, Math.floor(h / 2) - 4 + i);
|
|
302
|
+
screen.text(x, y, spaced, { fg: inkFor(a), attrs: 1 });
|
|
303
|
+
}
|
|
304
|
+
if (wordAlpha > 0 && w >= 20) {
|
|
305
|
+
const word = "DEEPSEEK";
|
|
306
|
+
const ink = lerpColor(SPLASH_WHITE, SPLASH_BLUE, Math.max(0, Math.min(1, wordAlpha)));
|
|
307
|
+
if (w >= 52) {
|
|
308
|
+
const glyphW = 6, totalW = word.length * glyphW - 1;
|
|
309
|
+
const x0 = Math.max(0, Math.floor((w - totalW) / 2));
|
|
310
|
+
const y0 = Math.max(0, Math.floor(h / 2));
|
|
311
|
+
for (let li = 0; li < word.length; li++) {
|
|
312
|
+
const rows = SPLASH_GLYPHS[word[li]] ?? ["00000", "00000", "00000", "00000", "00000", "00000"];
|
|
313
|
+
for (let pr = 0; pr < 6; pr += 2) {
|
|
314
|
+
const top = rows[pr], bottom = rows[pr + 1];
|
|
315
|
+
const y = y0 + pr / 2;
|
|
316
|
+
for (let px2 = 0; px2 < 5; px2++) {
|
|
317
|
+
const t = top[px2] === "1", b = bottom[px2] === "1";
|
|
318
|
+
const x = x0 + li * glyphW + px2;
|
|
319
|
+
if (t && b) screen.text(x, y, "█", { fg: ink });
|
|
320
|
+
else if (t) screen.text(x, y, "▀", { fg: ink, bg: SPLASH_WHITE });
|
|
321
|
+
else if (b) screen.text(x, y, "▄", { fg: ink, bg: SPLASH_WHITE });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
screen.text(Math.max(0, Math.floor((w - word.length) / 2)), Math.max(1, Math.floor(h / 2)), word, { fg: ink, attrs: 1 });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (blink && (p >= 1 || wordAlpha > 0)) {
|
|
330
|
+
const hint = "按任意键进入游戏";
|
|
331
|
+
screen.text(Math.max(0, Math.floor((w - strWidth(hint)) / 2)), Math.max(1, h - 2), hint, { fg: lerpColor(SPLASH_WHITE, SPLASH_HINT, 1), attrs: 1 });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
196
335
|
// ---- Tool card renderers (host-computed view models) ----
|
|
197
336
|
|
|
198
337
|
function renderToolCard(view, width, expanded) {
|
|
@@ -1200,9 +1339,6 @@ export class ChatView extends Widget {
|
|
|
1200
1339
|
this.rebuildQueued = false;
|
|
1201
1340
|
this.cache = new Map(); // node render cache: key → { lines, marks }
|
|
1202
1341
|
this.cardRanges = []; // absolute line ranges of card-backed message blocks
|
|
1203
|
-
this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
|
|
1204
|
-
this.welcomeModeIds = ["standard", "code", "minimal", "cordis"];
|
|
1205
|
-
this.welcomeModeSel = 0;
|
|
1206
1342
|
this.pressY = null;
|
|
1207
1343
|
this.pressInfo = null; // hit identity locked at press time
|
|
1208
1344
|
this.pressCtx = null;
|
|
@@ -1416,7 +1552,6 @@ export class ChatView extends Widget {
|
|
|
1416
1552
|
async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
|
|
1417
1553
|
this.sessionId = sessionId;
|
|
1418
1554
|
this.nodes = [];
|
|
1419
|
-
this.welcomeModeSel = 0;
|
|
1420
1555
|
this.blockSel = -1;
|
|
1421
1556
|
this.cursorMode = "block";
|
|
1422
1557
|
this.visualAnchor = null;
|
|
@@ -1612,7 +1747,7 @@ export class ChatView extends Widget {
|
|
|
1612
1747
|
if (trimmed === "/reload") { this.app.softReload(); return; }
|
|
1613
1748
|
if (trimmed === "/restart") { this.app.restartApp(); return; }
|
|
1614
1749
|
if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
|
|
1615
|
-
if (trimmed === "/theme") {
|
|
1750
|
+
if (trimmed === "/theme") { this.app.showThemePicker(); return; }
|
|
1616
1751
|
if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
|
|
1617
1752
|
if (trimmed === "/goal") { this.app.showGoal(); return; }
|
|
1618
1753
|
if (!trimmed && this.clipboardImages.length === 0) return;
|
|
@@ -2612,58 +2747,139 @@ export class ChatView extends Widget {
|
|
|
2612
2747
|
return true;
|
|
2613
2748
|
}
|
|
2614
2749
|
|
|
2615
|
-
/** Blank session:
|
|
2750
|
+
/** Blank session: logo + brand/version rows + a bottom mode hint. The 4-mode
|
|
2751
|
+
* inline list is gone: mode selection lives in the F9 picker buffer, which
|
|
2752
|
+
* also supports custom presets (web parity: self-made modes encouraged). */
|
|
2616
2753
|
#renderWelcome(screen) {
|
|
2617
2754
|
const x = this.view.x;
|
|
2618
2755
|
const cx = x + Math.max(0, Math.floor((this.view.w - 40) / 2));
|
|
2619
|
-
|
|
2620
|
-
const put = (t, fg, bold) => { if (
|
|
2621
|
-
|
|
2756
|
+
const h = this.view.h, top = this.view.y + 1;
|
|
2757
|
+
const put = (t, fg, bold, yy) => { if (yy < top + h) screen.text(cx, yy, t, { fg, attrs: bold ? 1 : 0 }); };
|
|
2758
|
+
// 40x19 half-block logo, per mode: preset (bundled), custom (JSON file)
|
|
2759
|
+
// or none (title only). Centered on the 40-col block.
|
|
2622
2760
|
this.welcomeVersionRows = [];
|
|
2623
|
-
const
|
|
2761
|
+
const logoDrawn = this.view.w >= 48 && this.#renderWelcomeLogo(screen, cx, top);
|
|
2762
|
+
// Brand lines: TUI-drawn half-block wordmarks (like the splash), each with
|
|
2763
|
+
// its clickable version/update row on the right; the diagonal shimmer
|
|
2764
|
+
// sweeps across every ~4s. Falls back to spaced text on short/narrow views.
|
|
2765
|
+
const brandY = top + (logoDrawn ? LOGO_ROWS + 1 : 0);
|
|
2766
|
+
const useWordmarks = h >= 32 && this.view.w >= 62;
|
|
2767
|
+
if (useWordmarks) {
|
|
2768
|
+
// The band must run PAST the right edge: the final I of DSH NEOTUI sits
|
|
2769
|
+
// at x0+57 (plus the ~0.6/row diagonal offset), so phase 1 lands ~x0+72.
|
|
2770
|
+
const bandX = this.app.brandShimmer >= 0
|
|
2771
|
+
? (x + Math.floor((this.view.w - 58) / 2) - 8 + this.app.brandShimmer * 80)
|
|
2772
|
+
: null;
|
|
2773
|
+
const glow = lerpColor(T.HEADING, 0xffffff, 0.18);
|
|
2774
|
+
const w1Y = brandY;
|
|
2775
|
+
drawHalfBlockWordmark(screen, x + Math.max(0, Math.floor((this.view.w - 47) / 2)), w1Y, "DEEPSEEK", glow, bandX, T.BG);
|
|
2776
|
+
this.#putVersionRight(screen, w1Y + 1, "dsh", this.app.dshVersion ?? "unknown", T.HEADING, true);
|
|
2777
|
+
const w2Y = w1Y + 4;
|
|
2778
|
+
const glide = T.FAINT; // dark enough for the white glint to pop
|
|
2779
|
+
drawHalfBlockWordmark(screen, x + Math.max(0, Math.floor((this.view.w - 58) / 2)), w2Y, "DSH NEOTUI", glide, bandX, T.BG);
|
|
2780
|
+
this.#putVersionRight(screen, w2Y + 1, "tui", TUI_VERSION, T.FAINT, false);
|
|
2781
|
+
// Update/check notices live at the bottom (only when not latest).
|
|
2782
|
+
this.#putUpdateHints(screen, top, h);
|
|
2783
|
+
} else {
|
|
2784
|
+
const versionLine = (name, version, key, fg, bold, yy) => {
|
|
2785
|
+
const spaced = [...name].join(" ");
|
|
2786
|
+
const text = `${spaced} ${version === "unknown" ? "版本未知" : `v${version}`}`;
|
|
2787
|
+
const tx = x + Math.max(0, Math.floor((this.view.w - strWidth(text)) / 2));
|
|
2788
|
+
if (yy < top + h) screen.text(tx, yy, text, { fg, attrs: bold ? 1 : 0 });
|
|
2789
|
+
this.welcomeVersionRows[yy] = { key, x1: tx, x2: tx + strWidth(text) - 1 };
|
|
2790
|
+
};
|
|
2791
|
+
versionLine("DEEPSEEK HARNESS", this.app.dshVersion ?? "unknown", "dsh", T.HEADING, true, brandY);
|
|
2792
|
+
versionLine("DSH NEOTUI", TUI_VERSION, "tui", T.FAINT, false, brandY + 1);
|
|
2793
|
+
this.#putUpdateHints(screen, top, h);
|
|
2794
|
+
}
|
|
2795
|
+
// Bottom hint: current preset + F9 opens the (custom-mode friendly) picker.
|
|
2796
|
+
if (this.app.currentSession != null) {
|
|
2797
|
+
const currentPreset = this.app.sessions.find((s) => s.sessionId === this.app.currentSession)?.agentPreset;
|
|
2798
|
+
const hint = `模式: ${modeName(currentPreset ?? "standard")} · F9 打开模式选择(支持自定义) · Ctrl+R 欢迎页 logo`;
|
|
2799
|
+
this.welcomeModeRow = top + h - 2;
|
|
2800
|
+
const hx = x + Math.max(0, Math.floor((this.view.w - strWidth(hint)) / 2));
|
|
2801
|
+
screen.text(hx, this.welcomeModeRow, hint, { fg: T.WARN, attrs: 1 });
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/** The 40x19 half-block mascot for the current logo mode. */
|
|
2806
|
+
#renderWelcomeLogo(screen, cx, top) {
|
|
2807
|
+
if (this.app.logoMode === "none") return false;
|
|
2808
|
+
const hr = this.view.y + this.view.h;
|
|
2809
|
+
let pal, grid;
|
|
2810
|
+
if (this.app.logoMode === "custom") {
|
|
2811
|
+
if (!this.app.logoData) {
|
|
2812
|
+
if (this.app.logoPath) {
|
|
2813
|
+
try { this.app.logoData = this.app.loadCustomLogo(this.app.logoPath); }
|
|
2814
|
+
catch { this.app.logoData = null; } // invalid file → title-only
|
|
2815
|
+
}
|
|
2816
|
+
if (!this.app.logoData) return false;
|
|
2817
|
+
}
|
|
2818
|
+
pal = this.app.logoData.palette; grid = this.app.logoData.grid;
|
|
2819
|
+
} else {
|
|
2820
|
+
pal = LOGO_PALETTE; grid = LOGO_GRID;
|
|
2821
|
+
}
|
|
2822
|
+
for (let r = 0; r < LOGO_ROWS && top + r < hr; r++) {
|
|
2823
|
+
for (let c = 0; c < LOGO_COLS; c++) {
|
|
2824
|
+
let topIdx, botIdx;
|
|
2825
|
+
if (this.app.logoMode === "custom") {
|
|
2826
|
+
const cell = grid[r][c];
|
|
2827
|
+
topIdx = cell[0]; botIdx = cell[1];
|
|
2828
|
+
} else {
|
|
2829
|
+
const i = (r * LOGO_COLS + c) * 4;
|
|
2830
|
+
topIdx = parseInt(grid.slice(i, i + 2), 16);
|
|
2831
|
+
botIdx = parseInt(grid.slice(i + 2, i + 4), 16);
|
|
2832
|
+
}
|
|
2833
|
+
const custom = this.app.logoMode === "custom";
|
|
2834
|
+
const emptyT = custom ? topIdx < 0 : topIdx === 0;
|
|
2835
|
+
const emptyB = custom ? botIdx < 0 : botIdx === 0;
|
|
2836
|
+
if (emptyT && emptyB) continue;
|
|
2837
|
+
const pal32 = (pi) => (custom ? pal[pi] : parseInt(pal[pi], 16));
|
|
2838
|
+
const tcol = emptyT ? null : pal32(custom ? topIdx : topIdx - 1);
|
|
2839
|
+
const bcol = emptyB ? null : pal32(custom ? botIdx : botIdx - 1);
|
|
2840
|
+
const fgc = tcol ?? bcol ?? T.BG;
|
|
2841
|
+
const bgc = bcol ?? T.BG;
|
|
2842
|
+
screen.put(cx + c, top + r, tcol != null ? "▀" : "▄", { fg: fgc, bg: bgc });
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
return true;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/** Bottom update notices: only shown when a product has a pending update
|
|
2849
|
+
* (or a failed/ongoing check) — "已是最新" stays quiet. Clickable. */
|
|
2850
|
+
#putUpdateHints(screen, top, h) {
|
|
2851
|
+
this.welcomeUpdateRows = [];
|
|
2852
|
+
const names = { dsh: "DeepSeek Harness", tui: "dsh-neotui" };
|
|
2853
|
+
const parts = [];
|
|
2854
|
+
for (const key of ["dsh", "tui"]) {
|
|
2624
2855
|
const check = this.app.versionChecks?.[key];
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
: "← 检查更新";
|
|
2630
|
-
const text = ` ${name} ${version === "unknown" ? "版本未知" : `v${version}`} ${status}`;
|
|
2631
|
-
put(text, fg, bold);
|
|
2632
|
-
this.welcomeVersionRows[y - 1] = { key, x1: cx, x2: cx + strWidth(text) - 1 };
|
|
2633
|
-
};
|
|
2634
|
-
versionLine("DeepSeek Harness", this.app.dshVersion ?? "unknown", "dsh", T.HEADING, true);
|
|
2635
|
-
versionLine("dsh-neotui", TUI_VERSION, "tui", T.FAINT, false);
|
|
2636
|
-
put("", 0, false);
|
|
2637
|
-
if (this.app.currentSession == null) {
|
|
2638
|
-
put(" 打开一个会话开始,或 Ctrl+N 新建", T.DIM, false);
|
|
2639
|
-
return;
|
|
2856
|
+
if (!check) continue;
|
|
2857
|
+
if (check.state === "update") parts.push({ key, text: `${names[key]} 可更新 ${check.latest}` });
|
|
2858
|
+
else if (check.state === "error") parts.push({ key, text: `${names[key]} 检查失败(点击重试)` });
|
|
2859
|
+
else if (check.state === "checking") parts.push({ key, text: `${names[key]} 检查中…` });
|
|
2640
2860
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
this.
|
|
2644
|
-
const
|
|
2645
|
-
const
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
const currentIdx = presets.findIndex(([id]) => id === currentPreset);
|
|
2652
|
-
if (this.welcomeModeSel == null || this.welcomeModeSel >= presets.length) this.welcomeModeSel = currentIdx >= 0 ? currentIdx : 0;
|
|
2653
|
-
for (let i = 0; i < presets.length; i++) {
|
|
2654
|
-
const [id, name, desc] = presets[i];
|
|
2655
|
-
if (y < this.view.y + this.view.h) {
|
|
2656
|
-
const active = id === currentPreset;
|
|
2657
|
-
const cursor = this.app.focused === this && i === this.welcomeModeSel;
|
|
2658
|
-
const label = `${cursor ? "=>" : " "} ${active ? "●" : "○"} ${name}${active ? " [当前]" : ""}`;
|
|
2659
|
-
screen.text(cx, y, ` ${label}`, { fg: active ? T.OK : cursor ? T.ACCENT : T.DIM, bg: cursor ? T.MENUSEL : -1, attrs: active || cursor ? 1 : 0 });
|
|
2660
|
-
screen.text(cx + 2 + strWidth(label) + 1, y, truncate(desc, Math.max(1, this.view.w - strWidth(label) - 8)), { fg: cursor ? T.TXT : T.DIM, bg: cursor ? T.MENUSEL : -1 });
|
|
2661
|
-
this.welcomeModes[y] = id;
|
|
2662
|
-
}
|
|
2663
|
-
y++;
|
|
2861
|
+
if (!parts.length) return;
|
|
2862
|
+
const row = top + h - 3;
|
|
2863
|
+
this.welcomeUpdateRow = row;
|
|
2864
|
+
const full = parts.map((p) => p.text).join(" · ");
|
|
2865
|
+
const tx = this.view.x + Math.max(0, Math.floor((this.view.w - strWidth(full)) / 2));
|
|
2866
|
+
let at = tx;
|
|
2867
|
+
for (const p of parts) {
|
|
2868
|
+
if (row < top + h) screen.text(at, row, p.text, { fg: T.WARN, attrs: 1 });
|
|
2869
|
+
this.welcomeUpdateRows.push({ key: p.key, x1: at, x2: at + strWidth(p.text) - 1 });
|
|
2870
|
+
at += strWidth(p.text) + 3;
|
|
2664
2871
|
}
|
|
2665
2872
|
}
|
|
2666
2873
|
|
|
2874
|
+
/** Right-aligned version row for the wordmark brand lines (clickable check;
|
|
2875
|
+
* update notices themselves live at the bottom of the welcome). */
|
|
2876
|
+
#putVersionRight(screen, y, key, version, fg, bold) {
|
|
2877
|
+
const text = version === "unknown" ? "版本未知" : `v${version}`;
|
|
2878
|
+
const tx = this.view.x + Math.max(0, this.view.w - strWidth(text) - 2);
|
|
2879
|
+
if (y < this.view.y + this.view.h) screen.text(tx, y, text, { fg, attrs: bold ? 1 : 0 });
|
|
2880
|
+
this.welcomeVersionRows[y] = { key, x1: tx, x2: tx + strWidth(text) - 1 };
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2667
2883
|
render(screen) {
|
|
2668
2884
|
// Blank session: show the welcome + mode prompt instead of empty chat.
|
|
2669
2885
|
const isBlank = this.app.sessions.find((s) => s.sessionId === this.sessionId)?.blank ?? false;
|
|
@@ -2837,12 +3053,14 @@ export class ChatView extends Widget {
|
|
|
2837
3053
|
if (this.view.inside(ev.x, ev.y)) {
|
|
2838
3054
|
// clicks act, but never exit INSERT mode — Esc is the only way out
|
|
2839
3055
|
if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
|
|
2840
|
-
// Welcome-screen
|
|
3056
|
+
// Welcome-screen clicks: version rows check updates; the mode hint opens
|
|
3057
|
+
// the custom-mode-friendly picker buffer.
|
|
2841
3058
|
if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
|
|
2842
3059
|
const versionHit = this.welcomeVersionRows?.[ev.y];
|
|
2843
3060
|
if (versionHit && ev.x >= versionHit.x1 && ev.x <= versionHit.x2) { this.app.checkUpdates(versionHit.key, true); return true; }
|
|
2844
|
-
const
|
|
2845
|
-
if (
|
|
3061
|
+
const updateHit = this.welcomeUpdateRows?.find((r) => ev.y === this.welcomeUpdateRow && ev.x >= r.x1 && ev.x <= r.x2);
|
|
3062
|
+
if (updateHit) { this.app.checkUpdates(updateHit.key, true); return true; }
|
|
3063
|
+
if (ev.y === this.welcomeModeRow && ev.x >= cx && ev.x <= cx + 40) { this.app.showModePicker(); return true; }
|
|
2846
3064
|
}
|
|
2847
3065
|
if (ev.kind === "wheel-up" && this.view.scrollY <= 3 && this.hasMore) { void this.loadOlder(); return true; }
|
|
2848
3066
|
if (ev.kind === "press" && ev.button === 0) {
|
|
@@ -2953,15 +3171,6 @@ export class ChatView extends Widget {
|
|
|
2953
3171
|
}
|
|
2954
3172
|
|
|
2955
3173
|
onKey(ev) {
|
|
2956
|
-
const blankWelcome = this.nodes.length === 0 && (this.app.sessions.find((s) => s.sessionId === this.sessionId)?.blank ?? false);
|
|
2957
|
-
if (blankWelcome && ev.type === "key" && (ev.name === "up" || ev.name === "down")) {
|
|
2958
|
-
this.welcomeModeSel = wrapIndex(this.welcomeModeSel + (ev.name === "up" ? -1 : 1), this.welcomeModeIds.length);
|
|
2959
|
-
return true;
|
|
2960
|
-
}
|
|
2961
|
-
if (blankWelcome && ev.type === "key" && ev.name === "enter") {
|
|
2962
|
-
this.app.selectPreset(this.welcomeModeIds[this.welcomeModeSel]);
|
|
2963
|
-
return true;
|
|
2964
|
-
}
|
|
2965
3174
|
if (ev.type === "text" || ev.type === "paste") {
|
|
2966
3175
|
// In cursor/Visual modes text is never inserted into the transcript or
|
|
2967
3176
|
// silently redirected to INSERT. Only explicit i enters the input editor.
|
|
@@ -3335,12 +3544,23 @@ export class ApprovalPopup extends Popup {
|
|
|
3335
3544
|
// ---- App ----
|
|
3336
3545
|
|
|
3337
3546
|
export class App {
|
|
3338
|
-
constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion }) {
|
|
3547
|
+
constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion, launcherAnime = false }) {
|
|
3339
3548
|
this.screen = screen;
|
|
3340
3549
|
this.term = term;
|
|
3341
3550
|
this.api = api;
|
|
3342
3551
|
this.log = log ?? (() => {});
|
|
3343
3552
|
this.versionFetcher = versionFetcher;
|
|
3553
|
+
this.launcherAnime = launcherAnime; // opt-in boot animation (--launcher-anime)
|
|
3554
|
+
// Welcome logo mode: "preset" (bundled mascot), "custom" (JSON file) or
|
|
3555
|
+
// "none" (title only). Persisted in the tui config file.
|
|
3556
|
+
const logoCfg = loadTuiConfig().logo ?? {};
|
|
3557
|
+
this.logoMode = ["preset", "custom", "none"].includes(logoCfg.mode) ? logoCfg.mode : "preset";
|
|
3558
|
+
this.logoPath = logoCfg.path ?? null;
|
|
3559
|
+
this.logoData = null; // decoded custom logo: { palette, grid }
|
|
3560
|
+
this.logoLoadError = null;
|
|
3561
|
+
this.brandShimmer = -1; // welcome wordmark diagonal sweep phase (-1 = idle)
|
|
3562
|
+
this.brandSweep0 = -1;
|
|
3563
|
+
this.brandShimmerNext = null;
|
|
3344
3564
|
this.popup = null;
|
|
3345
3565
|
this.activePrompt = null;
|
|
3346
3566
|
this.promptQueue = [];
|
|
@@ -4236,7 +4456,91 @@ export class App {
|
|
|
4236
4456
|
showQueue() { this.overlay = new QueuePanel(this); this.redraw(); }
|
|
4237
4457
|
showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
|
|
4238
4458
|
showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
|
|
4239
|
-
|
|
4459
|
+
showThemePicker() { this.overlay = new ThemePickerBuffer(this); this.redraw(); }
|
|
4460
|
+
|
|
4461
|
+
/** Welcome-logo picker (Ctrl+R / 控制面板): preset, custom file, or none. */
|
|
4462
|
+
showLogoPicker() {
|
|
4463
|
+
const mark = (m) => (this.logoMode === m ? "● " : "○ ");
|
|
4464
|
+
const items = [
|
|
4465
|
+
{ label: `${mark("preset")}预设`, hint: "DeepSeek 吉祥物(内置)", action: "preset" },
|
|
4466
|
+
{ label: `${mark("custom")}自定义…`, hint: `选择 JSON 文件(当前: ${this.logoPath ?? "—"})`, action: "custom" },
|
|
4467
|
+
{ label: `${mark("none")}关闭`, hint: "不显示 logo,仅保留品牌字标", action: "none" },
|
|
4468
|
+
];
|
|
4469
|
+
const pick = async (action) => {
|
|
4470
|
+
if (action === "preset" || action === "none") {
|
|
4471
|
+
this.logoMode = action;
|
|
4472
|
+
this.logoData = null;
|
|
4473
|
+
this.logoLoadError = null;
|
|
4474
|
+
saveTuiConfig({ logo: { mode: action, path: action === "preset" ? null : this.logoPath } });
|
|
4475
|
+
this.closeOverlay();
|
|
4476
|
+
this.redraw();
|
|
4477
|
+
} else if (action === "custom") {
|
|
4478
|
+
// Reuse the shared Ctrl+O / 新建工作区 picker (UploadPicker) in its
|
|
4479
|
+
// single-select mode — same UI, same navigation, one file only.
|
|
4480
|
+
const dir = this.logoPath ? dirname(this.logoPath) : (this.sessions.find((s) => s.sessionId === this.currentSession)?.cwd ?? process.cwd());
|
|
4481
|
+
const picker = new UploadPicker(this, {
|
|
4482
|
+
startPath: dir,
|
|
4483
|
+
single: true,
|
|
4484
|
+
onPickFile: (path) => this.#applyCustomLogo(path),
|
|
4485
|
+
onCancel: () => { this.overlay = null; this.focus(this.chat); this.redraw(); },
|
|
4486
|
+
});
|
|
4487
|
+
this.overlay = picker; this.focus(picker); this.redraw();
|
|
4488
|
+
}
|
|
4489
|
+
};
|
|
4490
|
+
const w = Math.max(1, Math.min(66, this.screen.w - 4)), ph = Math.max(1, Math.min(12, this.screen.h - 4));
|
|
4491
|
+
this.overlay = new Picker({
|
|
4492
|
+
x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
|
|
4493
|
+
w, h: ph, title: "欢迎页 logo(Ctrl+R)",
|
|
4494
|
+
items,
|
|
4495
|
+
onPick: (it) => void pick(it.action),
|
|
4496
|
+
onCancel: () => this.closeOverlay(),
|
|
4497
|
+
});
|
|
4498
|
+
this.redraw();
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
/** Load + validate a custom logo JSON ({ palette, grid }, 40x19) and switch
|
|
4502
|
+
* to it. Accepted schema mirrors the generator's logo-data.json; palette
|
|
4503
|
+
* entries may be [r,g,b] arrays or "#rrggbb"/"rrggbb" strings. Returns the
|
|
4504
|
+
* normalized data or throws with a user-facing message. */
|
|
4505
|
+
loadCustomLogo(path) {
|
|
4506
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
4507
|
+
const pal = data?.palette, grid = data?.grid;
|
|
4508
|
+
if (!Array.isArray(pal) || !Array.isArray(grid) || grid.length !== LOGO_ROWS
|
|
4509
|
+
|| grid.some((row) => !Array.isArray(row) || row.length !== LOGO_COLS)
|
|
4510
|
+
|| pal.length < 1 || pal.length > 65535) {
|
|
4511
|
+
throw new Error("需要 { palette:[…], grid:[[top,bottom]...] } 且为 40×19");
|
|
4512
|
+
}
|
|
4513
|
+
const norm = pal.map((c) => {
|
|
4514
|
+
if (Array.isArray(c) && c.length >= 3) return ((c[0] << 16) | (c[1] << 8) | c[2]) >>> 0;
|
|
4515
|
+
if (typeof c === "string") return parseInt(c.replace("#", ""), 16);
|
|
4516
|
+
return NaN;
|
|
4517
|
+
});
|
|
4518
|
+
if (norm.some((c) => Number.isNaN(c) || c < 0 || c > 0xffffff)) throw new Error("调色板条目必须是 [r,g,b] 或 #rrggbb");
|
|
4519
|
+
for (const row of grid) {
|
|
4520
|
+
for (const cell of row) {
|
|
4521
|
+
if (!Array.isArray(cell) || cell.length !== 2 || cell[0] < -1 || cell[1] < -1
|
|
4522
|
+
|| cell[0] >= norm.length || cell[1] >= norm.length || !Number.isInteger(cell[0]) || !Number.isInteger(cell[1])) {
|
|
4523
|
+
throw new Error("grid 必须是 [[top,bottom]...] 且索引在调色板范围内");
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
return { palette: norm, grid };
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
#applyCustomLogo(path) {
|
|
4531
|
+
try {
|
|
4532
|
+
this.logoData = this.loadCustomLogo(path);
|
|
4533
|
+
this.logoMode = "custom";
|
|
4534
|
+
this.logoPath = path;
|
|
4535
|
+
this.logoLoadError = null;
|
|
4536
|
+
saveTuiConfig({ logo: { mode: "custom", path } });
|
|
4537
|
+
this.closeOverlay();
|
|
4538
|
+
this.redraw();
|
|
4539
|
+
this.toast("自定义 logo 已应用");
|
|
4540
|
+
} catch (e) {
|
|
4541
|
+
this.toast(`logo 文件无效: ${e.message}`);
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4240
4544
|
|
|
4241
4545
|
/** /reload: in-place soft reload — fresh session list, fresh chat history,
|
|
4242
4546
|
* panels rebuilt, screen re-rendered. No process churn, no terminal
|
|
@@ -4418,7 +4722,8 @@ export class App {
|
|
|
4418
4722
|
switch (id) {
|
|
4419
4723
|
case "sessionFilter": this.startSearch(); this.redraw(); return true;
|
|
4420
4724
|
case "panel": this.overlay = new ControlPanel(this, { startPage: 0 }); this.redraw(); return true;
|
|
4421
|
-
case "
|
|
4725
|
+
case "panePrev": this.focusPane(-1); return true;
|
|
4726
|
+
case "paneNext": this.focusPane(1); return true;
|
|
4422
4727
|
case "permissionRotate": this.rotatePermission(); return true;
|
|
4423
4728
|
case "editConfig": this.editConfigFile(); return true;
|
|
4424
4729
|
case "quit": this.stop(); return true;
|
|
@@ -4443,6 +4748,8 @@ export class App {
|
|
|
4443
4748
|
case "addWorkspace": this.addWorkspace(); return true;
|
|
4444
4749
|
case "commandPalette": this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return true;
|
|
4445
4750
|
case "modePicker": this.showModePicker(); return true;
|
|
4751
|
+
case "themePicker": this.showThemePicker(); return true;
|
|
4752
|
+
case "logoPicker": this.showLogoPicker(); return true;
|
|
4446
4753
|
default: return false;
|
|
4447
4754
|
}
|
|
4448
4755
|
}
|
|
@@ -4548,6 +4855,16 @@ export class App {
|
|
|
4548
4855
|
|
|
4549
4856
|
onEvent(ev) {
|
|
4550
4857
|
if (ev.type === "resize") { this.resize(ev.w, ev.h); return; }
|
|
4858
|
+
// Retro launcher splash: every key/mouse press enters the game. Events
|
|
4859
|
+
// are swallowed while the blinking 按任意键提示 is on screen.
|
|
4860
|
+
if (this.splashWaiting) {
|
|
4861
|
+
if (ev.type === "key" || ev.type === "text" || ev.type === "paste" || ev.type === "mouse") {
|
|
4862
|
+
this.splashWaiting = false;
|
|
4863
|
+
this.screen.prev = null; // force a full repaint of the real UI
|
|
4864
|
+
this.redraw();
|
|
4865
|
+
}
|
|
4866
|
+
return;
|
|
4867
|
+
}
|
|
4551
4868
|
if (this.swallowRelease && ev.type === "mouse" && ev.kind === "release") {
|
|
4552
4869
|
this.swallowRelease = false;
|
|
4553
4870
|
return; // a press just closed an overlay; eat its matching release
|
|
@@ -5426,9 +5743,63 @@ export class App {
|
|
|
5426
5743
|
|
|
5427
5744
|
// ---- main loop ----
|
|
5428
5745
|
|
|
5746
|
+
/** DeepSeek-style boot splash: white background, DEEPSEEK wordmark fading
|
|
5747
|
+
* in, pulsing 启动中 dot, then dissolve into the theme background.
|
|
5748
|
+
* Opt-in meme: enable with --launcher-anime / DSH_TUI_LAUNCHER_ANIME=1;
|
|
5749
|
+
* DSH_TUI_NO_SPLASH=1 always wins. */
|
|
5750
|
+
playSplash() {
|
|
5751
|
+
if (!this.launcherAnime) return;
|
|
5752
|
+
if (process.env.DSH_TUI_NO_SPLASH === "1") return;
|
|
5753
|
+
if (!this.term?.output?.write) return;
|
|
5754
|
+
const w = this.screen.w, h = this.screen.h;
|
|
5755
|
+
if (w < 30 || h < 12) return;
|
|
5756
|
+
const wait = (ms) => { if (this.splashDelay ?? 30) { const sab = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sab, 0, 0, ms); } };
|
|
5757
|
+
const frames = (steps, dur, draw) => {
|
|
5758
|
+
for (let i = 0; i < steps; i++) {
|
|
5759
|
+
draw(i / Math.max(1, steps - 1), i);
|
|
5760
|
+
this.term.output.write(this.screen.render() + "\x1b[?25l");
|
|
5761
|
+
wait(dur);
|
|
5762
|
+
}
|
|
5763
|
+
};
|
|
5764
|
+
const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
|
|
5765
|
+
const clamp01 = (t) => Math.max(0, Math.min(1, t));
|
|
5766
|
+
// 1s: white circle diffuses from the screen center over pure black.
|
|
5767
|
+
frames(25, this.splashDelay ?? 40, (t) => drawBootSplash(this.screen, { diffuse: ease(t) }));
|
|
5768
|
+
// 2s: the four healthy-gaming lines fade in one sentence at a time.
|
|
5769
|
+
frames(40, this.splashDelay ?? 50, (t) => {
|
|
5770
|
+
const lineAlpha = [];
|
|
5771
|
+
for (let i = 0; i < SPLASH_LINES.length; i++) lineAlpha.push(ease(clamp01((t - i * 0.25) / 0.25)));
|
|
5772
|
+
drawBootSplash(this.screen, { diffuse: 1, lineAlpha });
|
|
5773
|
+
});
|
|
5774
|
+
// 2s: lines dissolve out while DEEPSEEK fades in (crossfade).
|
|
5775
|
+
frames(40, this.splashDelay ?? 50, (t) => drawBootSplash(this.screen, {
|
|
5776
|
+
diffuse: 1, lineAlpha: SPLASH_LINES.map(() => 1 - t), wordAlpha: ease(t),
|
|
5777
|
+
}));
|
|
5778
|
+
// Wait for any key: blinking prompt at the bottom; the tick loop toggles it.
|
|
5779
|
+
this.splashWaiting = true;
|
|
5780
|
+
this.splashBlink = true;
|
|
5781
|
+
this.splashBlinkAt = Date.now();
|
|
5782
|
+
drawBootSplash(this.screen, { diffuse: 1, wordAlpha: 1, blink: this.splashBlink });
|
|
5783
|
+
this.term.output.write(this.screen.render() + "\x1b[?25l");
|
|
5784
|
+
}
|
|
5785
|
+
|
|
5429
5786
|
run() {
|
|
5787
|
+
this.playSplash();
|
|
5430
5788
|
const tick = () => {
|
|
5431
5789
|
try {
|
|
5790
|
+
this.tickWelcomeShimmer(Date.now());
|
|
5791
|
+
// While the splash waits for a key, only the blinking prompt is
|
|
5792
|
+
// repainted — the real UI must not render underneath it.
|
|
5793
|
+
if (this.splashWaiting) {
|
|
5794
|
+
const now = Date.now();
|
|
5795
|
+
if (now - (this.splashBlinkAt ?? 0) >= 400) {
|
|
5796
|
+
this.splashBlinkAt = now;
|
|
5797
|
+
this.splashBlink = !this.splashBlink;
|
|
5798
|
+
drawBootSplash(this.screen, { diffuse: 1, wordAlpha: 1, blink: this.splashBlink });
|
|
5799
|
+
this.term?.output?.write?.(this.screen.render() + "\x1b[?25l");
|
|
5800
|
+
}
|
|
5801
|
+
return;
|
|
5802
|
+
}
|
|
5432
5803
|
if (this.dirty) {
|
|
5433
5804
|
this.dirty = false;
|
|
5434
5805
|
this.renderFrame();
|
|
@@ -5460,6 +5831,32 @@ export class App {
|
|
|
5460
5831
|
tick();
|
|
5461
5832
|
}
|
|
5462
5833
|
|
|
5834
|
+
/** Welcome brand shimmer: a diagonal sweep across the wordmarks (~0.85s every
|
|
5835
|
+
* ~3.5s) while a tall blank welcome is on screen. Separated from the tick
|
|
5836
|
+
* loop so tests can drive it with fake timestamps. */
|
|
5837
|
+
tickWelcomeShimmer(now) {
|
|
5838
|
+
const welcomeUp = this.chat.nodes.length === 0
|
|
5839
|
+
&& (this.sessions.find((s) => s.sessionId === this.currentSession)?.blank ?? false)
|
|
5840
|
+
&& this.chat.view.h >= 32 && this.chat.view.w >= 62;
|
|
5841
|
+
if (!welcomeUp) {
|
|
5842
|
+
if (this.brandSweep0 >= 0 || this.brandShimmer >= 0) { this.brandSweep0 = -1; this.brandShimmer = -1; }
|
|
5843
|
+
return;
|
|
5844
|
+
}
|
|
5845
|
+
if (this.brandSweep0 >= 0) {
|
|
5846
|
+
// 900ms so the very last tick reaches phase ~1.0 (the band's far end).
|
|
5847
|
+
if (now - this.brandSweep0 > 900) { this.brandSweep0 = -1; this.brandShimmer = -1; this.dirty = true; }
|
|
5848
|
+
else {
|
|
5849
|
+
const p = (now - this.brandSweep0) / 900;
|
|
5850
|
+
if (p !== this.brandShimmer) { this.brandShimmer = p; this.dirty = true; }
|
|
5851
|
+
}
|
|
5852
|
+
} else {
|
|
5853
|
+
if (this.brandShimmerNext == null) this.brandShimmerNext = now + 1500;
|
|
5854
|
+
if (now >= this.brandShimmerNext) {
|
|
5855
|
+
this.brandSweep0 = now; this.brandShimmer = 0; this.brandShimmerNext = now + 3500; this.dirty = true;
|
|
5856
|
+
}
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5463
5860
|
/** Force one render (also used by the scripted test harness). */
|
|
5464
5861
|
renderFrame() {
|
|
5465
5862
|
this.chat.flushRebuild();
|