@weiping/wingline 0.0.0-stage → 0.1.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/dist/audit-table.d.ts +7 -0
  3. package/dist/audit-table.js +68 -0
  4. package/dist/cli.d.ts +61 -0
  5. package/dist/cli.js +328 -0
  6. package/dist/console.d.ts +33 -0
  7. package/dist/console.js +136 -0
  8. package/dist/main.d.ts +2 -0
  9. package/dist/main.js +11 -0
  10. package/dist/output.d.ts +80 -0
  11. package/dist/output.js +335 -0
  12. package/dist/tui/app.d.ts +28 -0
  13. package/dist/tui/app.js +259 -0
  14. package/dist/tui/command.d.ts +16 -0
  15. package/dist/tui/command.js +35 -0
  16. package/dist/tui/input.d.ts +14 -0
  17. package/dist/tui/input.js +67 -0
  18. package/dist/tui/items.d.ts +15 -0
  19. package/dist/tui/items.js +137 -0
  20. package/dist/tui/keys.d.ts +6 -0
  21. package/dist/tui/keys.js +41 -0
  22. package/dist/tui/reduce.d.ts +10 -0
  23. package/dist/tui/reduce.js +127 -0
  24. package/dist/tui/render.d.ts +7 -0
  25. package/dist/tui/render.js +71 -0
  26. package/dist/tui/screen.d.ts +25 -0
  27. package/dist/tui/screen.js +52 -0
  28. package/dist/tui/text.d.ts +8 -0
  29. package/dist/tui/text.js +28 -0
  30. package/dist/tui/types.d.ts +86 -0
  31. package/dist/tui/types.js +1 -0
  32. package/dist/tui/views/inbox.d.ts +4 -0
  33. package/dist/tui/views/inbox.js +23 -0
  34. package/dist/tui/views/overview.d.ts +6 -0
  35. package/dist/tui/views/overview.js +13 -0
  36. package/dist/tui/views/picker.d.ts +4 -0
  37. package/dist/tui/views/picker.js +44 -0
  38. package/dist/tui/views/timeline.d.ts +4 -0
  39. package/dist/tui/views/timeline.js +11 -0
  40. package/dist/tui/width.d.ts +4 -0
  41. package/dist/tui/width.js +45 -0
  42. package/dist/verified.json +1 -0
  43. package/dist/web/assets/index-DLO1-OIY.css +1 -0
  44. package/dist/web/assets/index-DkiJcfz3.js +2 -0
  45. package/dist/web/index.html +14 -0
  46. package/package.json +34 -3
  47. package/README.md +0 -3
