dsh-neotui 0.4.1 → 0.4.3

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ### Added
5
+
6
+ - 欢迎页改版:**吉祥物 logo 默认关闭**(此前的 40×19 象限块/58×27 半块等实现保留,`Ctrl+R` 面板可随时启用预设/自定义/关闭;视觉迭代未能达成理想效果,先回退到仅标题)(真实色 255 色调色板,数据内嵌 `src/logo.js`,去背景、无残块、裁剪掉原图底部黑条);自定义 logo JSON 支持象限格式(`[mask,fg,bg]`)与旧半块格式(`[top,bottom]`);品牌行用 **`█▀▄` 半块像素字** 绘制(`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 列)回退为普通文本品牌行。
7
+ - 启动动画(经典游戏启动器风格,**opt-in meme**):纯黑 → 白色从屏幕中心圆形扩散至全屏(1s)→ 四行健康游戏忠告逐句淡入(2s)→ 忠告淡出的同时 `DEEPSEEK` 半块大字交叉淡入(2s)→ 底部闪烁「按任意键进入游戏」,任意键进入主界面——全程纯 ANSI(`█▀▄`),无图片协议;默认关闭,`--launcher-anime`(或 `DSH_TUI_LAUNCHER_ANIME=1`)显式启用,`DSH_TUI_NO_SPLASH=1` 强制关闭;`--script` 模式不播放,窄小终端自动跳过。
8
+
3
9
  ## 0.4.1 — 2026-08-22
4
10
 
5
11
  ### Added
package/README.md CHANGED
@@ -210,7 +210,9 @@ TUI 支持:
210
210
  - 工具审批、AskUser 单选/多选、Plan Review;
211
211
  - Goal、TODO、后台任务和 Subagent;
212
212
  - 独立插件清单及 `/` 筛选;
213
+ - 空白会话欢迎页:**默认不显示吉祥物 logo**(`Ctrl+R` 打开 logo 面板:预设(40×19 象限块 80×38 真实色)/ 自定义 JSON / 关闭);品牌行用 **`█▀▄` 半块像素字**绘制(`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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Keyboard-first Vim/tmux-style terminal client for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,43 @@
1
+ // ascii-logo.js — Welcome-page ASCII-art logo (52x24 character grid).
2
+ // Aspect-true for 1:2 terminal cells: 52 cols / 24 rows = 2.17, matching the
3
+ // source face (740x680 px). Each cell = [rampIndex, colorIndex] (hex byte
4
+ // each; 0,0 = empty). RAMP lightest '.' (no space keeps bright areas textured).
5
+ export const ASCII_LOGO_COLS = 52;
6
+ export const ASCII_LOGO_ROWS = 24;
7
+ export const ASCII_LOGO_RAMP = ".:-=+*#%@";
8
+ export const ASCII_LOGO_PALETTE = [
9
+ "fefeff","fefef7","fdfcfd","fdfbfd","fef9fd","fcfafe","fcfafb","fff2ed",
10
+ "ffefe8","feeee2","feecdf","feebe0","ffeae1","ffeadc","fdeadc","fceadd",
11
+ "fee9e2","ffe9de","fde9de","ffe9dd","ffe9dc","ffe9db","fce9d8","ffe8e3",
12
+ "ffe7de","fee8dd","fce8dd","ffe8dc","fee8d9","fde6da","fee5d9","fde3d8",
13
+ "ffe2d8","ffe1d9","fedacf","fed0c5","f9fefc","fafcfb","f6fdfc","fbfbfd",
14
+ "fbfbfb","fbfbf8","fbfafd","fafaf8","f6f9fd","f8f7fa","f7ede6","f8e6dc",
15
+ "f7dfd5","f9dbd1","f8d5cd","f1f1f7","e9eaf5","ece5e6","e9dbd9","d7d8e7",
16
+ "eed2ce","d9d5e5","d4d1e0","d0d5e8","d0cee0","cfcddc","cecbe2","cfcbda",
17
+ "b5e4fa","c0d7f4","b9ceeb","d0cadc","ccc9d8","c9c8da","c7c8de","c8c8d8",
18
+ "e2bcbc","e8b5b1","c8c5da","c8c3d0","bfc6e2","c5c6d8","c0bed5","abc1e9",
19
+ "a7b8e0","a5b5e1","e7b1af","ebaaaa","dab2b5","c2abbc","f4a19d","d49ea0",
20
+ "bf93a2","c58381","a3b0d6","9baddf","96aedb","95abdb","96aadc","95a9d8",
21
+ "97a6ce","9e9fc6","94a1c4","8faee1","91a7d8","8fa3d8","89a2d0","8c9bc7",
22
+ "8c96c3","839bcf","7697c3","8692c0","888ebf","8090bf","808cbf","7d8cbe",
23
+ "7d8ab7","778dc1","778bbe","718db9","8383a7","7a84b7","7685b4","7585ba",
24
+ "7188bc","7185b8","7085bb","7281b6","6e85b6","6d82b9","6885b3","6c81b5",
25
+ "6c80b6","6780b8","cc716d","9a7684","7778a5","707eb2","717ca8","72769e",
26
+ "6c7fb5","697eb9","697eb2","6b7bb1","687eb5","677eb1","677db7","687db2",
27
+ "677daf","687cb7","677cb3","677cb1","6774a6","657fb7","657fb3","667db2",
28
+ "647db6","647db3","667cb6","667cb4","657bb5","667bb2","657ab3","647ab4",
29
+ "657baf","657aaf","6679b0","6578b0","6479b0","6478ad","637db5","637cb5",
30
+ "627cb5","627bb4","607eb2","607cb3","6379b3","617ab3","6279ae","6278ae",
31
+ "6278a8","5f79ab","5b77a6","6175aa","5e74ae","5f72a7","5974a1","5d70a7",
32
+ "5c6f98","586fa5","576da4","576ba4","586a9b","586799","536ba2","53689f",
33
+ "4f689d","6b6486","586495","54659c","53628f","4e639c","4f6389","505e94",
34
+ "495e99","715861","7a4945","594d64","4c5b92","4c5781","485a8f","475084",
35
+ "455a94","445991","435891","435792","42578c","425691","43568d","41558e",
36
+ "42538f","465286","3f538d","3f5087","3c4c86","3a487e","32477e","724245",
37
+ "523e4d","3e4678","3c4184","5f3640","3f3857","552b30","391d2b","33457d",
38
+ "2e4076","313c6e","293672","2c345f","253163","252a57","221d38","1b2e61",
39
+ "11295c","152350","0c2357","0f1f56","0d1d50","121d46","0a1e53","101a48",
40
+ "0b1849","161536","071746","030e3c","080e29","090728","0c0605",
41
+ ];
42
+ export const ASCII_LOGO_GRID =
43
+ "0000000000000000000000000000000000000000000000000000000004b9002c0005002c002a0034013e036205c805c905c60147013e000105de0367025d0367002800040005002705ce000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005ce002d0029002e013d0035003507f607f505c905c9013c013807f904a6049606f00481047e024f013a00040003024c0034024c04b90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002d0003013b0006002e05c20250049e0498049a06df06df05dd05c905d404ae06e906df06dd07f1048a0372049704ab024301380145000100280006000305cc0000000000000000000000000000000000000000000000000000000000000000000000000487002c002e000200290475035f04ac049904ad06df049204a805bb04a904a7049904a704a7049b06f007f304b506eb04a70499049a049a025c07ef0144013e00340006003500000000000000000000000000000000000000000000000000000000000000000000002600290029013d0252037204a604aa0482049d049b04aa04b504a004aa04aa04aa04aa04ac04ae04a00499049904990499048204990499025e0360025c013b013e05cc05c707fb0000000000000000000000000000000000000000000000000000000000000001025601460138025d04b1036004b604a904b0049e04ae06f0049d04ad04ad04ad04ad04ae04ae04ae04ae04aa04aa04a8025f036a0497025f036004a60473048c07f8014b0256013d013800000000000000000000000000000000000000000000000000000001002b014c0138025204b104b604a504b604a20365049104a504b804b10360036b0365025c0252037203650365049b049c025c04a1036704a205d8047904b90023002106e105c3013f013e013e06e500000000000000000000000000000000000000000000000001440035013a025204a20498049d04a004ba036a04ae04a505d704b004b1036004a60366036604a2048e04ad048f049b04b504960496049904aa049907f704ac04ab06e7001900150011013a014005cd0000000000000000000000000000000000000000000005d50144013f025004b004aa04ac04aa0482049d04a80482049b07f10493048d0496048f048f047e04940481048a049e04800256047e05bf048f048f049b0492049b049a04ac04ad05c2000b0019000805cc07f2001e001105cb000f01320000000000000000000007fb014b0252049f05c904aa04aa049905db048a0492048a04b602550480047a047a0480047a047a04b1047804780491047a0255025504860478048800300031000b05cc048704a604a205c606e1001a001e000c035a0249000b024a024a00000000000000000000025204a304af05d204ae04a804a8049905d4047e05dd047b05ce06e507ef036904860370036f036f036f036e036f06ed036d01390013001202580473036e047706e101390019001f000c000906e105c200110031001b00300124024a05cb000007f7048e04b405d105c407f405cd0499049b0499048a047e05d1047905bc04c2000a000e0019048503690477036c036c036c05d00476025604750016001500150015000f025806ec05c404770480048605c205cb0022001a001505cb000c00310254025402530000000000000000000007f306f0049b05d10493048d047e047e05bc06e707fa001807ff07ff07ff0008013d04880137047703690369036205cc000f0031024a0132013907e7000f0014025804710478047804bd05bf05c006f005e0025400100015001c000f00150000000000000000000005da06f0049c05d1049e04930480047806ea013307e70140002d06e206e906d9024c0009002f03710484024906e504c2000906e006e600310132003105e006e60484036d0371048c04a605bc05c005c606e806e906e402530014001a001b00000000000007fc05da05da07f4048d05d6048a048b05c80477036c013906e0000406df02500141025000020010000f000f0017000f00120015001c00200023012301240124012401330368047605bd0486049407f405dc07f9036106ed06de06de06de06de06ea000006ed036805da05da05cf06ea025c07f505cd0252048605c3036f036c0009013301230133013200320020001200120012000900170012001300150020002100230023002106e207fe05cc05cd047c036006f006eb05c4025b0363014d024d014d024d014d06ec05da05d706e2036103610369036305ce07f107f805d104a4036605be05c506ec048707ff002101230032001f001c0015001500120012000f000f000f000f001e001e0020001e001707ef04860486047805db05d807f405d405d805d704ba05bb07f500000000000001420142014207fc0143026107f505cf05cd05d505d106df05c4047c047c036e06e1001e001f001f001e000f001506e00383045a035a04830383038300130012001200180249024a05c507fc05d905d9048c05d306eb05d404a305d804ba04b804ba05cd0000000000000000000005c007f505d505d104b804b805d805c005c4049805dc05d805d605d905c20016000b001200120015001505cb0257025702570257001a000d00120012000b06e105c105d305d70492048e049005d105d607f705d604b405d705bc04b406f004b807f7000007fc07fc0141025105c9047e04a305db05bc05db05d604b40490049304a405bc05d905c405be06e00030001300120012001200130015001200120019013105c305c005c605bf04b6047b04980490049005d105d405d607f107fc07fa048b05d507f70264025c036401430141014105c6048d049105d805c005d805d805d605dc0366048004940372047b06df04ba05bf04ba05bf05cb06e40139000a000d0139035a035a024a048804b304b704b205c0048b0366047d06e80142014d002500060006002a0001048b048607f9026404b601410141025204b004a105d205bb00010007002c024f013f013f036a0366047b047b06f004b304b304b3014d07ef02530254035a035a02540254025301380147024d04b204b20373047d047407f20147014e002c05c7003505c304b9002507fd07fc06e205c304b3025d047f047f048b047905c107f2002900290006000600060148024b06f0047d047406ea05be07fc024f0147014b014b0148014806e701470148014601460148024f024f07f6036b036b07f307f6013e00060034002a05ce05bf0027002a07fa06ee06e206e20474047407f7047f037405c405c007f6002a00290004013b013b0034014804b70252036b07f207fd06ef0147014701470146014b04750362036204880144013c07fa07fa07fd013f036e036a0491047d00340148002900030025002a000602560006002d06ec06e2";
@@ -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') { this.confirmUpload(); return true; }
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(),
@@ -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 quadrant).
2
+ // 40x19 cells × 2x2 quadrant pixels = 80x38 effective resolution.
3
+ // Each cell = 4 bytes: [type, maskByte, fg, bg]; type 0 = quadrant with
4
+ // maskByte low nibble = character quads, high nibble = second-color quads.
5
+ // Ordered luminance pairs (bright = fg), near-identical pairs collapsed.
6
+ export const LOGO_COLS = 40;
7
+ export const LOGO_ROWS = 19;
8
+ export const LOGO_PALETTE = [
9
+ "fefffd","fcfcfc","fcfbfb","fcfafc","fef2ea","feede3","feebde","feeae4",
10
+ "fee9de","ffe9dd","fdeae1","fdeadd","fdeadc","ffe8de","ffe8dc","fce8dc",
11
+ "fee6df","fde7da","fee5dc","fbfcfc","fbfdf9","fbfbfb","fbfafc","fbfaf5",
12
+ "fbebe2","fbe6df","f7fbfc","f7f8f9","f7f2ee","f3f4f3","eeecee","e2e9f1",
13
+ "fee3d9","fee2d7","fae2d8","fdded4","fddcd2","f6e0d7","ebe4e0","e1dfe8",
14
+ "f8d8cf","f8d2c8","ebd7d6","dfd8e1","f9d0c7","f4cac4","e7cecb","e7c8c2",
15
+ "d5d9ea","d4d2e1","d1d0dd","d0cede","ceccda","b6def8","bed4f3","cbcee2",
16
+ "c2ccec","cdcadc","cbc8d9","cac9db","c9c6d5","c7c7d7","c6c6d8","c0c8e1",
17
+ "eabbb9","dbb9c0","e4adaf","d6b0ae","c5c4d9","bfbecf","b3bbda","b5b5ce",
18
+ "b6acb7","a6b6e1","97b1e6","94aedf","a0acd4","97acdf","9aa9d2","94a9db",
19
+ "92a9d8","e9a3a3","de9896","b39da5","c78d8b","96a5d5","9ba5c3","98a0c3",
20
+ "978fab","91a6d6","8fa3d7","8ea0ca","8f9dcb","8c96c2","8c91b9","7ea1cc",
21
+ "879ac3","8498cb","8294c6","7893c2","818ebe","8590b5","7b8dbe","738db6",
22
+ "6d8cb9","7b88ba","7c85b8","7d85af","7787ba","7089bb","7386b8","7385b8",
23
+ "7084b6","6a84b4","7782ab","7282b4","7180af","6f82b5","707eb1","6b81b6",
24
+ "6b80b3","6a7eb6","6980b7","687fb7","687eb6","697fb2","6680b7","647eb6",
25
+ "af6c68","8c748a","77799a","6e7caf","6d78a8","6d6783","6a7db6","687db3",
26
+ "687cb1","6979ad","696d90","677db6","667cb6","667cb0","677bb4","667aad",
27
+ "6673a4","647db6","647cb5","637cb5","627cb4","627bb4","647cb1","617caa",
28
+ "657ab3","647ab0","637ab2","6279b2","6277b0","6278ab","6275ac","63739f",
29
+ "607aae","5f76ad","5d75ad","5d72a8","5973a8","5d70a6","5d6f98","576fa2",
30
+ "5c6b9e","576b9e","556b9f","59679c","576898","566888","526aa1","4c689e",
31
+ "5166a0","586498","5d6280","4e6497","4d619e","4d609a","4b5e96","495a94",
32
+ "525a84","4f5c71","475c93","455993","455892","445792","44588d","42587c",
33
+ "794e4d","654c51","5c506e","78403d","5f3b51","51557a","4d4e7a","4d3f5d",
34
+ "523745","45558d","42558f","41558d","45528a","40538d","415088","434572",
35
+ "44436c","423c5a","3e538c","3c5285","3d4f83","384b83","354881","394677",
36
+ "354275","30447a","393f75","3a4064","323f6d","303a6e","34395c","2b3e72",
37
+ "293a6c","29386f","583136","38324d","2f3155","263363","592527","432025",
38
+ "2d2945","2b1b33","213266","193166","1e2b5a","192c5f","1f244b","182755",
39
+ "1c1a38","132a5e","12255d","122256","0b2258","0e1e52","131e44","081c49",
40
+ "10173c","11152c","06174d","071444","0e0f2b","010c39","07061f",
41
+ ];
42
+ export const LOGO_GRID =
43
+ "0000000000000000000000000000000000000000000000000000000000000000000000000000000000088600002e17c6000f150000cf1635000f1600001f284900af34b200ef64b6008fb5ed003f35d5001f1e34001f38c7004f51df004f516e009f40dc008f023d004f0435002f16ba0004330000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004c18ba000c1e00002e183200cf1c33000f1c0000cf33b400ef2875003f93fc003fa4ed004fb6e200df31b500ef31a0005fccf400cf7fd7002fa1f5000f7b00002f9df600cf2862004f28fd004f2c86008f2854001f1454001d1486008c1e6100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008ba00001f16b3000f1c00000f1600008f33ff00ef01ac00ef4b95000f8d0000cfa3e2003f8dd7001fa3d7000f9300000f9600000f9400008f94af008f8daf003fb5ec008f7ad7004fa3f3000f8000000f780000ef50ad008f32fd00cf033a00cf0433000f0200009d17b300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005f1bba000f1400002f1733006f47b9004f4ea6000f7d00008f94ec002f9cbe000f9300000f9d00000f8c00000f9500000f9500000f9500000f9500000f9600000f8000000f8000000f9200000f7f00000f9400005f4c95002f5a7e002f50fa00cf1447004f1f83002f3ce5008cb4ec000000000000000000000000000000000000000000000000000000000000000000000000000000000008c000002e0234003f0446009f2046001f4e83000f5100000f5100008f78d3001f6deb00bf639b008f99ec000f7b00000f9d00000f9c00000fa200000f9c00000f9c00007f4e9c000fa500000f7800003f5193001f5ba1000f9700006f4b9e00ef5c9a00cf64e400df4b84008f48ef000f3b00000f3a00009d38c3000000000000000000000000000000000000000000000000000000000000000000000000002a1b20000f0300003f173500ef205600ef508e000f780000df4e8d000f9d0000cf5a9e00cf509d004f9db700af4e9e000f5a00000f5100000f5100005f5090005f5a8e00cf5a9a00ef5b7800df4ea400ef6297000f880000ef5b9b008f9bbc000f8000001f5fe8004f242f003f13b3001f2cdf000f3400008f3bdc0004fe000000000000000000000000000000000000000000000000000000000000000000006e3ddd003f3a83006f66fc00ef5697000f9600000f960000af7fda000f9900000fa300000f9d00005f99e2000f880000ef6288000f8000000f7d00000f7b00000f7d00000f7800000f7b0000df7aef000f7b00000f8c00000f8d00000f8d00001fa3f2000f9300000f850000df108b004f1ae3002f2154008f2ce8008da0f6004c07c2000c0e00001f2bc5009d13c200000000000000000000000000000000005f58f300af47b900ef519b000f8d00000f9400000f8000005f87d6000f8c00000f8d00007f44b200af42ac000f7600000f7100000f7000000f7000008f71a6000f6f00000f7700000f6f0000df43c600cf73c700cf88cf00cf85c6003f08c3003f248b003f138e008f58c9000f8500000fb20000cf30b3004f102e002f23e8005f2d81009f42c400ef1a5500ef26430008f40000000000008cdafe008c5d79001f6ffe008fa8ee001f9bd3000f9600000f8d00000f7c00005f7abe008f88e2008f6fe9003f2fd0003f2bdc007f65d1008f6ade000f6500000f6500002f65dd004f6584008f75e5008f65df001f0742000f0900001f2953003f446a008f6ab7002f85e300cf1ab200cf0f83004f29e8000f1000003f12c3003f0f82000f2300000f0900002f2ac4008f41c400df415200000000000269000003f6000003fb00005fcbf6007f99da000fa200000f8c00000f7800000f7100005fb7d600af83d1005f2fae000f2600000f0b00002f2ad2008f5885005f4de5004f5e6c000f5e00003f5e6b001f426b001f1a66000f0d00000f1200000f0700000f0600002f1244003f2adc005f6fe6000f7400008f77a700df86b1001f59e300cf21c5002f2ec2000f1000002f0ac1002f29c1001f29550000000000000000000000000008fc00002fcdf4001f67b1000fb700000f8800000f8e00000f7000009f90e1008f2ae8007f3de4007f1efa003fdbdf003fd7ea005f2bc9003f06e5008f2b6b007f42b9003f425e005f46c6004f104900cf2ac1002f19ff003f25ff003f24ff00af54e8004f2681000f6600008f6ade000f7700000fa600004fb1e000afb8db002fdbe300cf2fc800cf0f43000f2300000f10000000000000000000004c5d8a00ef56cd000fcd0000af508f008fbbf5004f7eb7002f79aa004f6a91002f65c2007f25e3004f1f81009f37e6007f2060003f3760001f05df000f0900000f0c00000f0600000f0d00000f0900000f0f00000f2100000f2500000f2d00000f2d00000f2a0000ef24ae008f5ead001f74f0007f4e77008f90f3001f90f4003f4ffb003f57d8003f4fd600bf5cd9000fd800007f47c8004c5b6300cf6dcd00ef5ecd00cfcdde00efcae6001f56f8005f5db700bf85b8006f4e9f002f71ee008f77de002f6bd2004f295e000f2e00000f2900000f2500000f2200000f0a00000f0a00000f0b00000f0d00000f0c00000f0c00000f1300000f2200000f2500000f2500003f21d100bf30c3003f6aa600bf637400ef50d700afbcf6001fbff400cf58bf008f58fc000f4700008f48e600cf40fc00df47dd003f40cd003f39d8003f394f004f39fa009f394d00ef58e100ef4bf0000fbf00004fccfb007f6d8a002f74a9003f84a900df44c9000f0800000f2400000f2200000f1200000f0d0000cf0ae3004f2154008f30e800cf0c5500cf0d81000f0600000f0a00000f0900000f230000af2bd100cf6db600cf74ce001fceee003f8acc00afbeeb000fbd0000bf8fbd007f9db1000fa50000ef5bcc0004fb00000000000000000000000000006eb1f8007f4ad5001fbbfb007f9fd300af8ecf001fbdf6008fb8f4007f89bd00bf7ed500cf74cb00cf6dce00ef6cef001f19f1000f0900000f0a00000f0a00003f12c4000f5200000f5200000f5200002f2de7000f0e00000f0900000f060000cf10dc00ef86b0004fbdf3007f7dd6000f790000af8ebb004fbcfb001fcbf5005fa4cb00bf9ebd004fa3cb009f89ec000faa00008e5bf30008fc00007f36d5001f36d500af36e0007f6ed7000f9a0000efb1cc009fb1ce008fcef2005f9fb8000f8900002f88d600bf8fcb002fb5f4002fb0b3001f5fe300cf09c6004f0c2f000f1100000f0a00000f1200000f0900000f0700008f1ae700cf2682001fafc3002fafec008fabfb002f8ad5000f8900000f790000afa4e0000fbe00000fbe00007f1cdd003f149a006f7ee6008f9fd3004f4bab002f4c7700af63e0000f3600008f36e0001f67bf000f8e0000ef8ebc007f02cf003f20d3004fd3f7007f39b4004f9ef000bf628e00bf6488001f78d7005fa5eb000fa400000fa800003f4dac004f52e3001f43c100cf105500cf0855001f53e7001f4355008f42f1009f5ced000fa500002f8ed3007f4c71004f6298008f67d8003f48e6007f0145001f1535008f1649008f1c8b002f14f9000f7900001f74aa00cf4bef002fa4f800ef36aa00ef3772008f7ea8007f6ea6008fabed000f1700000f0400002f042800bf013e000f3800009f73f600cf5b68002f71bf005f98d8009f9eeb003f47d4000f4500002f45ea003f3c41003f3f52008f43f1003f3f52001f3aea000f3b0000af3f48001f58ef00cfa8f700ef5064008f69b4005f48ad007f143f006f1eae003f15af000faf00005f1baf000f1400005fe5fa007fd0e5000fd000000e6800004f72ee006f68b4009f69ad000fad0000efb4f9000f0200000f1600003f1c2c00cf1d33006f1e3500af3fad001f68ee000f6400008feffa001f38ff00cf3efd00cf3ffa00cf3cf1000f3f00004f48f1004f58ff00df3ee500cf3cfd00cf3cdf00cf3fd100cf20d1002f3aef004f6068001f98f5008f61dd009f2882001f143d003f02a7009f1b6c008f1fc3000f0400003f03fd00bf1be5000fd000";
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,14 @@ 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, FilePicker, AttachmentPanel,
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";
23
+ import { ASCII_LOGO_COLS, ASCII_LOGO_ROWS, ASCII_LOGO_RAMP, ASCII_LOGO_PALETTE, ASCII_LOGO_GRID } from "./ascii-logo.js";
22
24
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
23
25
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
24
26
 
