dsh-neotui 0.4.1 → 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 +7 -0
- package/README.md +2 -0
- package/bin/dsh-tui.js +5 -1
- package/package.json +1 -1
- package/src/file-picker.js +11 -3
- package/src/index.js +1 -1
- package/src/keybindings.js +2 -1
- package/src/logo.js +43 -0
- package/src/panels.js +1 -1
- package/src/views.js +456 -62
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.4.1 — 2026-08-22
|
|
4
11
|
|
|
5
12
|
### Added
|
package/README.md
CHANGED
|
@@ -210,7 +210,9 @@ TUI 支持:
|
|
|
210
210
|
- 工具审批、AskUser 单选/多选、Plan Review;
|
|
211
211
|
- Goal、TODO、后台任务和 Subagent;
|
|
212
212
|
- 独立插件清单及 `/` 筛选;
|
|
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 列)回退为普通文本品牌行;
|
|
213
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` 模式不播放。
|
|
214
216
|
|
|
215
217
|
工作区(`Ctrl+W`)、设置(`Ctrl+S`)、模型供应商(模型选择器里的 `⚙ 管理供应商…`)、子代理(`Ctrl+A`)和技能(`Ctrl+H`)都是**全屏模态 Buffer**:打开后覆盖整个界面,`Esc` 逐级返回并最终关闭。它们不再占用“标签页模式”,因此关闭后 `Ctrl+←/→` 的 pane 聚焦立即恢复,两者互不冲突。轨迹仍是 pane 序列的一部分,由 `Ctrl+T` 或 `Ctrl+←/→` 进入。
|
|
216
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/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
|
@@ -51,13 +51,14 @@ export const DEFAULT_KEYBINDINGS = {
|
|
|
51
51
|
commandPalette: { mode: "normal", key: "Ctrl+P", key2: "" },
|
|
52
52
|
modePicker: { mode: "normal", key: "F9", key2: "" },
|
|
53
53
|
themePicker: { mode: "normal", key: "Ctrl+D", key2: "" },
|
|
54
|
+
logoPicker: { mode: "normal", key: "Ctrl+R", key2: "" },
|
|
54
55
|
quitDouble: { mode: "normal", key: "Ctrl+C", key2: "" },
|
|
55
56
|
quit: { mode: "all", key: "Ctrl+Q", key2: "" },
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
/** App-level dispatch precedence: the first matching binding wins. */
|
|
59
60
|
export const KEYBINDING_ORDER = [
|
|
60
|
-
"sessionFilter", "panel", "panePrev", "paneNext", "permissionRotate", "editConfig", "addWorkspace", "commandPalette", "modePicker", "themePicker", "copySelection", "quitDouble", "quit",
|
|
61
|
+
"sessionFilter", "panel", "panePrev", "paneNext", "permissionRotate", "editConfig", "addWorkspace", "commandPalette", "modePicker", "themePicker", "logoPicker", "copySelection", "quitDouble", "quit",
|
|
61
62
|
"model", "trajectory", "workspace", "settings", "subagent", "skills", "goal",
|
|
62
63
|
"jobs", "queue", "busyEnter", "attachments", "stepJump", "sidebar",
|
|
63
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
|
@@ -1749,7 +1749,7 @@ export class ControlPanel extends Widget {
|
|
|
1749
1749
|
const b=keyBindings();
|
|
1750
1750
|
const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${describeSpec(b[id]?.key)}\t${describeSpec(b[id]?.key2)}`,desc,null,id];
|
|
1751
1751
|
return [
|
|
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("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","退出"),
|
|
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","退出"),
|
|
1753
1753
|
];
|
|
1754
1754
|
}
|
|
1755
1755
|
items() {
|
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
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;
|
|
@@ -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 = [];
|
|
@@ -4237,7 +4457,90 @@ export class App {
|
|
|
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(); }
|
|
4240
|
-
|
|
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
|
+
}
|
|
4241
4544
|
|
|
4242
4545
|
/** /reload: in-place soft reload — fresh session list, fresh chat history,
|
|
4243
4546
|
* panels rebuilt, screen re-rendered. No process churn, no terminal
|
|
@@ -4446,6 +4749,7 @@ export class App {
|
|
|
4446
4749
|
case "commandPalette": this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return true;
|
|
4447
4750
|
case "modePicker": this.showModePicker(); return true;
|
|
4448
4751
|
case "themePicker": this.showThemePicker(); return true;
|
|
4752
|
+
case "logoPicker": this.showLogoPicker(); return true;
|
|
4449
4753
|
default: return false;
|
|
4450
4754
|
}
|
|
4451
4755
|
}
|
|
@@ -4551,6 +4855,16 @@ export class App {
|
|
|
4551
4855
|
|
|
4552
4856
|
onEvent(ev) {
|
|
4553
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
|
+
}
|
|
4554
4868
|
if (this.swallowRelease && ev.type === "mouse" && ev.kind === "release") {
|
|
4555
4869
|
this.swallowRelease = false;
|
|
4556
4870
|
return; // a press just closed an overlay; eat its matching release
|
|
@@ -5429,9 +5743,63 @@ export class App {
|
|
|
5429
5743
|
|
|
5430
5744
|
// ---- main loop ----
|
|
5431
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
|
+
|
|
5432
5786
|
run() {
|
|
5787
|
+
this.playSplash();
|
|
5433
5788
|
const tick = () => {
|
|
5434
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
|
+
}
|
|
5435
5803
|
if (this.dirty) {
|
|
5436
5804
|
this.dirty = false;
|
|
5437
5805
|
this.renderFrame();
|
|
@@ -5463,6 +5831,32 @@ export class App {
|
|
|
5463
5831
|
tick();
|
|
5464
5832
|
}
|
|
5465
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
|
+
|
|
5466
5860
|
/** Force one render (also used by the scripted test harness). */
|
|
5467
5861
|
renderFrame() {
|
|
5468
5862
|
this.chat.flushRebuild();
|