memory-eternal 0.14.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +27 -20
- package/.codex-plugin/plugin.json +24 -24
- package/.cursor-plugin/plugin.json +14 -14
- package/README.en.md +54 -50
- package/README.md +55 -51
- package/cursor-extension/package.json +54 -54
- package/dsh-tui-plugin/README.md +179 -119
- package/dsh-tui-plugin/demo.mjs +114 -26
- package/dsh-tui-plugin/dsh-plugin.json +42 -42
- package/dsh-tui-plugin/main.js +65 -31
- package/dsh-tui-plugin/package.json +51 -31
- package/dsh-tui-plugin/scene.js +125 -16
- package/index.js +3 -1
- package/lib/api.js +80 -14
- package/lib/capture.js +25 -2
- package/lib/client.js +116 -20
- package/lib/setup.js +67 -5
- package/orca-plugin/orca-plugin.json +36 -36
- package/package.json +145 -142
- package/web/app.js +121 -25
- package/web/index.html +14 -0
package/dsh-tui-plugin/README.md
CHANGED
|
@@ -1,119 +1,179 @@
|
|
|
1
|
-
# dsh-TUI plugin — `/memory`
|
|
2
|
-
|
|
3
|
-
在 dsh-TUI 里增加一个用户命令:
|
|
4
|
-
|
|
5
|
-
```text
|
|
6
|
-
/memory
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
执行后确保 `memory-eternal` 的本地 Web 服务存活,并打开同一套 Memory Atlas Web UI。
|
|
10
|
-
|
|
11
|
-
另一个命令:
|
|
12
|
-
|
|
13
|
-
```text
|
|
14
|
-
/audit
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
在 dsh-tui
|
|
18
|
-
|
|
19
|
-
## 先看效果(不需要 dsh-tui)
|
|
20
|
-
|
|
21
|
-
完整 TUI 原型:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
node dsh-tui-plugin/demo.mjs
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
包含:
|
|
28
|
-
|
|
29
|
-
- `1` 记忆库:搜索、类型、今日回看、读卡
|
|
30
|
-
- `2` 待审:类型、全选、批量批准/驳回、判词
|
|
31
|
-
- `3` 知识星图:连接簇、相关卡
|
|
32
|
-
- `4` 运行状态:知识库/备份/沉淀日志
|
|
33
|
-
- `5` 回收站:恢复、彻底删除
|
|
34
|
-
- `6` 设置:开关、主题
|
|
35
|
-
- `Enter` 读卡器
|
|
36
|
-
- `/` 搜索
|
|
37
|
-
- `?` 帮助
|
|
38
|
-
|
|
39
|
-
静态预览单页:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
node dsh-tui-plugin/demo.mjs --once --no-color
|
|
43
|
-
node dsh-tui-plugin/demo.mjs --once --no-color --screen=library
|
|
44
|
-
node dsh-tui-plugin/demo.mjs --once --no-color --screen=settings
|
|
45
|
-
node dsh-tui-plugin/demo.mjs --once --no-color --screen=audit --select-all
|
|
46
|
-
node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader
|
|
47
|
-
node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader --scroll=60
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
按键:
|
|
51
|
-
|
|
52
|
-
- `1–6` 切换页面
|
|
53
|
-
- `Tab` 循环页面
|
|
54
|
-
- `↑↓` / `j k` 移动
|
|
55
|
-
- `←→` / `t` 切换类型
|
|
56
|
-
- `Enter` 读卡 / 切换设置
|
|
57
|
-
- `L` 直接打开长文读卡器;`j/k` 滚动,`PgUp/PgDn` 翻页,`g/G` 首尾
|
|
58
|
-
|
|
59
|
-
实时数据模式:
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
# 只读:读取真实待审卡和正文
|
|
63
|
-
node dsh-tui-plugin/demo.mjs --live
|
|
64
|
-
|
|
65
|
-
# 写入:允许真实批准 / 驳回,谨慎使用
|
|
66
|
-
node dsh-tui-plugin/demo.mjs --live --write
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
实时模式默认走 `http://127.0.0.1:7999/memory-eternal/api`,可用
|
|
70
|
-
`MEMORY_ETERNAL_API` 覆盖。API 不可用时自动回退到示例数据。
|
|
71
|
-
- `Space` 多选 / 切换设置
|
|
72
|
-
- `s` 或 `Ctrl+A` 全选 / 反选
|
|
73
|
-
- `a` 批准,`r`
|
|
74
|
-
- 回收站:`r` 恢复,`d` 彻底删除
|
|
75
|
-
- `/` 搜索,`Esc` 返回 / 清空
|
|
76
|
-
- `?` 帮助,`q` 退出
|
|
77
|
-
|
|
78
|
-
每个页面的底部快捷键会随当前页面更新,例如待审页显示全选/批驳,设置页显示切换,回收站显示恢复/删除。
|
|
79
|
-
|
|
80
|
-
`demo-audit.mjs` 保留为纯审核页备份。
|
|
81
|
-
|
|
82
|
-
## 安装
|
|
83
|
-
|
|
84
|
-
在 `memory-eternal` 仓库根目录:
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
# 本地 profile(推荐先用这个验证)
|
|
88
|
-
dsh plugin --profile dsh-tui add ./dsh-tui-plugin
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
或手动:
|
|
92
|
-
|
|
93
|
-
1. 把本目录复制到 dsh-TUI profile 的 node_modules;
|
|
94
|
-
2. 在 profile 的 `cordis.patch.yml` 中加入:
|
|
95
|
-
|
|
96
|
-
```yaml
|
|
97
|
-
- insert:
|
|
98
|
-
- id: memory-eternal-tui
|
|
99
|
-
name: 'memory-eternal-tui'
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
3. 重启 dsh-TUI。
|
|
103
|
-
|
|
104
|
-
## 使用
|
|
105
|
-
|
|
106
|
-
```text
|
|
107
|
-
/memory
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
- 优先执行 `dsh-memory open`(确保 7999 存活 + 打开浏览器)
|
|
111
|
-
- 找不到 CLI 时直接打开 `http://127.0.0.1:7999/`
|
|
112
|
-
- 不会在 TUI 内嵌 WebView,也不复制第二套 UI
|
|
113
|
-
- 审批仍在 Web 里由人完成,不新增 agent 工具
|
|
114
|
-
|
|
115
|
-
##
|
|
116
|
-
|
|
117
|
-
- `memory
|
|
118
|
-
|
|
119
|
-
|
|
1
|
+
# dsh-TUI plugin — `/memory` 与 `/audit`
|
|
2
|
+
|
|
3
|
+
在 dsh-TUI 里增加一个用户命令:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
/memory
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
执行后确保 `memory-eternal` 的本地 Web 服务存活,并打开同一套 Memory Atlas Web UI。
|
|
10
|
+
|
|
11
|
+
另一个命令:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
/audit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
在 dsh-tui 内直接打开**原生审核场景**:读取真实待审队列,判词面板显示卡片来源与 `reason`(区分「蒸馏兜底原文卡」和真正值得审的内容)、`severity`;`s` 全选、`a` 批准、`r` 驳回(批准/驳回都先进入二次确认,驳回可顺手填理由),`Esc` 取消/退出。它不打开浏览器,也不复制 Web UI。详见下文 [「`/audit` 审核闭环」](#audit-审核闭环issue-50)。
|
|
18
|
+
|
|
19
|
+
## 先看效果(不需要 dsh-tui)
|
|
20
|
+
|
|
21
|
+
完整 TUI 原型:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
node dsh-tui-plugin/demo.mjs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
包含:
|
|
28
|
+
|
|
29
|
+
- `1` 记忆库:搜索、类型、今日回看、读卡
|
|
30
|
+
- `2` 待审:类型、全选、批量批准/驳回、判词
|
|
31
|
+
- `3` 知识星图:连接簇、相关卡
|
|
32
|
+
- `4` 运行状态:知识库/备份/沉淀日志
|
|
33
|
+
- `5` 回收站:恢复、彻底删除
|
|
34
|
+
- `6` 设置:开关、主题
|
|
35
|
+
- `Enter` 读卡器
|
|
36
|
+
- `/` 搜索
|
|
37
|
+
- `?` 帮助
|
|
38
|
+
|
|
39
|
+
静态预览单页:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
node dsh-tui-plugin/demo.mjs --once --no-color
|
|
43
|
+
node dsh-tui-plugin/demo.mjs --once --no-color --screen=library
|
|
44
|
+
node dsh-tui-plugin/demo.mjs --once --no-color --screen=settings
|
|
45
|
+
node dsh-tui-plugin/demo.mjs --once --no-color --screen=audit --select-all
|
|
46
|
+
node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader
|
|
47
|
+
node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader --scroll=60
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
按键:
|
|
51
|
+
|
|
52
|
+
- `1–6` 切换页面
|
|
53
|
+
- `Tab` 循环页面
|
|
54
|
+
- `↑↓` / `j k` 移动
|
|
55
|
+
- `←→` / `t` 切换类型
|
|
56
|
+
- `Enter` 读卡 / 切换设置
|
|
57
|
+
- `L` 直接打开长文读卡器;`j/k` 滚动,`PgUp/PgDn` 翻页,`g/G` 首尾
|
|
58
|
+
|
|
59
|
+
实时数据模式:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# 只读:读取真实待审卡和正文
|
|
63
|
+
node dsh-tui-plugin/demo.mjs --live
|
|
64
|
+
|
|
65
|
+
# 写入:允许真实批准 / 驳回,谨慎使用
|
|
66
|
+
node dsh-tui-plugin/demo.mjs --live --write
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
实时模式默认走 `http://127.0.0.1:7999/memory-eternal/api`,可用
|
|
70
|
+
`MEMORY_ETERNAL_API` 覆盖。API 不可用时自动回退到示例数据。
|
|
71
|
+
- `Space` 多选 / 切换设置
|
|
72
|
+
- `s` 或 `Ctrl+A` 全选 / 反选
|
|
73
|
+
- `a` 批准,`r` 驳回(都先进入二次确认:`Enter` 才提交,`Esc` 取消;驳回确认态里可直接输入理由)
|
|
74
|
+
- 回收站:`r` 恢复,`d` 彻底删除
|
|
75
|
+
- `/` 搜索,`Esc` 返回 / 清空
|
|
76
|
+
- `?` 帮助,`q` 退出
|
|
77
|
+
|
|
78
|
+
每个页面的底部快捷键会随当前页面更新,例如待审页显示全选/批驳,设置页显示切换,回收站显示恢复/删除。
|
|
79
|
+
|
|
80
|
+
`demo-audit.mjs` 保留为纯审核页备份。
|
|
81
|
+
|
|
82
|
+
## 安装
|
|
83
|
+
|
|
84
|
+
在 `memory-eternal` 仓库根目录:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# 本地 profile(推荐先用这个验证)
|
|
88
|
+
dsh plugin --profile dsh-tui add ./dsh-tui-plugin
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
或手动:
|
|
92
|
+
|
|
93
|
+
1. 把本目录复制到 dsh-TUI profile 的 node_modules;
|
|
94
|
+
2. 在 profile 的 `cordis.patch.yml` 中加入:
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
- insert:
|
|
98
|
+
- id: memory-eternal-tui
|
|
99
|
+
name: 'memory-eternal-tui'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
3. 重启 dsh-TUI。
|
|
103
|
+
|
|
104
|
+
## 使用
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
/memory
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- 优先执行 `dsh-memory open`(确保 7999 存活 + 打开浏览器)
|
|
111
|
+
- 找不到 CLI 时直接打开 `http://127.0.0.1:7999/`
|
|
112
|
+
- 不会在 TUI 内嵌 WebView,也不复制第二套 UI
|
|
113
|
+
- 审批仍在 Web 里由人完成,不新增 agent 工具
|
|
114
|
+
|
|
115
|
+
## `/audit` 审核闭环(issue #50)
|
|
116
|
+
|
|
117
|
+
装到 dsh-tui 之后,输入 `/audit` 打开原生审核场景(或 `/memory` 打开 Web 版)。
|
|
118
|
+
两者用的是**同一份数据**:待审队列来自 `GET /cards?status=pending&limit=300`,批准/驳回走
|
|
119
|
+
`POST /audit/approve`、`POST /audit/reject`,最终都落到 `setCardStatus()` + `audit_log`,
|
|
120
|
+
不新增 agent 工具、不绕过审核守卫。
|
|
121
|
+
|
|
122
|
+
### 判词面板:先看 `reason`
|
|
123
|
+
|
|
124
|
+
右侧面板按顺序显示:`kind · 时间 · 来源 · 严重度` → `依据:<reason>` → 兜底标记 → 标题/摘要。
|
|
125
|
+
`reason` 由卡片 DB 的 `reason` 字段带出(`/cards` 已返回),常见值:
|
|
126
|
+
|
|
127
|
+
| reason | 含义 | 审核建议 |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| `AI 自动沉淀(蒸馏卡)` | LLM 蒸馏出的知识卡 | 正常审 |
|
|
130
|
+
| `AI 自动沉淀(原文卡)` | **蒸馏失败/不可用时的降级原文卡** | 优先复核,多数可驳回 |
|
|
131
|
+
| `手动创建` | `/write` 手动写入 | 正常审 |
|
|
132
|
+
| `多卡合并` | 优化合并产生 | 正常审 |
|
|
133
|
+
|
|
134
|
+
- 兜底原文卡额外显示 `⚠ 蒸馏兜底原文卡(无 LLM 精炼,优先复核)`;
|
|
135
|
+
- 没有 `reason` 的卡片不显示该行(不留空行);
|
|
136
|
+
- `severity` 为默认值 `info` 时不显示,非默认值(如 `warning`)才出现在第一行。
|
|
137
|
+
|
|
138
|
+
### 键位
|
|
139
|
+
|
|
140
|
+
| 键 | 作用 |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `↑` / `↓` | 移动选中项(列表窗口跟着走,标题显示当前屏 `起-止/总数`) |
|
|
143
|
+
| `Space` | 多选 / 取消选中当前卡 |
|
|
144
|
+
| `s` / `Ctrl+A` | 全选 / 全不选 |
|
|
145
|
+
| `a` | 进入**批准确认态**(底栏:`确认批准 N 张?Enter 确认 / Esc 取消`) |
|
|
146
|
+
| `r` | 进入**驳回确认态**并输入理由(底栏:`驳回 N 张 · 理由(可空):… Enter 确认 / Esc 取消`) |
|
|
147
|
+
| `Enter` | 确认态下提交(驳回时把输入的理由一起提交,批量驳回共用同一理由) |
|
|
148
|
+
| `Esc` | 确认态:取消回列表;列表:退出场景 |
|
|
149
|
+
| 其他可见字符 | 仅驳回确认态:拼进理由(可空;上限 120 字) |
|
|
150
|
+
|
|
151
|
+
确认目标在进入确认态时**冻结**:确认期间列表/选择变化不会改变这次提交的对象;
|
|
152
|
+
提交失败会留在确认态,排好网络/权限后可直接 `Enter` 重试。
|
|
153
|
+
|
|
154
|
+
### 驳回理由写到哪
|
|
155
|
+
|
|
156
|
+
`POST /audit/reject` 现在接受可选 `reason`(JSON body 或 query):
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
// body: { path, reason? } 或 GET /audit/reject?path=...&reason=...
|
|
160
|
+
await setCardStatus(vaultRoot, rel, 'rejected', { changedBy: 'user', reason })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- 有理由 → 写进 `audit_log.reason`(`changed_by='user'`);
|
|
164
|
+
- 不传理由 → `reason` 仍是空串,状态流转与以前一致(唯一区别是 `changed_by` 从默认
|
|
165
|
+
`'system'` 变成 `'user'`,因为这是人点的);
|
|
166
|
+
- 响应里回显 `reason`,方便调用方自检。
|
|
167
|
+
|
|
168
|
+
### 本机没有 dsh-tui 宿主时
|
|
169
|
+
|
|
170
|
+
本插件只做 dsh-tui 宿主注册(`tuiPluginHost` / `tuiScenes`)。没有宿主时 `/audit` 不会被加载,
|
|
171
|
+
能用的是仓库里的 TUI 原型(`node dsh-tui-plugin/demo.mjs --live`)。
|
|
172
|
+
纯函数(`cardMeta` / `reasonLine` / `reasonFlag` / `positionLabel` / `confirmLabel` / `auditPayload`)
|
|
173
|
+
可以在没有宿主的环境里直接被 `tests/tui-audit-scene.test.mjs` 验证。
|
|
174
|
+
|
|
175
|
+
## 要求
|
|
176
|
+
|
|
177
|
+
- `memory-eternal` 已安装在当前机器
|
|
178
|
+
- 终端能执行 `dsh-memory`(否则走 URL 兜底)
|
|
179
|
+
- dsh-TUI >= 0.9.0(commands seam)
|
package/dsh-tui-plugin/demo.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Memory Atlas TUI —— 完整交互原型
|
|
3
3
|
// node dsh-tui-plugin/demo.mjs
|
|
4
4
|
// 静态帧:node dsh-tui-plugin/demo.mjs --once --no-color
|
|
5
|
+
// 定位列表:node dsh-tui-plugin/demo.mjs --once --no-color --screen=library --index=900
|
|
5
6
|
// 全选预览:node dsh-tui-plugin/demo.mjs --once --no-color --select-all
|
|
6
7
|
//
|
|
7
8
|
// 示例数据,不连接真实 vault,不写任何卡片。
|
|
@@ -174,11 +175,13 @@ const SETTINGS = [
|
|
|
174
175
|
const argv = new Set(process.argv.slice(2))
|
|
175
176
|
const once = argv.has('--once')
|
|
176
177
|
const selectAllAtStart = argv.has('--select-all')
|
|
177
|
-
const liveMode = argv.has('--
|
|
178
|
+
const liveMode = !argv.has('--demo') && !argv.has('--sample')
|
|
178
179
|
const writeMode = argv.has('--write')
|
|
179
180
|
const API = process.env.MEMORY_ETERNAL_API || 'http://127.0.0.1:7999/memory-eternal/api'
|
|
180
181
|
const screenArg = (process.argv.find((a) => a.startsWith('--screen=')) || '').split('=')[1]
|
|
181
182
|
const scrollArg = Number((process.argv.find((a) => a.startsWith('--scroll=')) || '').split('=')[1] || 0)
|
|
183
|
+
// --index=N:设定初始选中项;列表窗口会跟着它滚动,便于自动化验收位置
|
|
184
|
+
const indexArg = Number((process.argv.find((a) => a.startsWith('--index=')) || '').split('=')[1])
|
|
182
185
|
const useColor = !argv.has('--no-color') && !process.env.NO_COLOR
|
|
183
186
|
|
|
184
187
|
const sgr = (code, text) => (useColor ? code + text + C.reset : text)
|
|
@@ -227,13 +230,55 @@ const wrap = (text, n) => {
|
|
|
227
230
|
function panel(title, lines, w, h, accent = C.border) {
|
|
228
231
|
const inner = Math.max(1, w - 2)
|
|
229
232
|
const t = ' ' + title + ' '
|
|
230
|
-
|
|
233
|
+
// 顶框总宽必须等于内框 + 左右边框(= w):标题两侧各多占 1 列,否则右边会多出 1 列
|
|
234
|
+
const top = sgr(accent, '╭─') + sgr(accent, t) + sgr(accent, '─'.repeat(Math.max(0, inner - width(t) - 1)) + '╮')
|
|
231
235
|
const bottom = sgr(accent, '╰' + '─'.repeat(inner) + '╯')
|
|
232
236
|
const body = []
|
|
233
237
|
for (let i = 0; i < h - 2; i++) body.push(sgr(C.border, '│') + pad(clip(lines[i] || '', inner), inner) + sgr(C.border, '│'))
|
|
234
238
|
return [top, ...body, bottom].join('\n')
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
// ── 列表窗口 + 滚动条 ────────────────────────────────────────────────
|
|
242
|
+
// 记忆库 / 待审队列不再全量渲染:只渲染 listTop 起的一屏,右侧 1 列画
|
|
243
|
+
// 滚动条(滑轨 │ / 滑块 █),并保证 state.index 始终落在窗口内。
|
|
244
|
+
const SCROLL_THUMB = '█'
|
|
245
|
+
const SCROLL_TRACK = '│'
|
|
246
|
+
|
|
247
|
+
function windowInfo(state, total, visible) {
|
|
248
|
+
const count = Math.max(0, Number.isFinite(total) ? Math.floor(total) : 0)
|
|
249
|
+
const vis = Math.max(1, Number.isFinite(visible) ? Math.floor(visible) : 1)
|
|
250
|
+
let index = Math.max(0, Math.min(Math.floor(Number(state.index) || 0), Math.max(0, count - 1)))
|
|
251
|
+
let top = Math.max(0, Math.floor(Number(state.listTop) || 0))
|
|
252
|
+
if (index < top) top = index
|
|
253
|
+
else if (index >= top + vis) top = index - vis + 1
|
|
254
|
+
top = Math.min(top, Math.max(0, count - vis))
|
|
255
|
+
state.index = index
|
|
256
|
+
state.listTop = top
|
|
257
|
+
return { total: count, visible: vis, index, top, end: Math.min(count, top + vis) }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scrollChar(row, rows, view) {
|
|
261
|
+
if (!view || view.total <= view.visible || rows <= 0) return ' '
|
|
262
|
+
const thumbH = Math.min(rows, Math.max(1, Math.round((view.visible / view.total) * rows)))
|
|
263
|
+
const maxTop = Math.max(1, view.total - view.visible)
|
|
264
|
+
const pos = Math.round((Math.min(view.top, maxTop) / maxTop) * (rows - thumbH))
|
|
265
|
+
return row >= pos && row < pos + thumbH ? sgr(C.ember, SCROLL_THUMB) : sgr(C.faint, SCROLL_TRACK)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// panel 会把每行 clip/pad 到 inner 宽度:这里先把正文压到 inner-1,再补滚动条列,
|
|
269
|
+
// 所以右边框不会被挤掉;不需要滚动时该列留空。
|
|
270
|
+
function scrollPanel(title, lines, w, h, accent, view) {
|
|
271
|
+
const inner = Math.max(1, w - 2)
|
|
272
|
+
const rows = Math.max(0, h - 2)
|
|
273
|
+
if (!view) return panel(title, lines, w, h, accent)
|
|
274
|
+
const cw = Math.max(1, inner - 1)
|
|
275
|
+
const composed = []
|
|
276
|
+
for (let i = 0; i < rows; i++) {
|
|
277
|
+
composed.push(pad(clip(lines[i] || '', cw), cw) + scrollChar(i, rows, view))
|
|
278
|
+
}
|
|
279
|
+
return panel(title, composed, w, h, accent)
|
|
280
|
+
}
|
|
281
|
+
|
|
237
282
|
const kindOf = (c) => KIND[c.kind] || KIND.project
|
|
238
283
|
const filtered = (state) => {
|
|
239
284
|
const q = state.query.trim().toLowerCase()
|
|
@@ -261,14 +306,18 @@ function searchRow(state, w) {
|
|
|
261
306
|
return ' ' + sgr(C.faint, '/ ') + sgr(state.searchMode ? C.text : C.faint, text) + ' ' + sgr(C.faint, 'Enter 确认 · Esc 清空')
|
|
262
307
|
}
|
|
263
308
|
|
|
264
|
-
function queueLines(cards, state, w) {
|
|
309
|
+
function queueLines(cards, state, w, view) {
|
|
265
310
|
if (!cards.length) return ['', sgr(C.green, ' ✓ 没有匹配的卡片'), '', sgr(C.dim, ' 按 Esc 清空搜索 / 筛选')]
|
|
266
311
|
const lines = []
|
|
267
312
|
const chosen = cards.filter((c) => state.marked.has(c.id)).length
|
|
268
313
|
const all = cards.length > 0 && chosen === cards.length
|
|
269
314
|
lines.push(` ${all ? sgr(C.ember, '▣') : sgr(C.faint, '☐')} ${sgr(C.dim, all ? '反选' : '全选')} ${cards.length} ${chosen ? sgr(C.amber, '已选 ' + chosen) : sgr(C.faint, '已选 0')} ${keycap('s', all ? '反选' : '全选')}`)
|
|
270
315
|
lines.push('')
|
|
271
|
-
|
|
316
|
+
// 只渲染窗口内的项:view.top..view.end(0 张 / 全量时退化为原行为)
|
|
317
|
+
const from = view ? view.top : 0
|
|
318
|
+
const to = view ? view.end : cards.length
|
|
319
|
+
for (let i = from; i < to; i++) {
|
|
320
|
+
const card = cards[i]
|
|
272
321
|
const k = kindOf(card)
|
|
273
322
|
const selected = i === state.index
|
|
274
323
|
const marked = state.marked.has(card.id)
|
|
@@ -282,7 +331,7 @@ function queueLines(cards, state, w) {
|
|
|
282
331
|
if (selected) meta = tone(pad(clip(meta, w), w), '', C.selectedBg)
|
|
283
332
|
lines.push(clip(meta, w))
|
|
284
333
|
lines.push('')
|
|
285
|
-
}
|
|
334
|
+
}
|
|
286
335
|
return lines
|
|
287
336
|
}
|
|
288
337
|
|
|
@@ -313,11 +362,17 @@ function previewLines(card, w) {
|
|
|
313
362
|
|
|
314
363
|
function auditView(state, w, h) {
|
|
315
364
|
const cards = filtered(state)
|
|
316
|
-
const selected = cards[state.index]
|
|
317
365
|
const qw = Math.max(40, Math.floor((w - 1) * 0.4))
|
|
318
366
|
const pw = w - qw - 1
|
|
319
367
|
const ph = h - 2
|
|
320
|
-
|
|
368
|
+
// 队列面板内高 = ph-2;前 2 行是全选/已选说明,其余按每卡 3 行开窗
|
|
369
|
+
const rows = Math.max(0, ph - 2)
|
|
370
|
+
const visible = Math.max(1, Math.floor(Math.max(0, rows - 2) / 3) || 1)
|
|
371
|
+
const view = windowInfo(state, cards.length, visible)
|
|
372
|
+
state.listPage = view.visible
|
|
373
|
+
const selected = cards[view.index]
|
|
374
|
+
const pos = cards.length ? ` · ${view.top + 1}-${view.end}/${cards.length}` : ''
|
|
375
|
+
const q = scrollPanel(`队列 ${cards.length}${state.marked.size ? ' ◉' + state.marked.size : ''}${pos}`, queueLines(cards, state, qw - 3, view), qw, ph, C.border, view)
|
|
321
376
|
const p = panel('判词', previewLines(selected, pw - 2), pw, ph, C.ember)
|
|
322
377
|
const ql = q.split('\n'), pl = p.split('\n')
|
|
323
378
|
const out = [typeRow(state, w), '']
|
|
@@ -336,19 +391,33 @@ function cardRow(card, selected, w, compact) {
|
|
|
336
391
|
|
|
337
392
|
function libraryView(state, w, h) {
|
|
338
393
|
const cards = filtered(state)
|
|
339
|
-
const
|
|
394
|
+
const inner = Math.max(1, w - 2)
|
|
395
|
+
// 右侧留 1 列给滚动条:正文宽度 = 内框宽度 - 1
|
|
396
|
+
const cw = Math.max(1, inner - 1)
|
|
397
|
+
const bodyRows = Math.max(0, h - 2)
|
|
340
398
|
const feature = cards[0] || CARDS[0]
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
399
|
+
const header = [
|
|
400
|
+
typeRow(state, cw), '',
|
|
401
|
+
searchRow(state, cw), '',
|
|
402
|
+
`${sgr(C.ember, '✦ 今日回看')} ${sgr(C.text, feature.title)}`,
|
|
403
|
+
` ${sgr(C.dim, feature.summary)}`,
|
|
404
|
+
` ${feature.tags.map((t) => tone(' #' + t + ' ', C.ember, C.chipBg)).join(' ')}`,
|
|
405
|
+
'', sgr(C.border, '─'.repeat(Math.max(1, cw))), '',
|
|
406
|
+
`${sgr(C.dim, '全部记忆')} ${sgr(C.faint, cards.length + ' 张 · 按 Enter 读卡')}`,
|
|
407
|
+
'',
|
|
408
|
+
]
|
|
409
|
+
const listRows = Math.max(0, bodyRows - header.length)
|
|
410
|
+
// 每张卡固定 2 行正文 + 1 行空行;只渲染这一屏
|
|
411
|
+
const visible = Math.max(1, Math.floor(listRows / 3) || 1)
|
|
412
|
+
const view = windowInfo(state, cards.length, visible)
|
|
413
|
+
state.listPage = view.visible
|
|
414
|
+
const lines = header.slice()
|
|
415
|
+
for (let i = view.top; i < view.end; i++) {
|
|
416
|
+
lines.push(...cardRow(cards[i], i === view.index, cw, true))
|
|
349
417
|
lines.push('')
|
|
350
|
-
}
|
|
351
|
-
|
|
418
|
+
}
|
|
419
|
+
const title = cards.length ? `记忆库 · ${view.top + 1}-${view.end}/${cards.length}` : '记忆库 · 0/0'
|
|
420
|
+
return [scrollPanel(title, lines, w, h, C.border, view)]
|
|
352
421
|
}
|
|
353
422
|
|
|
354
423
|
function readerView(state, w, h) {
|
|
@@ -478,6 +547,8 @@ function helpView(w, h) {
|
|
|
478
547
|
` ${keycap('1')}–${keycap('6')} 记忆库 / 待审 / 星图 / 状态 / 回收 / 设置`,
|
|
479
548
|
` ${keycap('Tab')} 切换页面`,
|
|
480
549
|
` ${keycap('↑↓')} ${keycap('jk')} 移动`,
|
|
550
|
+
` ${keycap('PgUp/PgDn')} 列表翻页(记忆库 / 待审)`,
|
|
551
|
+
` ${keycap('g/G')} 列表首 / 尾`,
|
|
481
552
|
` ${keycap('←→')} ${keycap('t')} 切换类型`,
|
|
482
553
|
` ${keycap('Enter')} 读卡 / 切换设置`,
|
|
483
554
|
` ${keycap('Space')} 多选 / 切换设置`,
|
|
@@ -501,16 +572,19 @@ function footer(state, w) {
|
|
|
501
572
|
if (state.screen === 'settings') return ' ' + [keycap('↑↓', '移动'), keycap('Enter', '切换'), keycap('Space', '切换'), keycap('Esc', '返回')].join(' ')
|
|
502
573
|
if (state.screen === 'recycle') return ' ' + [keycap('↑↓', '移动'), keycap('r', '恢复'), keycap('d', '删除')].join(' ')
|
|
503
574
|
if (state.screen === 'audit' && state.marked.size) return ' ' + [keycap('a', '批准 ' + state.marked.size), keycap('r', '驳回'), keycap('s', '反选'), keycap('Esc', '清空')].join(' ')
|
|
504
|
-
if (state.screen === 'audit') return ' ' + [keycap('↑↓', '移动'), keycap('←→', '类型'), keycap('Space', '多选'), keycap('s', '全选'), keycap('a', '批准'), keycap('r', '驳回'), keycap('Enter', '读卡'), keycap('1-6', '页面'), keycap('?', '帮助')].join(' ')
|
|
505
|
-
if (state.screen === 'library') return ' ' + [keycap('↑↓', '移动'), keycap('←→', '类型'), keycap('/', '搜索'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
|
|
575
|
+
if (state.screen === 'audit') return ' ' + [keycap('↑↓', '移动'), keycap('PgUp/PgDn', '翻页'), keycap('g/G', '首/尾'), keycap('←→', '类型'), keycap('Space', '多选'), keycap('s', '全选'), keycap('a', '批准'), keycap('r', '驳回'), keycap('Enter', '读卡'), keycap('1-6', '页面'), keycap('?', '帮助')].join(' ')
|
|
576
|
+
if (state.screen === 'library') return ' ' + [keycap('↑↓', '移动'), keycap('PgUp/PgDn', '翻页'), keycap('g/G', '首/尾'), keycap('←→', '类型'), keycap('/', '搜索'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
|
|
506
577
|
if (state.screen === 'graph') return ' ' + [keycap('↑↓', '切换卡片'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
|
|
507
578
|
if (state.screen === 'status') return ' ' + [keycap('1-6', '页面'), keycap('Tab', '切换'), keycap('?', '帮助')].join(' ')
|
|
508
579
|
return ' ' + [keycap('1-6', '页面'), keycap('Tab', '切换'), keycap('?', '帮助')].join(' ')
|
|
509
580
|
}
|
|
510
581
|
|
|
511
582
|
function frame(state) {
|
|
512
|
-
|
|
513
|
-
const
|
|
583
|
+
// 非 TTY(管道 / --once 重定向)时退化到 COLUMNS/LINES,便于自动化验收终端尺寸
|
|
584
|
+
const cols = Number(process.stdout.columns) || Number(process.env.COLUMNS) || 0
|
|
585
|
+
const rowsRaw = Number(process.stdout.rows) || Number(process.env.LINES) || 0
|
|
586
|
+
const w = Math.max(72, Math.min(useColor ? 140 : 120, cols || 120))
|
|
587
|
+
const rows = Math.max(24, rowsRaw || 36)
|
|
514
588
|
const screen = state.help ? 'help' : state.screen
|
|
515
589
|
const label = screen === 'help' ? '帮助' : SCREENS[screen]?.label || '记忆库'
|
|
516
590
|
const totalNow = state.screen === 'library' ? (state.libraryCards || state.cards).length : state.cards.length
|
|
@@ -615,6 +689,7 @@ async function loadLive(state) {
|
|
|
615
689
|
path: c.path,
|
|
616
690
|
}))
|
|
617
691
|
state.index = 0
|
|
692
|
+
state.listTop = 0
|
|
618
693
|
state.marked.clear()
|
|
619
694
|
if (selectAllAtStart) state.cards.forEach((c) => state.marked.add(c.id))
|
|
620
695
|
if (!state.cards.length) {
|
|
@@ -639,7 +714,12 @@ async function main() {
|
|
|
639
714
|
settings: SETTINGS.map((s) => ({ ...s })), settingsIndex: 0, help: screenArg === 'help', toast: null,
|
|
640
715
|
live: false, liveError: '', overview: null, libraryCards: CARDS.slice(), graphData: null, graphIndex: 0,
|
|
641
716
|
}
|
|
717
|
+
state.initialIndex = Number.isFinite(indexArg) && indexArg > 0 ? Math.floor(indexArg) : 0
|
|
718
|
+
state.listTop = 0
|
|
719
|
+
state.listPage = 1
|
|
642
720
|
if (liveMode) await loadLive(state)
|
|
721
|
+
// --index=N 在实时数据加载后仍要生效(loadLive 内部会把 index 归零)
|
|
722
|
+
if (state.initialIndex) state.index = state.initialIndex
|
|
643
723
|
if (once) { draw(state); return }
|
|
644
724
|
if (!process.stdout.isTTY) { process.stderr.write('请在有 TTY 的终端里运行;预览用 --once\n'); process.exitCode = 1; return }
|
|
645
725
|
|
|
@@ -695,6 +775,7 @@ async function main() {
|
|
|
695
775
|
const ids = new Set(targets.map((c) => c.id))
|
|
696
776
|
state.cards = state.cards.filter((c) => !ids.has(c.id))
|
|
697
777
|
state.index = 0
|
|
778
|
+
state.listTop = 0
|
|
698
779
|
state.marked.clear()
|
|
699
780
|
toast(`${decision === 'a' ? '✓ 已批准' : '✕ 已驳回'} ${targets.length} 张:${targets[0].title}`, decision === 'a')
|
|
700
781
|
}
|
|
@@ -725,6 +806,7 @@ async function main() {
|
|
|
725
806
|
const i = ids.indexOf(state.screen === 'reader' ? state.readerBack : state.screen)
|
|
726
807
|
state.screen = ids[(i + dir + ids.length) % ids.length]
|
|
727
808
|
state.index = 0
|
|
809
|
+
state.listTop = 0
|
|
728
810
|
draw(state)
|
|
729
811
|
}
|
|
730
812
|
|
|
@@ -745,7 +827,7 @@ async function main() {
|
|
|
745
827
|
if (key.name === 'tab') { cycle(key.shift ? -1 : 1); return }
|
|
746
828
|
if (/^[1-6]$/.test(str)) {
|
|
747
829
|
const id = ['library', 'audit', 'graph', 'status', 'recycle', 'settings'][Number(str) - 1]
|
|
748
|
-
state.screen = id; state.index = 0; draw(state); return
|
|
830
|
+
state.screen = id; state.index = 0; state.listTop = 0; draw(state); return
|
|
749
831
|
}
|
|
750
832
|
if (state.screen === 'reader') {
|
|
751
833
|
if (key.name === 'escape') { state.screen = state.readerBack; draw(state); return }
|
|
@@ -790,22 +872,28 @@ async function main() {
|
|
|
790
872
|
if (str === 't' || key.name === 'right' || str === 'l') {
|
|
791
873
|
const keys = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt']
|
|
792
874
|
state.kind = keys[(keys.indexOf(state.kind) + 1) % keys.length]
|
|
793
|
-
state.index = 0; state.marked.clear(); draw(state); return
|
|
875
|
+
state.index = 0; state.listTop = 0; state.marked.clear(); draw(state); return
|
|
794
876
|
}
|
|
795
877
|
if (key.name === 'left' || str === 'h') {
|
|
796
878
|
const keys = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt']
|
|
797
879
|
state.kind = keys[(keys.indexOf(state.kind) - 1 + keys.length) % keys.length]
|
|
798
|
-
state.index = 0; state.marked.clear(); draw(state); return
|
|
880
|
+
state.index = 0; state.listTop = 0; state.marked.clear(); draw(state); return
|
|
799
881
|
}
|
|
800
882
|
if (state.screen === 'audit' && (str === 's' || (key.ctrl && key.name === 'a'))) {
|
|
801
883
|
const all = cards.length > 0 && cards.every((c) => state.marked.has(c.id))
|
|
802
884
|
cards.forEach((c) => all ? state.marked.delete(c.id) : state.marked.add(c.id))
|
|
803
885
|
draw(state); return
|
|
804
886
|
}
|
|
887
|
+
// 列表翻页:一次走一屏(state.listPage 由 libraryView / auditView 渲染时写入)
|
|
888
|
+
const pageStep = Math.max(1, state.listPage || 1)
|
|
889
|
+
if (key.name === 'pageup') { state.index = Math.max(0, state.index - pageStep); draw(state); return }
|
|
890
|
+
if (key.name === 'pagedown') { state.index = Math.min(Math.max(0, cards.length - 1), state.index + pageStep); draw(state); return }
|
|
891
|
+
if (str === 'g') { state.index = 0; draw(state); return }
|
|
892
|
+
if (str === 'G') { state.index = Math.max(0, cards.length - 1); draw(state); return }
|
|
805
893
|
if (key.name === 'up' || str === 'k') { state.index = Math.max(0, state.index - 1); draw(state); return }
|
|
806
894
|
if (key.name === 'down' || str === 'j') { state.index = Math.min(Math.max(0, cards.length - 1), state.index + 1); draw(state); return }
|
|
807
895
|
if (key.name === 'return' || key.name === 'enter') { openReader(); return }
|
|
808
|
-
if (key.name === 'escape') { state.query = ''; state.kind = 'all'; state.marked.clear(); state.index = 0; draw(state); return }
|
|
896
|
+
if (key.name === 'escape') { state.query = ''; state.kind = 'all'; state.marked.clear(); state.index = 0; state.listTop = 0; draw(state); return }
|
|
809
897
|
if (state.screen === 'audit' && (key.name === 'space' || str === ' ')) {
|
|
810
898
|
const c = cards[state.index]; if (c) { state.marked.has(c.id) ? state.marked.delete(c.id) : state.marked.add(c.id) } draw(state); return
|
|
811
899
|
}
|