@@ -193,6 +195,144 @@ function compareSemver(left, right) {
193
195
  return 0;
194
196
  }
195
197
 
198
+ // ---- Boot splash (retro game-launcher animation) ----
199
+
200
+ const SPLASH_WHITE = 0xffffff;
201
+ const SPLASH_BLACK = 0x000000;
202
+ const SPLASH_BLUE = 0x2b5bd7; // DEEPSEEK wordmark color on white
203
+ const SPLASH_INK = 0x14181d; // caution-lines ink
204
+ const SPLASH_HINT = 0x2f3540; // bottom hint ink
205
+ /** Classic four-line healthy-gaming warning, one sentence per line. */
206
+ const SPLASH_LINES = [
207
+ "抵制不良游戏,拒绝盗版游戏。",
208
+ "注意自我保护,谨防受骗上当。",
209
+ "适度游戏益脑,沉迷游戏伤身。",
210
+ "合理安排时间,享受健康生活。",
211
+ ];
212
+ // 5x6 pixel glyphs rendered with half-blocks (3 terminal rows per letter);
213
+ // "█" full, "▀" upper half, "▄" lower half — pure-TUI, no image protocol.
214
+ const SPLASH_GLYPHS = {
215
+ D: ["11110", "10001", "10001", "10001", "10001", "11110"],
216
+ E: ["11111", "10000", "11110", "10000", "10000", "11111"],
217
+ P: ["11110", "10001", "10001", "11110", "10000", "10000"],
218
+ S: ["01111", "10000", "01110", "00001", "00001", "11110"],
219
+ K: ["10001", "10010", "10100", "11000", "10100", "10001"],
220
+ };
221
+
222
+ function lerpColor(a, b, t) {
223
+ const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
224
+ const br = (b >> 16) & 255, bg2 = (b >> 8) & 255, bb = b & 255;
225
+ return ((Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg2 - ag) * t) << 8) | Math.round(ab + (bb - ab) * t)) >>> 0;
226
+ }
227
+
228
+ // ---- Welcome brand wordmarks (TUI-drawn half-block letters) ----
229
+
230
+ /** 5x7 pixel font for the welcome brand lines (7 pixel rows = 4 terminal
231
+ * rows with ▀/▄ half-blocks, the last row single-half). */
232
+ const BRAND_GLYPHS = {
233
+ A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
234
+ D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
235
+ E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
236
+ H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
237
+ I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"], // 工-style serif I
238
+ K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
239
+ N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
240
+ O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
241
+ P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
242
+ R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
243
+ S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
244
+ T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
245
+ U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
246
+ };
247
+
248
+ /** Draw a half-block wordmark (5-wide glyphs, 6-col advance). `bandX`
249
+ * (nullable) places a diagonal shimmer sweep: pixels near the band mix
250
+ * toward white, tilted ~0.7 cols/row. */
251
+ function drawHalfBlockWordmark(screen, x0, y0, word, color, bandX, bg) {
252
+ const mix = (col, px, py) => {
253
+ if (bandX == null) return col;
254
+ const d = Math.abs((px + py * 0.6) - bandX);
255
+ if (d > 2.5) return col;
256
+ return lerpColor(col, 0xffffff, ((2.5 - d) / 2.5) * 0.7);
257
+ };
258
+ let cx = x0;
259
+ for (const ch of word) {
260
+ if (ch === " ") { cx += 5; continue; }
261
+ const rows = BRAND_GLYPHS[ch] ?? ["00000", "00000", "00000", "00000", "00000", "00000", "00000"];
262
+ for (let pr = 0; pr < 7; pr += 2) {
263
+ const topBits = rows[pr], botBits = rows[pr + 1] ?? "00000";
264
+ const y = y0 + pr / 2;
265
+ for (let px = 0; px < 5; px++) {
266
+ const t = topBits[px] === "1", b = botBits[px] === "1";
267
+ if (!t && !b) continue;
268
+ const fc = mix(color, cx + px, y);
269
+ if (t && b) screen.put(cx + px, y, "▀", { fg: fc, bg: fc });
270
+ else if (t) screen.put(cx + px, y, "▀", { fg: fc, bg });
271
+ else screen.put(cx + px, y, "▄", { fg: fc, bg });
272
+ }
273
+ }
274
+ cx += 6;
275
+ }
276
+ return cx - x0 - 1;
277
+ }
278
+
279
+ /** One frame of the opt-in boot animation. Phases are driven by opaque
280
+ * numbers so the renderer stays pure and testable:
281
+ * diffuse 0..1 white circle expanding from the screen center over black;
282
+ * lineAlpha[4] per-line opacity of the healthy-gaming warning;
283
+ * wordAlpha 0..1 DEEPSEEK wordmark crossfading in;
284
+ * blink show/hide the 按任意键进入游戏 prompt (toggling = blinking). */
285
+ export function drawBootSplash(screen, { diffuse = 0, lineAlpha = [0, 0, 0, 0], wordAlpha = 0, blink = false } = {}) {
286
+ const w = screen.w, h = screen.h;
287
+ const p = Math.max(0, Math.min(1, diffuse));
288
+ const cx = (w - 1) / 2, cy = (h - 1) / 2;
289
+ const maxR = Math.hypot(cx, cy * 2);
290
+ for (let y = 0; y < h; y++) {
291
+ for (let x = 0; x < w; x++) {
292
+ const r = Math.hypot(x - cx, (y - cy) * 2);
293
+ screen.put(x, y, " ", { bg: p >= 1 || r <= maxR * p ? SPLASH_WHITE : SPLASH_BLACK });
294
+ }
295
+ }
296
+ const inkFor = (a) => lerpColor(SPLASH_WHITE, SPLASH_INK, Math.max(0, Math.min(1, a)));
297
+ for (let i = 0; i < SPLASH_LINES.length; i++) {
298
+ const a = Math.max(0, Math.min(1, lineAlpha[i] ?? 0));
299
+ if (a <= 0) continue;
300
+ const spaced = [...SPLASH_LINES[i]].join(" ");
301
+ const x = Math.max(0, Math.floor((w - strWidth(spaced)) / 2));
302
+ const y = Math.max(1, Math.floor(h / 2) - 4 + i);
303
+ screen.text(x, y, spaced, { fg: inkFor(a), attrs: 1 });
304
+ }
305
+ if (wordAlpha > 0 && w >= 20) {
306
+ const word = "DEEPSEEK";
307
+ const ink = lerpColor(SPLASH_WHITE, SPLASH_BLUE, Math.max(0, Math.min(1, wordAlpha)));
308
+ if (w >= 52) {
309
+ const glyphW = 6, totalW = word.length * glyphW - 1;
310
+ const x0 = Math.max(0, Math.floor((w - totalW) / 2));
311
+ const y0 = Math.max(0, Math.floor(h / 2));
312
+ for (let li = 0; li < word.length; li++) {
313
+ const rows = SPLASH_GLYPHS[word[li]] ?? ["00000", "00000", "00000", "00000", "00000", "00000"];
314
+ for (let pr = 0; pr < 6; pr += 2) {
315
+ const top = rows[pr], bottom = rows[pr + 1];
316
+ const y = y0 + pr / 2;
317
+ for (let px2 = 0; px2 < 5; px2++) {
318
+ const t = top[px2] === "1", b = bottom[px2] === "1";
319
+ const x = x0 + li * glyphW + px2;
320
+ if (t && b) screen.text(x, y, "█", { fg: ink });
321
+ else if (t) screen.text(x, y, "▀", { fg: ink, bg: SPLASH_WHITE });
322
+ else if (b) screen.text(x, y, "▄", { fg: ink, bg: SPLASH_WHITE });
323
+ }
324
+ }
325
+ }
326
+ } else {
327
+ screen.text(Math.max(0, Math.floor((w - word.length) / 2)), Math.max(1, Math.floor(h / 2)), word, { fg: ink, attrs: 1 });
328
+ }
329
+ }
330
+ if (blink && (p >= 1 || wordAlpha > 0)) {
331
+ const hint = "按任意键进入游戏";
332
+ 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 });
333
+ }
334
+ }
335
+
196
336
  // ---- Tool card renderers (host-computed view models) ----