@@ -0,0 +1,259 @@
1
+ import { WinglineError, attention, auditedWriteCommands, cachedOrca, listTasks, ps, readTimeline, readTimelineAfter, } from '@weiping/wingline-core';
2
+ import { parseKeys } from './keys.js';
3
+ import { reduce, initialTuiState } from './reduce.js';
4
+ import { renderView } from './render.js';
5
+ import { Screen } from './screen.js';
6
+ export { initialTuiState, reduce, renderView };
7
+ const TIMELINE_KEEP = 500;
8
+ const LOADING = '加载中…';
9
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
10
+ const emptyAttention = (team) => ({
11
+ team, dispatcher: { state: 'never' }, frozen: [], signals: [], questions: [], stale: [], unavailable: [], warnings: [],
12
+ });
13
+ function summaryOf(result) {
14
+ const r = result;
15
+ if (r?.duplicate === true)
16
+ return '已完成(该请求此前已处理过)';
17
+ return typeof r?.outcome === 'string' ? `已完成(${r.outcome})` : '已完成';
18
+ }
19
+ /** 一次写操作失败:只改状态行。结果不确定且带 retryRequest 时记下来,按 R 以同样的参数重试。 */
20
+ function failure(action, err) {
21
+ if (err instanceof WinglineError) {
22
+ const hint = err.hint ? `(${err.hint})` : '';
23
+ const retryRequest = err.detail?.retryRequest;
24
+ if (err.kind === 'uncertain' && typeof retryRequest === 'string') {
25
+ return { lastUncertain: { ...action, args: { ...action.args, retryRequest } }, status: `${err.message}${hint};按 R 原样重试` };
26
+ }
27
+ return { status: `${action.label}失败:${err.message}${hint}` };
28
+ }
29
+ return { status: `${action.label}失败:${err instanceof Error ? err.message : String(err)}` };
30
+ }
31
+ /**
32
+ * 一次 wl tui 会话:进程内直接调用 core,不依赖控制台服务。读取失败只标“数据过期”;
33
+ * 其他异常先恢复终端(退出备用屏幕、显示光标、关闭 raw 模式)再 reject。
34
+ */
35
+ class TuiSession {
36
+ deps;
37
+ team;
38
+ io;
39
+ o;
40
+ done;
41
+ screen;
42
+ cached;
43
+ cdeps;
44
+ read;
45
+ commands;
46
+ state = { ...initialTuiState(), status: LOADING };
47
+ data;
48
+ cursor = null;
49
+ /** 正在进行的读取与“进行中又被要求刷新”的标记:同一种读取不并发,被挤掉的请求在读完后补跑一次。 */
50
+ busy = { data: false, timeline: false };
51
+ rerun = { data: false, timeline: false };
52
+ finished = false;
53
+ timers = [];
54
+ constructor(deps, team, io, o, done) {
55
+ this.deps = deps;
56
+ this.team = team;
57
+ this.io = io;
58
+ this.o = o;
59
+ this.done = done;
60
+ this.screen = new Screen(io.stdout);
61
+ this.cached = cachedOrca(deps.orca);
62
+ this.cdeps = { ...deps, orca: this.cached };
63
+ this.read = { ps, attention, listTasks, readTimeline, readTimelineAfter, ...o.read };
64
+ this.commands = o.commands ?? auditedWriteCommands('tui:');
65
+ this.data = { team, posts: [], attention: emptyAttention(team), tasks: [], timeline: [], hushed: [] };
66
+ }
67
+ // —— 生命周期 ——
68
+ start() {
69
+ try {
70
+ process.on('exit', this.restore);
71
+ process.on('uncaughtException', this.onFatal);
72
+ for (const sig of SIGNALS)
73
+ process.on(sig, this.onStop);
74
+ this.io.stdin.setRawMode?.(true);
75
+ this.io.stdin.setEncoding?.('utf8');
76
+ this.io.stdin.resume?.();
77
+ this.screen.enter();
78
+ this.io.stdin.on('data', this.onData);
79
+ this.io.stdout.on('resize', this.onResize);
80
+ this.o.signal?.addEventListener('abort', this.onStop, { once: true });
81
+ this.draw();
82
+ this.startLoops();
83
+ if (this.o.signal?.aborted)
84
+ this.finish();
85
+ }
86
+ catch (err) {
87
+ this.finish(err);
88
+ }
89
+ }
90
+ startLoops() {
91
+ this.timers.push(setInterval(this.guard(() => this.refreshData()), this.o.dataMs ?? 5000), setInterval(this.guard(() => this.refreshTimeline()), this.o.timelineMs ?? 2000));
92
+ this.guard(() => this.refreshData())();
93
+ this.guard(() => this.refreshTimeline())();
94
+ }
95
+ restore = () => {
96
+ this.screen.restore();
97
+ try {
98
+ this.io.stdin.setRawMode?.(false);
99
+ }
100
+ catch {
101
+ // 终端已经没了
102
+ }
103
+ this.io.stdin.pause?.();
104
+ };
105
+ finish(err) {
106
+ if (this.finished)
107
+ return;
108
+ this.finished = true;
109
+ this.timers.forEach(clearInterval);
110
+ this.io.stdin.off('data', this.onData);
111
+ this.io.stdout.off('resize', this.onResize);
112
+ process.off('exit', this.restore);
113
+ process.off('uncaughtException', this.onFatal);
114
+ for (const sig of SIGNALS)
115
+ process.off(sig, this.onStop);
116
+ this.o.signal?.removeEventListener('abort', this.onStop);
117
+ this.restore();
118
+ if (err === undefined)
119
+ this.done.resolve();
120
+ else
121
+ this.done.reject(err);
122
+ }
123
+ /** 回调里抛出的异常(同步或异步)一律走 finish:先恢复终端再 reject。 */
124
+ guard(fn) {
125
+ return (...a) => {
126
+ try {
127
+ const r = fn(...a);
128
+ if (r instanceof Promise)
129
+ r.catch((e) => this.finish(e));
130
+ }
131
+ catch (err) {
132
+ this.finish(err);
133
+ }
134
+ };
135
+ }
136
+ onStop = () => this.finish();
137
+ onFatal = (err) => {
138
+ process.off('uncaughtException', this.onFatal);
139
+ this.restore();
140
+ throw err;
141
+ };
142
+ // —— 画面与输入 ——
143
+ draw() {
144
+ if (this.finished)
145
+ return;
146
+ const lines = renderView(this.state, this.data, this.screen.size());
147
+ this.o.onFrame?.(lines);
148
+ this.screen.render(lines);
149
+ }
150
+ onData = this.guard((chunk) => {
151
+ for (const k of parseKeys(String(chunk))) {
152
+ const r = reduce(this.state, k, this.data);
153
+ this.state = r.state;
154
+ if (r.effect)
155
+ this.run(r.effect);
156
+ }
157
+ this.draw();
158
+ });
159
+ onResize = this.guard(() => {
160
+ this.screen.invalidate();
161
+ this.draw();
162
+ });
163
+ run(effect) {
164
+ if (effect.kind === 'quit')
165
+ return this.finish();
166
+ if (effect.kind === 'refresh')
167
+ return void this.refreshAll().catch((e) => this.finish(e));
168
+ void this.execute(effect.action).catch((e) => this.finish(e));
169
+ }
170
+ // —— 读取 ——
171
+ refreshAll() {
172
+ return Promise.all([this.refreshData(), this.refreshTimeline()]);
173
+ }
174
+ markStale() {
175
+ this.state = { ...this.state, stale: true };
176
+ this.draw();
177
+ }
178
+ /** 读取失败只标过期;处理结果时抛出的异常是 bug,交给 guard 走 finish。 */
179
+ refreshData() {
180
+ return this.exclusive('data', () => this.loadData());
181
+ }
182
+ refreshTimeline() {
183
+ return this.exclusive('timeline', () => this.loadTimeline());
184
+ }
185
+ async exclusive(kind, load) {
186
+ if (this.busy[kind]) {
187
+ this.rerun[kind] = true;
188
+ return;
189
+ }
190
+ this.busy[kind] = true;
191
+ try {
192
+ await load();
193
+ }
194
+ finally {
195
+ this.busy[kind] = false;
196
+ }
197
+ if (this.rerun[kind]) {
198
+ this.rerun[kind] = false;
199
+ await this.exclusive(kind, load);
200
+ }
201
+ }
202
+ async loadData() {
203
+ const { team, cdeps, read } = this;
204
+ let loaded;
205
+ try {
206
+ const [posts, att, tasks] = await Promise.all([read.ps(cdeps, { team }), read.attention(cdeps, team), read.listTasks(cdeps, { team })]);
207
+ loaded = { posts, attention: att, tasks };
208
+ }
209
+ catch {
210
+ return this.markStale();
211
+ }
212
+ const now = this.deps.now().getTime();
213
+ const hushed = loaded.posts.map((p) => p.address).filter((a) => Date.parse(this.deps.registry.hushedUntil(a) ?? '') > now);
214
+ this.data = { ...this.data, ...loaded, hushed };
215
+ this.state = { ...this.state, stale: false, status: this.state.status === LOADING ? '' : this.state.status };
216
+ this.draw();
217
+ }
218
+ async loadTimeline() {
219
+ const { team, cdeps, read } = this;
220
+ let events;
221
+ try {
222
+ if (this.cursor === null) {
223
+ const first = await read.readTimeline(cdeps, { team, limit: 200 });
224
+ this.cursor = first.cursor;
225
+ events = first.events;
226
+ }
227
+ else {
228
+ const more = await read.readTimelineAfter(cdeps, team, this.cursor);
229
+ this.cursor = more.cursor;
230
+ events = [...this.data.timeline, ...more.events];
231
+ }
232
+ }
233
+ catch {
234
+ return this.markStale();
235
+ }
236
+ const wasAtEnd = this.state.view === 'timeline' && this.state.selected >= this.data.timeline.length - 1;
237
+ this.data = { ...this.data, timeline: events.slice(-TIMELINE_KEEP) };
238
+ if (wasAtEnd)
239
+ this.state = { ...this.state, selected: Math.max(0, this.data.timeline.length - 1) };
240
+ this.draw();
241
+ }
242
+ // —— 写操作 ——
243
+ async execute(action) {
244
+ try {
245
+ const result = await this.commands[action.command](this.cdeps, action.args);
246
+ this.state = { ...this.state, status: `${action.label}:${summaryOf(result)}`, lastUncertain: null };
247
+ }
248
+ catch (err) {
249
+ this.state = { ...this.state, ...failure(action, err) };
250
+ }
251
+ this.cached.invalidate();
252
+ this.draw();
253
+ await this.refreshAll();
254
+ }
255
+ }
256
+ /** 全屏终端视图。结束(q / Ctrl-C / 信号 / abort)时 resolve;出错时先恢复终端再 reject。 */
257
+ export function runTui(deps, team, io, o = {}) {
258
+ return new Promise((resolve, reject) => new TuiSession(deps, team, io, o, { resolve, reject }).start());
259
+ }
@@ -0,0 +1,16 @@
1
+ import { type CommandDeps } from '@weiping/wingline-core';
2
+ import { runTui } from './app.js';
3
+ import type { TuiIo } from './types.js';
4
+ import { pickTeam } from './views/picker.js';
5
+ export interface TuiCmdArgs {
6
+ team?: string;
7
+ io: TuiIo;
8
+ /** 以下几项只在测试里替换。 */
9
+ platform?: NodeJS.Platform;
10
+ env?: NodeJS.ProcessEnv;
11
+ warn?: (message: string) => void;
12
+ run?: typeof runTui;
13
+ pick?: typeof pickTeam;
14
+ }
15
+ /** `wl tui [team]`:检查终端,选定团队,进入全屏视图。 */
16
+ export declare function tuiCmd(deps: CommandDeps, a: TuiCmdArgs): Promise<Record<string, never>>;
@@ -0,0 +1,35 @@
1
+ import { WinglineError } from '@weiping/wingline-core';
2
+ import { runTui } from './app.js';
3
+ import { pickTeam } from './views/picker.js';
4
+ function chooseTeams(deps, name) {
5
+ if (name !== undefined) {
6
+ const t = deps.registry.getTeam(name);
7
+ if (!t)
8
+ throw new WinglineError('input', 'team_unknown', `团队 ${name} 未登记`, { hint: '先执行 wl up <team.yaml>' });
9
+ return [t];
10
+ }
11
+ const teams = deps.registry.listTeams();
12
+ if (teams.length === 0)
13
+ throw new WinglineError('input', 'team_unknown', '还没有登记任何团队', { hint: '先执行 wl up <team.yaml>' });
14
+ return teams;
15
+ }
16
+ /** `wl tui [team]`:检查终端,选定团队,进入全屏视图。 */
17
+ export async function tuiCmd(deps, a) {
18
+ if (!a.io.stdout.isTTY || !a.io.stdin.isTTY) {
19
+ throw new WinglineError('input', 'tui_not_tty', 'wl tui 需要在交互式终端里运行', { hint: '直接在终端里运行 wl tui,输入输出都不要重定向' });
20
+ }
21
+ if (!(a.io.stdout.columns > 0)) {
22
+ throw new WinglineError('input', 'tui_unsupported_terminal', '终端没有报告窗口宽度,无法布局', { hint: '换一个终端模拟器,或改用 wl attention / wl log' });
23
+ }
24
+ const env = a.env ?? deps.env;
25
+ if ((a.platform ?? process.platform) === 'win32' && !env.WT_SESSION && !env.TERM_PROGRAM) {
26
+ ;
27
+ (a.warn ?? ((m) => process.stderr.write(`警告:${m}\n`)))('当前终端可能不支持 VT 序列,显示异常请改用 Windows Terminal');
28
+ }
29
+ const teams = chooseTeams(deps, a.team);
30
+ const name = teams.length === 1 ? teams[0].name : await (a.pick ?? pickTeam)(teams, a.io);
31
+ if (name === null)
32
+ return {};
33
+ await (a.run ?? runTui)(deps, name, a.io, {});
34
+ return {};
35
+ }
@@ -0,0 +1,14 @@
1
+ import type { Key } from './keys.js';
2
+ /** 底部单行输入框:可打印字符、退格、左右移动;Enter 提交,Esc 取消。光标画成 `|`,不用任何终端样式。 */
3
+ export declare class LineInput {
4
+ private readonly prompt;
5
+ private chars;
6
+ private pos;
7
+ constructor(prompt: string);
8
+ get value(): string;
9
+ /** reduce 是纯函数:按键作用在副本上。 */
10
+ clone(): LineInput;
11
+ feed(k: Key): 'continue' | 'submit' | 'cancel';
12
+ /** 一行 cols 列:放不下时让光标附近的文字留在可见区里,被截的左侧以 … 开头。 */
13
+ view(cols: number): string;
14
+ }
@@ -0,0 +1,67 @@
1
+ import { cellWidth, fit } from './width.js';
2
+ const PRINTABLE = /^[^\x00-\x1f\x7f-\x9f]+$/;
3
+ /** 底部单行输入框:可打印字符、退格、左右移动;Enter 提交,Esc 取消。光标画成 `|`,不用任何终端样式。 */
4
+ export class LineInput {
5
+ prompt;
6
+ chars = [];
7
+ pos = 0;
8
+ constructor(prompt) {
9
+ this.prompt = prompt;
10
+ }
11
+ get value() {
12
+ return this.chars.join('');
13
+ }
14
+ /** reduce 是纯函数:按键作用在副本上。 */
15
+ clone() {
16
+ const c = new LineInput(this.prompt);
17
+ c.chars = [...this.chars];
18
+ c.pos = this.pos;
19
+ return c;
20
+ }
21
+ feed(k) {
22
+ if (k.name === 'enter')
23
+ return 'submit';
24
+ if (k.name === 'escape')
25
+ return 'cancel';
26
+ if (k.name === 'left')
27
+ this.pos = Math.max(0, this.pos - 1);
28
+ else if (k.name === 'right')
29
+ this.pos = Math.min(this.chars.length, this.pos + 1);
30
+ else if (k.name === 'backspace' && this.pos > 0)
31
+ this.chars.splice(--this.pos, 1);
32
+ else if (k.name === 'char' && k.ch && PRINTABLE.test(k.ch))
33
+ this.chars.splice(this.pos++, 0, k.ch);
34
+ return 'continue';
35
+ }
36
+ /** 一行 cols 列:放不下时让光标附近的文字留在可见区里,被截的左侧以 … 开头。 */
37
+ view(cols) {
38
+ const before = this.prompt + this.chars.slice(0, this.pos).join('');
39
+ const after = this.chars.slice(this.pos).join('');
40
+ if (cellWidth(before) + 1 + cellWidth(after) <= cols)
41
+ return fit(`${before}|${after}`, cols);
42
+ const room = Math.max(0, cols - 1);
43
+ const afterShown = takeHead(after, Math.floor(room / 3));
44
+ const beforeShown = takeTail(before, room - cellWidth(afterShown) - 1);
45
+ return fit(`…${beforeShown}|${afterShown}`, cols);
46
+ }
47
+ }
48
+ /** 从头取尽量多的字符,宽度不超过 budget。 */
49
+ function takeHead(s, budget) {
50
+ let out = '';
51
+ for (const ch of s) {
52
+ if (cellWidth(out + ch) > budget)
53
+ break;
54
+ out += ch;
55
+ }
56
+ return out;
57
+ }
58
+ /** 从尾取尽量多的字符,宽度不超过 budget。 */
59
+ function takeTail(s, budget) {
60
+ let out = '';
61
+ for (const ch of [...s].reverse()) {
62
+ if (cellWidth(ch + out) > budget)
63
+ break;
64
+ out = ch + out;
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1,15 @@
1
+ import { type TimelineEvent } from '@weiping/wingline-core';
2
+ import type { MenuItem, TimelineFilterSpec, TuiData, TuiState, View } from './types.js';
3
+ /** 视图里一行可选中的条目:显示文字、能做的写操作;没有写操作的给出对应的 wl 命令。 */
4
+ export interface Row {
5
+ section?: string;
6
+ text: string;
7
+ actions: MenuItem[];
8
+ hint?: string;
9
+ }
10
+ /** 事件是否命中过滤条件(岗位按名字或完整地址匹配 actor / target)。 */
11
+ export declare function matchesFilter(e: TimelineEvent, f: TimelineFilterSpec | null): boolean;
12
+ export declare const eventLine: (e: TimelineEvent) => string;
13
+ export declare function filterText(f: TimelineFilterSpec | null): string;
14
+ /** 当前视图里可选中的行;选中下标和 Enter 都以它为准。 */
15
+ export declare function rowsOf(view: View, s: Pick<TuiState, 'filter'>, data: TuiData): Row[];
@@ -0,0 +1,137 @@
1
+ import { postAddress, STALE_REASON_LABEL } from '@weiping/wingline-core';
2
+ import { renderTimelineEvent } from '../output.js';
3
+ import { cleanEvent, oneLine } from './text.js';
4
+ import { fit } from './width.js';
5
+ const action = (command, args, label) => ({ command, args, label });
6
+ const sendItem = (address) => ({
7
+ label: '发消息',
8
+ steps: [{ prompt: '消息: ' }],
9
+ build: ([message]) => action('send', { address, message: message }, `给 ${address} 发消息`),
10
+ });
11
+ const freezeItem = (address) => ({
12
+ label: '冻结',
13
+ steps: [{ prompt: '冻结理由: ' }],
14
+ build: ([reason]) => action('freezePostCmd', { address, reason: reason }, `冻结 ${address}`),
15
+ });
16
+ const unfreezeItem = (address) => ({
17
+ label: '解冻', steps: [], build: () => action('unfreezePostCmd', { address }, `解冻 ${address}`),
18
+ });
19
+ const hushItem = (address) => ({
20
+ label: '静音',
21
+ steps: [{ prompt: '静音分钟数(留空为默认): ', optional: true }],
22
+ build: ([minutes]) => {
23
+ const text = (minutes ?? '').trim();
24
+ if (text === '')
25
+ return action('hushCmd', { address }, `静音 ${address}`);
26
+ const n = Number(text);
27
+ if (!Number.isFinite(n) || n <= 0)
28
+ return '分钟数应为正数';
29
+ return action('hushCmd', { address, forMs: n * 60_000 }, `静音 ${address} ${text} 分钟`);
30
+ },
31
+ });
32
+ const unhushItem = (address) => ({
33
+ label: '取消静音', steps: [], build: () => action('unhushCmd', { address }, `取消 ${address} 的静音`),
34
+ });
35
+ const replyItem = (team, messageId, asker) => ({
36
+ label: '回答',
37
+ steps: [{ prompt: '回答: ' }],
38
+ build: ([body]) => action('dispatcherReply', { team, messageId, body: body }, `回答 ${asker} 的提问`),
39
+ });
40
+ const resolveItem = (gateId) => ({
41
+ label: '处理',
42
+ steps: [{ prompt: '决定: ' }],
43
+ build: ([resolution]) => action('resolveGate', { gateId, resolution: resolution }, `处理 gate ${gateId}`),
44
+ });
45
+ const resolveReassignItem = (gateId, team) => ({
46
+ label: '处理并改派',
47
+ steps: [{ prompt: '决定: ' }, { prompt: '改派给岗位: ' }],
48
+ build: ([resolution, post]) => {
49
+ const target = post.includes('@') ? post : postAddress(post.trim(), team);
50
+ return action('resolveGate', { gateId, resolution: resolution, reassign: target }, `处理 gate ${gateId} 并改派给 ${target}`);
51
+ },
52
+ });
53
+ const recoverItem = (taskId) => ({
54
+ label: '恢复派发', steps: [], build: () => action('recoverTask', { id: taskId }, `恢复任务 ${taskId} 的派发`),
55
+ });
56
+ function postRows(data) {
57
+ const frozen = new Set(data.attention.frozen.map((f) => f.post));
58
+ const hushed = new Set(data.hushed);
59
+ return data.posts.map((p) => {
60
+ const task = data.tasks.find((t) => t.status === 'dispatched' && p.handle !== null && t.assigneeHandle === p.handle);
61
+ const flags = [
62
+ frozen.has(p.address) ? '[冻结]' : '',
63
+ hushed.has(p.address) ? '[静音]' : '',
64
+ ...data.attention.signals.filter((s) => s.post === p.address).map((s) => `[${s.signal} L${s.level}]`),
65
+ ].filter(Boolean);
66
+ return {
67
+ text: [fit(oneLine(p.address), 20), fit(oneLine(p.agent), 7), fit(p.status, 7), fit(task ? oneLine(task.title) : '—', 16), ...flags].join(' ').trimEnd(),
68
+ actions: [
69
+ sendItem(p.address),
70
+ frozen.has(p.address) ? unfreezeItem(p.address) : freezeItem(p.address),
71
+ hushed.has(p.address) ? unhushItem(p.address) : hushItem(p.address),
72
+ ],
73
+ };
74
+ });
75
+ }
76
+ function inboxRows(data) {
77
+ const a = data.attention;
78
+ const gates = a.stale.filter((s) => s.reason === 'blocked_waiting');
79
+ const rest = a.stale.filter((s) => s.reason !== 'blocked_waiting');
80
+ return [
81
+ ...a.questions.map((q) => ({
82
+ section: '待回答的提问',
83
+ text: `${oneLine(q.asker)}:${oneLine(q.body)}${q.options.length ? ` 选项:${q.options.map(oneLine).join(' / ')}` : ''}`,
84
+ actions: [replyItem(a.team, q.messageId, oneLine(q.asker))],
85
+ })),
86
+ ...gates.map((s) => {
87
+ const gateId = s.gateId;
88
+ return {
89
+ section: '等待决策的 gate',
90
+ text: `${oneLine(s.title)} 归属 ${s.owner ? oneLine(s.owner) : '—'}`,
91
+ actions: gateId ? [resolveItem(gateId), resolveReassignItem(gateId, a.team)] : [],
92
+ hint: s.command,
93
+ };
94
+ }),
95
+ ...a.frozen.map((f) => ({ section: '冻结的岗位', text: `${oneLine(f.post)} ${oneLine(f.reason)}`, actions: [unfreezeItem(f.post)] })),
96
+ ...rest.map((s) => ({
97
+ section: '卡住的任务',
98
+ text: `${oneLine(s.title)} ${STALE_REASON_LABEL[s.reason]} 主人 ${s.owner ? oneLine(s.owner) : '—'}`,
99
+ actions: s.reason === 'dead_assignee' ? [recoverItem(s.taskId)] : [],
100
+ hint: s.command,
101
+ })),
102
+ ...a.signals.map((s) => ({
103
+ section: '巡查信号',
104
+ text: `${oneLine(s.post)} ${s.signal} L${s.level}${s.summary ? ` ${oneLine(s.summary)}` : ''}`,
105
+ actions: [hushItem(s.post), sendItem(s.post)],
106
+ hint: s.command ?? undefined,
107
+ })),
108
+ ];
109
+ }
110
+ /** 事件是否命中过滤条件(岗位按名字或完整地址匹配 actor / target)。 */
111
+ export function matchesFilter(e, f) {
112
+ if (!f)
113
+ return true;
114
+ if (f.source && e.source !== f.source)
115
+ return false;
116
+ if (f.post) {
117
+ const hit = [e.actor, e.target].some((x) => x !== null && (x === f.post || x.split('@')[0] === f.post));
118
+ if (!hit)
119
+ return false;
120
+ }
121
+ return !f.task || e.target === f.task || (e.target?.startsWith(`${f.task}#`) ?? false);
122
+ }
123
+ export const eventLine = (e) => oneLine(renderTimelineEvent(cleanEvent(e)));
124
+ export function filterText(f) {
125
+ if (!f)
126
+ return '';
127
+ return Object.entries(f).map(([k, v]) => `${k}:${v}`).join(' ');
128
+ }
129
+ function timelineRows(data, filter) {
130
+ return data.timeline.filter((e) => matchesFilter(e, filter)).map((e) => ({ text: eventLine(e), actions: [] }));
131
+ }
132
+ /** 当前视图里可选中的行;选中下标和 Enter 都以它为准。 */
133
+ export function rowsOf(view, s, data) {
134
+ if (view === 'overview')
135
+ return postRows(data);
136
+ return view === 'inbox' ? inboxRows(data) : timelineRows(data, s.filter);
137
+ }
@@ -0,0 +1,6 @@
1
+ export type Key = {
2
+ name: 'up' | 'down' | 'enter' | 'tab' | 'escape' | 'backspace' | 'left' | 'right' | 'ctrl-c' | 'char';
3
+ ch?: string;
4
+ };
5
+ /** 把一次 stdin 数据拆成按键。认不出的转义序列整条丢掉,不会当成输入打出来。 */
6
+ export declare function parseKeys(chunk: string): Key[];
@@ -0,0 +1,41 @@
1
+ const ARROWS = {
2
+ '\x1b[A': 'up', '\x1b[B': 'down', '\x1b[C': 'right', '\x1b[D': 'left',
3
+ '\x1bOA': 'up', '\x1bOB': 'down', '\x1bOC': 'right', '\x1bOD': 'left',
4
+ };
5
+ /** CSI(ESC [ 参数 中间字节 终止字节)与 SS3(ESC O x)。 */
6
+ const SEQUENCE = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O[@-~])/;
7
+ /** 把一次 stdin 数据拆成按键。认不出的转义序列整条丢掉,不会当成输入打出来。 */
8
+ export function parseKeys(chunk) {
9
+ const keys = [];
10
+ let i = 0;
11
+ while (i < chunk.length) {
12
+ const c = chunk[i];
13
+ if (c === '\x1b') {
14
+ if (i === chunk.length - 1) {
15
+ keys.push({ name: 'escape' });
16
+ break;
17
+ }
18
+ const seq = SEQUENCE.exec(chunk.slice(i));
19
+ const arrow = seq ? ARROWS[seq[0]] : undefined;
20
+ if (arrow)
21
+ keys.push({ name: arrow });
22
+ i += seq ? seq[0].length : 2;
23
+ continue;
24
+ }
25
+ i += 1;
26
+ if (c === '\r' || c === '\n')
27
+ keys.push({ name: 'enter' });
28
+ else if (c === '\t')
29
+ keys.push({ name: 'tab' });
30
+ else if (c === '\x7f' || c === '\b')
31
+ keys.push({ name: 'backspace' });
32
+ else if (c === '\x03')
33
+ keys.push({ name: 'ctrl-c' });
34
+ else if (c >= ' ') {
35
+ const ch = String.fromCodePoint(chunk.codePointAt(i - 1));
36
+ i += ch.length - 1;
37
+ keys.push({ name: 'char', ch });
38
+ }
39
+ }
40
+ return keys;
41
+ }
@@ -0,0 +1,10 @@
1
+ import type { Key } from './keys.js';
2
+ import type { Effect, TuiData, TuiState } from './types.js';
3
+ export declare function initialTuiState(): TuiState;
4
+ type Out = {
5
+ state: TuiState;
6
+ effect?: Effect;
7
+ };
8
+ /** 按键状态机(纯函数):返回新状态,以及需要外部执行的动作(写操作 / 刷新 / 退出)。 */
9
+ export declare function reduce(s: TuiState, k: Key, data: TuiData): Out;
10
+ export {};