197
337
 
198
338
  function renderToolCard(view, width, expanded) {
@@ -1200,9 +1340,6 @@ export class ChatView extends Widget {
1200
1340
  this.rebuildQueued = false;
1201
1341
  this.cache = new Map(); // node render cache: key → { lines, marks }
1202
1342
  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
1343
  this.pressY = null;
1207
1344
  this.pressInfo = null; // hit identity locked at press time
1208
1345
  this.pressCtx = null;
@@ -1416,7 +1553,6 @@ export class ChatView extends Widget {
1416
1553
  async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
1417
1554
  this.sessionId = sessionId;
1418
1555
  this.nodes = [];
1419
- this.welcomeModeSel = 0;
1420
1556
  this.blockSel = -1;
1421
1557
  this.cursorMode = "block";
1422
1558
  this.visualAnchor = null;
@@ -2612,58 +2748,207 @@ export class ChatView extends Widget {
2612
2748
  return true;
2613
2749
  }
2614
2750
 
2615
- /** Blank session: whale logo + mode selection prompt (no conversation yet). */
2751
+ /** Blank session: logo + brand/version rows + a bottom mode hint. The 4-mode
2752
+ * inline list is gone: mode selection lives in the F9 picker buffer, which
2753
+ * also supports custom presets (web parity: self-made modes encouraged). */
2616
2754
  #renderWelcome(screen) {
2617
2755
  const x = this.view.x;
2618
2756
  const cx = x + Math.max(0, Math.floor((this.view.w - 40) / 2));
2619
- let y = this.view.y + 1;
2620
- const put = (t, fg, bold) => { if (y < this.view.y + this.view.h) { screen.text(cx, y, t, { fg, attrs: bold ? 1 : 0 }); } y++; };
2621
- put("", 0, false);
2757
+ const h = this.view.h, top = this.view.y + 1;
2758
+ const put = (t, fg, bold, yy) => { if (yy < top + h) screen.text(cx, yy, t, { fg, attrs: bold ? 1 : 0 }); };
2759
+ // 40x19 half-block logo, per mode: preset (bundled), custom (JSON file)
2760
+ // or none (title only). Centered on the 40-col block.
2622
2761
  this.welcomeVersionRows = [];
2623
- const versionLine = (name, version, key, fg, bold) => {
2762
+ // the logo only fits when the view is tall enough for its own grid;
2763
+ // otherwise the welcome falls back to the plain text brand lines.
2764
+ const needRows = this.app.logoMode === "none" ? 0
2765
+ : this.app.logoStyle === "ascii" && this.app.logoMode !== "custom" ? ASCII_LOGO_ROWS
2766
+ : this.app.logoMode === "custom" ? (this.app.logoData?.grid?.length ?? LOGO_ROWS)
2767
+ : (this.app.logoStyle === "ascii" ? ASCII_LOGO_ROWS : LOGO_ROWS);
2768
+ const logoH = this.view.w >= 62 && h >= needRows + 6 ? this.#renderWelcomeLogo(screen, cx, top) : 0;
2769
+ const logoDrawn = logoH > 0;
2770
+ // Brand lines: TUI-drawn half-block wordmarks (like the splash), each with
2771
+ // its clickable version/update row on the right; the diagonal shimmer
2772
+ // sweeps across every ~4s. Falls back to spaced text on short/narrow views.
2773
+ const brandY = top + (logoDrawn ? logoH + 1 : 0);
2774
+ const useWordmarks = h >= (logoH > 0 ? logoH + 13 : 32) && this.view.w >= 62;
2775
+ if (useWordmarks) {
2776
+ // The band must run PAST the right edge: the final I of DSH NEOTUI sits
2777
+ // at x0+57 (plus the ~0.6/row diagonal offset), so phase 1 lands ~x0+72.
2778
+ const bandX = this.app.brandShimmer >= 0
2779
+ ? (x + Math.floor((this.view.w - 58) / 2) - 8 + this.app.brandShimmer * 88)
2780
+ : null;
2781
+ const glow = lerpColor(T.HEADING, 0xffffff, 0.18);
2782
+ const w1Y = brandY;
2783
+ drawHalfBlockWordmark(screen, x + Math.max(0, Math.floor((this.view.w - 47) / 2)), w1Y, "DEEPSEEK", glow, bandX, T.BG);
2784
+ this.#putVersionRight(screen, w1Y + 1, "dsh", this.app.dshVersion ?? "unknown", T.HEADING, true);
2785
+ const w2Y = w1Y + 4;
2786
+ const glide = T.FAINT; // dark enough for the white glint to pop
2787
+ drawHalfBlockWordmark(screen, x + Math.max(0, Math.floor((this.view.w - 58) / 2)), w2Y, "DSH NEOTUI", glide, bandX, T.BG);
2788
+ this.#putVersionRight(screen, w2Y + 1, "tui", TUI_VERSION, T.FAINT, false);
2789
+ // Update/check notices live at the bottom (only when not latest).
2790
+ this.#putUpdateHints(screen, top, h);
2791
+ } else {
2792
+ const versionLine = (name, version, key, fg, bold, yy) => {
2793
+ const spaced = [...name].join(" ");
2794
+ const text = `${spaced} ${version === "unknown" ? "版本未知" : `v${version}`}`;
2795
+ const tx = x + Math.max(0, Math.floor((this.view.w - strWidth(text)) / 2));
2796
+ if (yy < top + h) screen.text(tx, yy, text, { fg, attrs: bold ? 1 : 0 });
2797
+ this.welcomeVersionRows[yy] = { key, x1: tx, x2: tx + strWidth(text) - 1 };
2798
+ };
2799
+ versionLine("DEEPSEEK HARNESS", this.app.dshVersion ?? "unknown", "dsh", T.HEADING, true, brandY);
2800
+ versionLine("DSH NEOTUI", TUI_VERSION, "tui", T.FAINT, false, brandY + 1);
2801
+ this.#putUpdateHints(screen, top, h);
2802
+ }
2803
+ // Bottom hint: current preset + F9 opens the (custom-mode friendly) picker.
2804
+ if (this.app.currentSession != null) {
2805
+ const currentPreset = this.app.sessions.find((s) => s.sessionId === this.app.currentSession)?.agentPreset;
2806
+ const hint = `模式: ${modeName(currentPreset ?? "standard")} · F9 打开模式选择(支持自定义) · Ctrl+R 欢迎页 logo`;
2807
+ this.welcomeModeRow = top + h - 2;
2808
+ const hx = x + Math.max(0, Math.floor((this.view.w - strWidth(hint)) / 2));
2809
+ screen.text(hx, this.welcomeModeRow, hint, { fg: T.WARN, attrs: 1 });
2810
+ }
2811
+ }
2812
+
2813
+ /** The welcome mascot for the current logo mode/style. Pixel style renders
2814
+ * 40x19 2x2 quadrant blocks (80x38 effective); ascii style renders the
2815
+ * 52x24 character grid (aspect-true for 1:2 terminal cells). Returns the
2816
+ * drawn height in rows (0 = not drawn). */
2817
+ #renderWelcomeLogo(screen, cx, top) {
2818
+ if (this.app.logoMode === "none") return 0;
2819
+ const hr = this.view.y + this.view.h;
2820
+ if (this.app.logoStyle === "ascii" && this.app.logoMode !== "custom") {
2821
+ for (let r = 0; r < ASCII_LOGO_ROWS && top + r < hr; r++) {
2822
+ for (let c = 0; c < ASCII_LOGO_COLS; c++) {
2823
+ const i = (r * ASCII_LOGO_COLS + c) * 4;
2824
+ // empty cell = ramp 0 AND color 0; ramp 0 with a color = the '.' glyph
2825
+ const ci = parseInt(ASCII_LOGO_GRID.slice(i, i + 2), 16);
2826
+ const pi = parseInt(ASCII_LOGO_GRID.slice(i + 2, i + 4), 16);
2827
+ if (ci === 0 && pi === 0) continue;
2828
+ screen.put(cx - Math.floor((ASCII_LOGO_COLS - LOGO_COLS) / 2) + c, top + r,
2829
+ ASCII_LOGO_RAMP[ci], { fg: pi ? parseInt(ASCII_LOGO_PALETTE[pi - 1], 16) : T.FAINT, attrs: 1 });
2830
+ }
2831
+ }
2832
+ return ASCII_LOGO_ROWS;
2833
+ }
2834
+ const custom = this.app.logoMode === "custom";
2835
+ let pal, grid, quadrant = !custom;
2836
+ if (custom) {
2837
+ if (!this.app.logoData) {
2838
+ if (this.app.logoPath) {
2839
+ try { this.app.logoData = this.app.loadCustomLogo(this.app.logoPath); }
2840
+ catch { this.app.logoData = null; } // invalid file → title-only
2841
+ }
2842
+ if (!this.app.logoData) return false;
2843
+ }
2844
+ pal = this.app.logoData.palette; grid = this.app.logoData.grid;
2845
+ quadrant = this.app.logoData.quadrant !== false;
2846
+ } else {
2847
+ pal = LOGO_PALETTE; grid = LOGO_GRID;
2848
+ }
2849
+ // mask → glyph: bits tl/tr/bl/br (quadrant blocks render width 1).
2850
+ const GL = [" ", "▘", "▝", "▀", "▖", "▌", "▞", "▛", "▗", "▚", "▐", "▜", "▄", "▙", "▟", "█"];
2851
+ const rows = custom ? grid.length : LOGO_ROWS;
2852
+ const cols = custom ? (grid[0]?.length ?? LOGO_COLS) : LOGO_COLS;
2853
+ for (let r = 0; r < rows && top + r < hr; r++) {
2854
+ for (let c = 0; c < cols; c++) {
2855
+ let kind, mask, fi, bi;
2856
+ if (quadrant) {
2857
+ const cell = custom ? grid[r]?.[c] : null;
2858
+ if (cell) { kind = cell.length === 4 ? cell[0] : 0; mask = cell.length === 4 ? cell[1] : cell[0]; fi = cell.length === 4 ? cell[2] : cell[1]; bi = cell.length === 4 ? cell[3] : cell[2]; }
2859
+ else {
2860
+ // each cell = 4 bytes (type,mask,fg,bg) = 8 hex chars; type 0 =
2861
+ // quadrant (low nibble character quads, high nibble 2nd-color
2862
+ // quads), type 1 = braille (mouth region, 2x4 dots)
2863
+ const i = (r * LOGO_COLS + c) * 8;
2864
+ kind = parseInt(grid.slice(i, i + 2), 16);
2865
+ mask = parseInt(grid.slice(i + 2, i + 4), 16);
2866
+ fi = parseInt(grid.slice(i + 4, i + 6), 16);
2867
+ bi = parseInt(grid.slice(i + 6, i + 8), 16);
2868
+ }
2869
+ if (kind === 0 && mask === 0 && fi === 0 && bi === 0) continue;
2870
+ const fgc = fi ? (custom ? pal[fi] : parseInt(pal[fi - 1], 16)) : null;
2871
+ const bgc = bi ? (custom ? pal[bi] : parseInt(pal[bi - 1], 16)) : null;
2872
+ if (kind === 1) {
2873
+ // braille mouth cell: 8 dots (2 cols x 4 rows), fg/bg pair
2874
+ screen.put(cx + c, top + r, String.fromCharCode(0x2800 | (mask & 0xff)),
2875
+ { fg: fgc ?? T.BG, bg: bgc ?? T.BG });
2876
+ continue;
2877
+ }
2878
+ if (kind === 2) {
2879
+ // half-block: one cell = one full-width pixel pair (top/bottom)
2880
+ screen.put(cx + c, top + r, "▀", { fg: fgc ?? T.BG, bg: bgc ?? T.BG });
2881
+ continue;
2882
+ }
2883
+ const fm = mask & 0x0f, cm = (mask >> 4) & 0x0f;
2884
+ if (fm === 0) continue;
2885
+ if (fm === 0x0f && bgc != null) {
2886
+ // full cell: second-color quads render as glyph bg → true 2×2.
2887
+ const glyph = GL[(~cm) & 0x0f];
2888
+ screen.put(cx + c, top + r, glyph, { fg: fgc ?? T.BG, bg: bgc });
2889
+ } else {
2890
+ // partial: character quads in fg over the transparent background.
2891
+ screen.put(cx + c, top + r, GL[fm], { fg: fgc ?? T.BG, bg: T.BG });
2892
+ }
2893
+ } else {
2894
+ const cell = custom ? grid[r][c] : null;
2895
+ let topIdx, botIdx;
2896
+ if (cell) { topIdx = cell[0]; botIdx = cell[1]; }
2897
+ else {
2898
+ const i = (r * LOGO_COLS + c) * 4;
2899
+ topIdx = parseInt(grid.slice(i, i + 2), 16);
2900
+ botIdx = parseInt(grid.slice(i + 2, i + 4), 16);
2901
+ }
2902
+ const emptyT = custom ? topIdx < 0 : topIdx === 0;
2903
+ const emptyB = custom ? botIdx < 0 : botIdx === 0;
2904
+ if (emptyT && emptyB) continue;
2905
+ const pal32 = (pi) => (custom ? pal[pi] : parseInt(pal[pi], 16));
2906
+ const tcol = emptyT ? null : pal32(custom ? topIdx : topIdx - 1);
2907
+ const bcol = emptyB ? null : pal32(custom ? botIdx : botIdx - 1);
2908
+ const fgc = tcol ?? bcol ?? T.BG;
2909
+ const bgc = bcol ?? T.BG;
2910
+ screen.put(cx + c, top + r, tcol != null ? "▀" : "▄", { fg: fgc, bg: bgc });
2911
+ }
2912
+ }
2913
+ }
2914
+ return LOGO_ROWS;
2915
+ }
2916
+
2917
+ /** Bottom update notices: only shown when a product has a pending update
2918
+ * (or a failed/ongoing check) — "已是最新" stays quiet. Clickable. */
2919
+ #putUpdateHints(screen, top, h) {
2920
+ this.welcomeUpdateRows = [];
2921
+ const names = { dsh: "DeepSeek Harness", tui: "dsh-neotui" };
2922
+ const parts = [];
2923
+ for (const key of ["dsh", "tui"]) {
2624
2924
  const check = this.app.versionChecks?.[key];
2625
- const status = check?.state === "checking" ? "← 检查更新…"
2626
- : check?.state === "current" ? "← 已是最新"
2627
- : check?.state === "update" ? `← 可更新 ${check.latest}`
2628
- : check?.state === "error" ? "← 检查失败(点击重试)"
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;
2925
+ if (!check) continue;
2926
+ if (check.state === "update") parts.push({ key, text: `${names[key]} 可更新 ${check.latest}` });
2927
+ else if (check.state === "error") parts.push({ key, text: `${names[key]} 检查失败(点击重试)` });
2928
+ else if (check.state === "checking") parts.push({ key, text: `${names[key]} 检查中…` });
2640
2929
  }
2641
- put(" 请选择模式(F9 或点击下方,选择后立即生效):", T.WARN, true);
2642
- put("", 0, false);
2643
- this.welcomeModes = [];
2644
- const currentPreset = this.app.sessions.find((s) => s.sessionId === this.app.currentSession)?.agentPreset;
2645
- const presets = [
2646
- ["standard", "标准模式", "完整编码 Agent(文件/Shell/检索/Skills/目标/子代理)"],
2647
- ["code", "PTC 模式", "标准模式能力 + Code Mode SDK 单程序多步操作"],
2648
- ["minimal", "极简模式", "仅持久 bash str_replace_editor 双工具"],
2649
- ["cordis", "创造模式", "标准模式 + 运行时检查/插件实验/预设创作"],
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++;
2930
+ if (!parts.length) return;
2931
+ const row = top + h - 3;
2932
+ this.welcomeUpdateRow = row;
2933
+ const full = parts.map((p) => p.text).join(" · ");
2934
+ const tx = this.view.x + Math.max(0, Math.floor((this.view.w - strWidth(full)) / 2));
2935
+ let at = tx;
2936
+ for (const p of parts) {
2937
+ if (row < top + h) screen.text(at, row, p.text, { fg: T.WARN, attrs: 1 });
2938
+ this.welcomeUpdateRows.push({ key: p.key, x1: at, x2: at + strWidth(p.text) - 1 });
2939
+ at += strWidth(p.text) + 3;
2664
2940
  }
2665
2941
  }
2666
2942
 
2943
+ /** Right-aligned version row for the wordmark brand lines (clickable check;
2944
+ * update notices themselves live at the bottom of the welcome). */
2945
+ #putVersionRight(screen, y, key, version, fg, bold) {
2946
+ const text = version === "unknown" ? "版本未知" : `v${version}`;
2947
+ const tx = this.view.x + Math.max(0, this.view.w - strWidth(text) - 2);
2948
+ if (y < this.view.y + this.view.h) screen.text(tx, y, text, { fg, attrs: bold ? 1 : 0 });
2949
+ this.welcomeVersionRows[y] = { key, x1: tx, x2: tx + strWidth(text) - 1 };
2950
+ }
2951
+
2667
2952
  render(screen) {
2668
2953
  // Blank session: show the welcome + mode prompt instead of empty chat.
2669
2954
  const isBlank = this.app.sessions.find((s) => s.sessionId === this.sessionId)?.blank ?? false;
@@ -2837,12 +3122,14 @@ export class ChatView extends Widget {
2837
3122
  if (this.view.inside(ev.x, ev.y)) {
2838
3123
  // clicks act, but never exit INSERT mode — Esc is the only way out
2839
3124
  if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
2840
- // Welcome-screen mode click: select the preset under the cursor.
3125
+ // Welcome-screen clicks: version rows check updates; the mode hint opens
3126
+ // the custom-mode-friendly picker buffer.
2841
3127
  if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
2842
3128
  const versionHit = this.welcomeVersionRows?.[ev.y];
2843
3129
  if (versionHit && ev.x >= versionHit.x1 && ev.x <= versionHit.x2) { this.app.checkUpdates(versionHit.key, true); return true; }
2844
- const id = this.welcomeModes[ev.y];
2845
- if (id) { this.app.selectPreset(id); return true; }
3130
+ const updateHit = this.welcomeUpdateRows?.find((r) => ev.y === this.welcomeUpdateRow && ev.x >= r.x1 && ev.x <= r.x2);
3131
+ if (updateHit) { this.app.checkUpdates(updateHit.key, true); return true; }
3132
+ if (ev.y === this.welcomeModeRow && ev.x >= cx && ev.x <= cx + 40) { this.app.showModePicker(); return true; }
2846
3133
  }
2847
3134
  if (ev.kind === "wheel-up" && this.view.scrollY <= 3 && this.hasMore) { void this.loadOlder(); return true; }
2848
3135
  if (ev.kind === "press" && ev.button === 0) {
@@ -2953,15 +3240,6 @@ export class ChatView extends Widget {
2953
3240
  }
2954
3241
 
2955
3242
  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
3243
  if (ev.type === "text" || ev.type === "paste") {
2966
3244
  // In cursor/Visual modes text is never inserted into the transcript or
2967
3245
  // silently redirected to INSERT. Only explicit i enters the input editor.
@@ -3335,12 +3613,24 @@ export class ApprovalPopup extends Popup {
3335
3613
  // ---- App ----
3336
3614
 
3337
3615
  export class App {
3338
- constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion }) {
3616
+ constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion, launcherAnime = false }) {
3339
3617
  this.screen = screen;
3340
3618
  this.term = term;
3341
3619
  this.api = api;
3342
3620
  this.log = log ?? (() => {});
3343
3621
  this.versionFetcher = versionFetcher;
3622
+ this.launcherAnime = launcherAnime; // opt-in boot animation (--launcher-anime)
3623
+ // Welcome logo mode: "preset" (bundled mascot), "custom" (JSON file) or
3624
+ // "none" (title only). Persisted in the tui config file.
3625
+ const logoCfg = loadTuiConfig().logo ?? {};
3626
+ this.logoMode = ["preset", "custom", "none"].includes(logoCfg.mode) ? logoCfg.mode : "none"; // mascot off by default; Ctrl+R re-enables
3627
+ this.logoStyle = logoCfg.style === "ascii" ? "ascii" : "pixel"; // pixel = quadrant blocks
3628
+ this.logoPath = logoCfg.path ?? null;
3629
+ this.logoData = null; // decoded custom logo: { palette, grid }
3630
+ this.logoLoadError = null;
3631
+ this.brandShimmer = -1; // welcome wordmark diagonal sweep phase (-1 = idle)
3632
+ this.brandSweep0 = -1;
3633
+ this.brandShimmerNext = null;
3344
3634
  this.popup = null;
3345
3635
  this.activePrompt = null;
3346
3636
  this.promptQueue = [];
@@ -4237,7 +4527,108 @@ export class App {
4237
4527
  showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
4238
4528
  showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
4239
4529
  showThemePicker() { this.overlay = new ThemePickerBuffer(this); this.redraw(); }
4240
- showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
4530
+
4531
+ /** Welcome-logo picker (Ctrl+R / 控制面板): preset, custom file, or none. */
4532
+ showLogoPicker() {
4533
+ const mark = (m) => (this.logoMode === m ? "● " : "○ ");
4534
+ const items = [
4535
+ { label: `${mark("preset")}预设`, hint: "DeepSeek 吉祥物(内置)", action: "preset" },
4536
+ { label: `${mark("custom")}自定义…`, hint: `选择 JSON 文件(当前: ${this.logoPath ?? "—"})`, action: "custom" },
4537
+ { label: `${mark("none")}关闭`, hint: "不显示 logo,仅保留品牌字标", action: "none" },
4538
+ { label: `${this.logoStyle === "ascii" ? "◉" : "○"} 字符样式`, hint: this.logoStyle === "ascii" ? "当前: ASCII 52×24(回车切回像素)" : "当前: 像素 80×38(回车切换为字符画)", action: "style" },
4539
+ ];
4540
+ const pick = async (action) => {
4541
+ if (action === "preset" || action === "none") {
4542
+ this.logoMode = action;
4543
+ this.logoData = null;
4544
+ this.logoLoadError = null;
4545
+ saveTuiConfig({ logo: { mode: action, path: action === "preset" ? null : this.logoPath, style: this.logoStyle } });
4546
+ this.closeOverlay();
4547
+ this.redraw();
4548
+ } else if (action === "style") {
4549
+ this.logoStyle = this.logoStyle === "ascii" ? "pixel" : "ascii";
4550
+ saveTuiConfig({ logo: { mode: this.logoMode, path: this.logoPath, style: this.logoStyle } });
4551
+ this.closeOverlay();
4552
+ this.redraw();
4553
+ } else if (action === "custom") {
4554
+ // Reuse the shared Ctrl+O / 新建工作区 picker (UploadPicker) in its
4555
+ // single-select mode — same UI, same navigation, one file only.
4556
+ const dir = this.logoPath ? dirname(this.logoPath) : (this.sessions.find((s) => s.sessionId === this.currentSession)?.cwd ?? process.cwd());
4557
+ const picker = new UploadPicker(this, {
4558
+ startPath: dir,
4559
+ single: true,
4560
+ onPickFile: (path) => this.#applyCustomLogo(path),
4561
+ onCancel: () => { this.overlay = null; this.focus(this.chat); this.redraw(); },
4562
+ });
4563
+ this.overlay = picker; this.focus(picker); this.redraw();
4564
+ }
4565
+ };
4566
+ const w = Math.max(1, Math.min(66, this.screen.w - 4)), ph = Math.max(1, Math.min(12, this.screen.h - 4));
4567
+ this.overlay = new Picker({
4568
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
4569
+ w, h: ph, title: "欢迎页 logo(Ctrl+R)",
4570
+ items,
4571
+ onPick: (it) => void pick(it.action),
4572
+ onCancel: () => this.closeOverlay(),
4573
+ });
4574
+ this.redraw();
4575
+ }
4576
+
4577
+ /** Load + validate a custom logo JSON ({ palette, grid }, 40x19) and switch
4578
+ * to it. Accepted schema mirrors the generator's logo-data.json; palette
4579
+ * entries may be [r,g,b] arrays or "#rrggbb"/"rrggbb" strings. Returns the
4580
+ * normalized data or throws with a user-facing message. */
4581
+ loadCustomLogo(path) {
4582
+ const data = JSON.parse(readFileSync(path, "utf8"));
4583
+ const pal = data?.palette, grid = data?.grid;
4584
+ const rows = grid?.length, cols = grid?.[0]?.length;
4585
+ if (!Array.isArray(pal) || !Array.isArray(grid) || (rows !== LOGO_ROWS && rows !== 19)
4586
+ || grid.some((row) => !Array.isArray(row) || row.length !== cols)
4587
+ || (cols !== LOGO_COLS && cols !== 40)
4588
+ || pal.length < 1 || pal.length > 65535) {
4589
+ throw new Error(`需要 { palette:[…], grid:[...] } 且为 58×27 或 40×19(得到 ${rows ?? "?"}×${cols ?? "?"})`);
4590
+ }
4591
+ const norm = pal.map((c) => {
4592
+ if (Array.isArray(c) && c.length >= 3) return ((c[0] << 16) | (c[1] << 8) | c[2]) >>> 0;
4593
+ if (typeof c === "string") return parseInt(c.replace("#", ""), 16);
4594
+ return NaN;
4595
+ });
4596
+ if (norm.some((c) => Number.isNaN(c) || c < 0 || c > 0xffffff)) throw new Error("调色板条目必须是 [r,g,b] 或 #rrggbb");
4597
+ // Cells: [top, bottom] = half-block (legacy) or [mask, fg, bg] = quadrant.
4598
+ const fmt = grid[0][0]?.length === 3 ? "quadrant" : "half";
4599
+ for (const row of grid) {
4600
+ for (const cell of row) {
4601
+ if (!Array.isArray(cell) || !Number.isInteger(cell[0])) throw new Error("grid 单元必须是数组");
4602
+ if (fmt === "quadrant") {
4603
+ if (cell.length !== 3 || cell[0] < 0 || cell[0] > 0xff || cell[1] < -1 || cell[2] < -1
4604
+ || cell[1] >= norm.length || cell[2] >= norm.length) {
4605
+ throw new Error("quadrant 单元必须是 [maskByte, fg, bg] 且索引在调色板范围内(mask 低四位=角色象限,高四位=第二色象限)");
4606
+ }
4607
+ } else {
4608
+ if (cell.length !== 2 || cell[0] < -1 || cell[1] < -1
4609
+ || cell[0] >= norm.length || cell[1] >= norm.length || !Number.isInteger(cell[1])) {
4610
+ throw new Error("half 单元必须是 [top, bottom] 且索引在调色板范围内");
4611
+ }
4612
+ }
4613
+ }
4614
+ }
4615
+ return { palette: norm, grid, quadrant: fmt === "quadrant" };
4616
+ }
4617
+
4618
+ #applyCustomLogo(path) {
4619
+ try {
4620
+ this.logoData = this.loadCustomLogo(path);
4621
+ this.logoMode = "custom";
4622
+ this.logoPath = path;
4623
+ this.logoLoadError = null;
4624
+ saveTuiConfig({ logo: { mode: "custom", path, style: this.logoStyle } });
4625
+ this.closeOverlay();
4626
+ this.redraw();
4627
+ this.toast("自定义 logo 已应用");
4628
+ } catch (e) {
4629
+ this.toast(`logo 文件无效: ${e.message}`);
4630
+ }
4631
+ }
4241
4632
 
4242
4633
  /** /reload: in-place soft reload — fresh session list, fresh chat history,
4243
4634
  * panels rebuilt, screen re-rendered. No process churn, no terminal
@@ -4446,6 +4837,7 @@ export class App {
4446
4837
  case "commandPalette": this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return true;
4447
4838
  case "modePicker": this.showModePicker(); return true;
4448
4839
  case "themePicker": this.showThemePicker(); return true;
4840
+ case "logoPicker": this.showLogoPicker(); return true;
4449
4841
  default: return false;
4450
4842
  }
4451
4843
  }
@@ -4551,6 +4943,16 @@ export class App {
4551
4943
 
4552
4944
  onEvent(ev) {
4553
4945
  if (ev.type === "resize") { this.resize(ev.w, ev.h); return; }
4946
+ // Retro launcher splash: every key/mouse press enters the game. Events
4947
+ // are swallowed while the blinking 按任意键提示 is on screen.
4948
+ if (this.splashWaiting) {
4949
+ if (ev.type === "key" || ev.type === "text" || ev.type === "paste" || ev.type === "mouse") {
4950
+ this.splashWaiting = false;
4951
+ this.screen.prev = null; // force a full repaint of the real UI
4952
+ this.redraw();
4953
+ }
4954
+ return;
4955
+ }
4554
4956
  if (this.swallowRelease && ev.type === "mouse" && ev.kind === "release") {
4555
4957
  this.swallowRelease = false;
4556
4958
  return; // a press just closed an overlay; eat its matching release
@@ -5429,9 +5831,63 @@ export class App {
5429
5831
 
5430
5832
  // ---- main loop ----
5431
5833
 
5834
+ /** DeepSeek-style boot splash: white background, DEEPSEEK wordmark fading
5835
+ * in, pulsing 启动中 dot, then dissolve into the theme background.
5836
+ * Opt-in meme: enable with --launcher-anime / DSH_TUI_LAUNCHER_ANIME=1;
5837
+ * DSH_TUI_NO_SPLASH=1 always wins. */
5838
+ playSplash() {
5839
+ if (!this.launcherAnime) return;
5840
+ if (process.env.DSH_TUI_NO_SPLASH === "1") return;
5841
+ if (!this.term?.output?.write) return;
5842
+ const w = this.screen.w, h = this.screen.h;
5843
+ if (w < 30 || h < 12) return;
5844
+ const wait = (ms) => { if (this.splashDelay ?? 30) { const sab = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sab, 0, 0, ms); } };
5845
+ const frames = (steps, dur, draw) => {
5846
+ for (let i = 0; i < steps; i++) {
5847
+ draw(i / Math.max(1, steps - 1), i);
5848
+ this.term.output.write(this.screen.render() + "\x1b[?25l");
5849
+ wait(dur);
5850
+ }
5851
+ };
5852
+ const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
5853
+ const clamp01 = (t) => Math.max(0, Math.min(1, t));
5854
+ // 1s: white circle diffuses from the screen center over pure black.
5855
+ frames(25, this.splashDelay ?? 40, (t) => drawBootSplash(this.screen, { diffuse: ease(t) }));
5856
+ // 2s: the four healthy-gaming lines fade in one sentence at a time.
5857
+ frames(40, this.splashDelay ?? 50, (t) => {
5858
+ const lineAlpha = [];
5859
+ for (let i = 0; i < SPLASH_LINES.length; i++) lineAlpha.push(ease(clamp01((t - i * 0.25) / 0.25)));
5860
+ drawBootSplash(this.screen, { diffuse: 1, lineAlpha });
5861
+ });
5862
+ // 2s: lines dissolve out while DEEPSEEK fades in (crossfade).
5863
+ frames(40, this.splashDelay ?? 50, (t) => drawBootSplash(this.screen, {
5864
+ diffuse: 1, lineAlpha: SPLASH_LINES.map(() => 1 - t), wordAlpha: ease(t),
5865
+ }));
5866
+ // Wait for any key: blinking prompt at the bottom; the tick loop toggles it.
5867
+ this.splashWaiting = true;
5868
+ this.splashBlink = true;
5869
+ this.splashBlinkAt = Date.now();
5870
+ drawBootSplash(this.screen, { diffuse: 1, wordAlpha: 1, blink: this.splashBlink });
5871
+ this.term.output.write(this.screen.render() + "\x1b[?25l");
5872
+ }
5873
+
5432
5874
  run() {
5875
+ this.playSplash();
5433
5876
  const tick = () => {
5434
5877
  try {
5878
+ this.tickWelcomeShimmer(Date.now());
5879
+ // While the splash waits for a key, only the blinking prompt is
5880
+ // repainted — the real UI must not render underneath it.
5881
+ if (this.splashWaiting) {
5882
+ const now = Date.now();
5883
+ if (now - (this.splashBlinkAt ?? 0) >= 400) {
5884
+ this.splashBlinkAt = now;
5885
+ this.splashBlink = !this.splashBlink;
5886
+ drawBootSplash(this.screen, { diffuse: 1, wordAlpha: 1, blink: this.splashBlink });
5887
+ this.term?.output?.write?.(this.screen.render() + "\x1b[?25l");
5888
+ }
5889
+ return;
5890
+ }
5435
5891
  if (this.dirty) {
5436
5892
  this.dirty = false;
5437
5893
  this.renderFrame();
@@ -5463,6 +5919,33 @@ export class App {
5463
5919
  tick();
5464
5920
  }
5465
5921
 
5922
+ /** Welcome brand shimmer: a diagonal sweep across the wordmarks (~0.85s every
5923
+ * ~3.5s) while a tall blank welcome is on screen. Separated from the tick
5924
+ * loop so tests can drive it with fake timestamps. */
5925
+ tickWelcomeShimmer(now) {
5926
+ const logoH = this.logoStyle === "ascii" ? ASCII_LOGO_ROWS : this.logoMode === "none" ? 0 : LOGO_ROWS;
5927
+ const welcomeUp = this.chat.nodes.length === 0
5928
+ && (this.sessions.find((s) => s.sessionId === this.currentSession)?.blank ?? false)
5929
+ && this.chat.view.h >= logoH + 13 && this.chat.view.w >= 62;
5930
+ if (!welcomeUp) {
5931
+ if (this.brandSweep0 >= 0 || this.brandShimmer >= 0) { this.brandSweep0 = -1; this.brandShimmer = -1; }
5932
+ return;
5933
+ }
5934
+ if (this.brandSweep0 >= 0) {
5935
+ // 900ms so the very last tick reaches phase ~1.0 (the band's far end).
5936
+ if (now - this.brandSweep0 > 900) { this.brandSweep0 = -1; this.brandShimmer = -1; this.dirty = true; }
5937
+ else {
5938
+ const p = (now - this.brandSweep0) / 900;
5939
+ if (p !== this.brandShimmer) { this.brandShimmer = p; this.dirty = true; }
5940
+ }
5941
+ } else {
5942
+ if (this.brandShimmerNext == null) this.brandShimmerNext = now + 1500;
5943
+ if (now >= this.brandShimmerNext) {
5944
+ this.brandSweep0 = now; this.brandShimmer = 0; this.brandShimmerNext = now + 3500; this.dirty = true;
5945
+ }
5946
+ }
5947
+ }
5948
+
5466
5949
  /** Force one render (also used by the scripted test harness). */
5467
5950
  renderFrame() {
5468
5951
  this.chat.flushRebuild();