@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.
- package/LICENSE +21 -0
- package/dist/audit-table.d.ts +7 -0
- package/dist/audit-table.js +68 -0
- package/dist/cli.d.ts +61 -0
- package/dist/cli.js +328 -0
- package/dist/console.d.ts +33 -0
- package/dist/console.js +136 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +11 -0
- package/dist/output.d.ts +80 -0
- package/dist/output.js +335 -0
- package/dist/tui/app.d.ts +28 -0
- package/dist/tui/app.js +259 -0
- package/dist/tui/command.d.ts +16 -0
- package/dist/tui/command.js +35 -0
- package/dist/tui/input.d.ts +14 -0
- package/dist/tui/input.js +67 -0
- package/dist/tui/items.d.ts +15 -0
- package/dist/tui/items.js +137 -0
- package/dist/tui/keys.d.ts +6 -0
- package/dist/tui/keys.js +41 -0
- package/dist/tui/reduce.d.ts +10 -0
- package/dist/tui/reduce.js +127 -0
- package/dist/tui/render.d.ts +7 -0
- package/dist/tui/render.js +71 -0
- package/dist/tui/screen.d.ts +25 -0
- package/dist/tui/screen.js +52 -0
- package/dist/tui/text.d.ts +8 -0
- package/dist/tui/text.js +28 -0
- package/dist/tui/types.d.ts +86 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/views/inbox.d.ts +4 -0
- package/dist/tui/views/inbox.js +23 -0
- package/dist/tui/views/overview.d.ts +6 -0
- package/dist/tui/views/overview.js +13 -0
- package/dist/tui/views/picker.d.ts +4 -0
- package/dist/tui/views/picker.js +44 -0
- package/dist/tui/views/timeline.d.ts +4 -0
- package/dist/tui/views/timeline.js +11 -0
- package/dist/tui/width.d.ts +4 -0
- package/dist/tui/width.js +45 -0
- package/dist/verified.json +1 -0
- package/dist/web/assets/index-DLO1-OIY.css +1 -0
- package/dist/web/assets/index-DkiJcfz3.js +2 -0
- package/dist/web/index.html +14 -0
- package/package.json +34 -3
- package/README.md +0 -3
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { rowsOf } from './items.js';
|
|
2
|
+
import { LineInput } from './input.js';
|
|
3
|
+
const VIEWS = ['overview', 'inbox', 'timeline'];
|
|
4
|
+
const FILTER_USAGE = '过滤格式:source:task|patrol|command / post:<岗位> / task:<任务 id>;留空清除';
|
|
5
|
+
const SOURCES = ['task', 'patrol', 'command'];
|
|
6
|
+
export function initialTuiState() {
|
|
7
|
+
return {
|
|
8
|
+
view: 'overview', selected: 0, menu: null, flow: null, input: null, inputKind: null, confirm: null,
|
|
9
|
+
filter: null, help: false, status: '', lastUncertain: null, stale: false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const cancelled = (s) => ({ ...s, menu: null, flow: null, input: null, inputKind: null, confirm: null, status: '已取消' });
|
|
13
|
+
/** 逐项问完 steps:还有没回答的就开下一个输入框,问完交给 build。 */
|
|
14
|
+
function advanceFlow(s, item, values) {
|
|
15
|
+
const next = item.steps[values.length];
|
|
16
|
+
if (next)
|
|
17
|
+
return { state: { ...s, menu: null, flow: { item, values }, input: new LineInput(next.prompt), inputKind: 'flow' } };
|
|
18
|
+
const built = item.build(values);
|
|
19
|
+
const done = { ...s, menu: null, flow: null, input: null, inputKind: null };
|
|
20
|
+
return typeof built === 'string' ? { state: { ...done, status: built } } : { state: { ...done, confirm: built, status: '' } };
|
|
21
|
+
}
|
|
22
|
+
function parseFilter(text) {
|
|
23
|
+
const m = /^(source|post|task):(.+)$/.exec(text.trim());
|
|
24
|
+
if (!m)
|
|
25
|
+
return FILTER_USAGE;
|
|
26
|
+
if (m[1] === 'source')
|
|
27
|
+
return SOURCES.includes(m[2]) ? { source: m[2] } : FILTER_USAGE;
|
|
28
|
+
return { [m[1]]: m[2].trim() };
|
|
29
|
+
}
|
|
30
|
+
function applyFilter(s, text) {
|
|
31
|
+
const closed = { ...s, input: null, inputKind: null };
|
|
32
|
+
if (text.trim() === '')
|
|
33
|
+
return { state: { ...closed, filter: null, selected: 0, status: '已清除过滤' } };
|
|
34
|
+
const f = parseFilter(text);
|
|
35
|
+
return typeof f === 'string' ? { state: { ...closed, status: f } } : { state: { ...closed, filter: f, selected: 0, status: '' } };
|
|
36
|
+
}
|
|
37
|
+
function inputKey(s, k) {
|
|
38
|
+
const box = s.input.clone();
|
|
39
|
+
const r = box.feed(k);
|
|
40
|
+
if (r === 'continue')
|
|
41
|
+
return { state: { ...s, input: box } };
|
|
42
|
+
if (r === 'cancel')
|
|
43
|
+
return { state: cancelled(s) };
|
|
44
|
+
if (s.inputKind === 'filter')
|
|
45
|
+
return applyFilter(s, box.value);
|
|
46
|
+
const { item, values } = s.flow;
|
|
47
|
+
if (box.value.trim() === '' && !item.steps[values.length].optional)
|
|
48
|
+
return { state: { ...s, input: box, status: '不能为空' } };
|
|
49
|
+
return advanceFlow(s, item, [...values, box.value]);
|
|
50
|
+
}
|
|
51
|
+
function menuKey(s, k) {
|
|
52
|
+
const m = s.menu;
|
|
53
|
+
if (k.name === 'escape')
|
|
54
|
+
return { state: { ...s, menu: null, status: '' } };
|
|
55
|
+
if (k.name === 'enter')
|
|
56
|
+
return advanceFlow(s, m.items[m.selected], []);
|
|
57
|
+
const step = k.name === 'up' ? -1 : k.name === 'down' ? 1 : 0;
|
|
58
|
+
return { state: step ? { ...s, menu: { ...m, selected: Math.max(0, Math.min(m.items.length - 1, m.selected + step)) } } : s };
|
|
59
|
+
}
|
|
60
|
+
function confirmKey(s, k) {
|
|
61
|
+
if (k.name === 'char' && k.ch === 'y')
|
|
62
|
+
return { state: { ...s, confirm: null, status: '执行中…' }, effect: { kind: 'run', action: s.confirm } };
|
|
63
|
+
return { state: { ...s, confirm: null, status: '已取消' } };
|
|
64
|
+
}
|
|
65
|
+
function switchView(s, view, data) {
|
|
66
|
+
// 时间线新的在下,进来时选中最新一条
|
|
67
|
+
const selected = view === 'timeline' ? Math.max(0, rowsOf(view, s, data).length - 1) : 0;
|
|
68
|
+
return { ...s, view, selected, menu: null };
|
|
69
|
+
}
|
|
70
|
+
function openRow(s, data) {
|
|
71
|
+
const row = rowsOf(s.view, s, data)[s.selected];
|
|
72
|
+
if (!row)
|
|
73
|
+
return { state: s };
|
|
74
|
+
if (row.actions.length === 0) {
|
|
75
|
+
const status = row.hint ? `这一项没有对应的写操作,请在终端执行:${row.hint}` : '这一项没有可执行的操作';
|
|
76
|
+
return { state: { ...s, status } };
|
|
77
|
+
}
|
|
78
|
+
if (row.actions.length === 1)
|
|
79
|
+
return advanceFlow(s, row.actions[0], []);
|
|
80
|
+
return { state: { ...s, menu: { items: row.actions, selected: 0 }, status: '' } };
|
|
81
|
+
}
|
|
82
|
+
function move(s, data, delta) {
|
|
83
|
+
const last = Math.max(0, rowsOf(s.view, s, data).length - 1);
|
|
84
|
+
return { state: { ...s, selected: Math.max(0, Math.min(last, s.selected + delta)) } };
|
|
85
|
+
}
|
|
86
|
+
function charKey(s, data, ch) {
|
|
87
|
+
const n = Number(ch);
|
|
88
|
+
if (n >= 1 && n <= VIEWS.length)
|
|
89
|
+
return { state: switchView(s, VIEWS[n - 1], data) };
|
|
90
|
+
switch (ch) {
|
|
91
|
+
case 'j': return move(s, data, 1);
|
|
92
|
+
case 'k': return move(s, data, -1);
|
|
93
|
+
case 'r': return { state: s, effect: { kind: 'refresh' } };
|
|
94
|
+
case 'R': return s.lastUncertain ? { state: s, effect: { kind: 'run', action: s.lastUncertain } } : { state: s };
|
|
95
|
+
case '?': return { state: { ...s, help: true } };
|
|
96
|
+
case 'q': return { state: s, effect: { kind: 'quit' } };
|
|
97
|
+
case '/':
|
|
98
|
+
return s.view === 'timeline'
|
|
99
|
+
? { state: { ...s, input: new LineInput('过滤(source:/post:/task:,留空清除): '), inputKind: 'filter' } }
|
|
100
|
+
: { state: s };
|
|
101
|
+
default: return { state: s };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function normalKey(s, k, data) {
|
|
105
|
+
switch (k.name) {
|
|
106
|
+
case 'tab': return { state: switchView(s, VIEWS[(VIEWS.indexOf(s.view) + 1) % VIEWS.length], data) };
|
|
107
|
+
case 'down': return move(s, data, 1);
|
|
108
|
+
case 'up': return move(s, data, -1);
|
|
109
|
+
case 'enter': return openRow(s, data);
|
|
110
|
+
case 'char': return charKey(s, data, k.ch ?? '');
|
|
111
|
+
default: return { state: s };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** 按键状态机(纯函数):返回新状态,以及需要外部执行的动作(写操作 / 刷新 / 退出)。 */
|
|
115
|
+
export function reduce(s, k, data) {
|
|
116
|
+
if (k.name === 'ctrl-c')
|
|
117
|
+
return { state: s, effect: { kind: 'quit' } };
|
|
118
|
+
if (s.help)
|
|
119
|
+
return { state: { ...s, help: false } };
|
|
120
|
+
if (s.confirm)
|
|
121
|
+
return confirmKey(s, k);
|
|
122
|
+
if (s.input)
|
|
123
|
+
return inputKey(s, k);
|
|
124
|
+
if (s.menu)
|
|
125
|
+
return menuKey(s, k);
|
|
126
|
+
return normalKey(s, k, data);
|
|
127
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { pendingCount } from '@weiping/wingline-core';
|
|
2
|
+
import { dispatcherLine } from '../output.js';
|
|
3
|
+
import { rowsOf } from './items.js';
|
|
4
|
+
import { oneLine } from './text.js';
|
|
5
|
+
import { fit } from './width.js';
|
|
6
|
+
import { inboxBody } from './views/inbox.js';
|
|
7
|
+
import { overviewBody } from './views/overview.js';
|
|
8
|
+
import { timelineBody } from './views/timeline.js';
|
|
9
|
+
export const MIN_COLS = 40;
|
|
10
|
+
const TABS = [['overview', '1 总览'], ['inbox', '2 收件箱'], ['timeline', '3 时间线']];
|
|
11
|
+
const KEYS_HINT = 'j/k 选择 Enter 操作 Tab 切换 ? 帮助 q 退出';
|
|
12
|
+
const HELP = [
|
|
13
|
+
'按键',
|
|
14
|
+
' 1 2 3 / Tab 切换 总览 / 收件箱 / 时间线',
|
|
15
|
+
' ↑ ↓ / j k 选中行',
|
|
16
|
+
' Enter 打开选中项的操作菜单(没有写操作的条目会显示对应的 wl 命令)',
|
|
17
|
+
' / 时间线里按 source: / post: / task: 过滤,留空清除',
|
|
18
|
+
' r 立即刷新',
|
|
19
|
+
' R 结果不确定时按原样重试上一次写操作',
|
|
20
|
+
' ? 显示 / 关闭本帮助',
|
|
21
|
+
' q / Ctrl-C 退出',
|
|
22
|
+
'',
|
|
23
|
+
'写操作提交前会再确认一次:按 y 执行,其他键取消。',
|
|
24
|
+
];
|
|
25
|
+
function header(s, data) {
|
|
26
|
+
const tabs = TABS.map(([v, label]) => (v === s.view ? `[${label}]` : ` ${label} `)).join(' ');
|
|
27
|
+
const a = data.attention;
|
|
28
|
+
const pending = pendingCount(a);
|
|
29
|
+
const disp = dispatcherLine(a.dispatcher) ?? '调度员:未启动';
|
|
30
|
+
return [`雁阵 ${oneLine(data.team)} ${tabs}`, `${disp} 待办 ${pending}${s.stale ? ' [数据过期]' : ''}`];
|
|
31
|
+
}
|
|
32
|
+
function footer(s, cols) {
|
|
33
|
+
if (s.confirm)
|
|
34
|
+
return [`确认:${oneLine(s.confirm.label)}?按 y 执行,其他键取消`];
|
|
35
|
+
const status = s.status ? oneLine(s.status) : KEYS_HINT;
|
|
36
|
+
if (s.input)
|
|
37
|
+
return [status, s.input.view(cols)];
|
|
38
|
+
if (!s.menu)
|
|
39
|
+
return [status];
|
|
40
|
+
const items = s.menu.items.map((m, i) => `${i === s.menu.selected ? '> ' : ' '}${m.label}`);
|
|
41
|
+
return ['操作(↑↓ 选择,Enter 确认,Esc 取消):', ...items];
|
|
42
|
+
}
|
|
43
|
+
function body(s, data, height) {
|
|
44
|
+
if (s.help)
|
|
45
|
+
return HELP;
|
|
46
|
+
const rows = rowsOf(s.view, s, data);
|
|
47
|
+
const selected = Math.max(0, Math.min(s.selected, rows.length - 1));
|
|
48
|
+
if (s.view === 'overview')
|
|
49
|
+
return overviewBody(rows, selected, data);
|
|
50
|
+
return s.view === 'inbox' ? inboxBody(rows, selected, data) : timelineBody(rows, selected, s.filter, height);
|
|
51
|
+
}
|
|
52
|
+
/** 正文比可用高度长时,围绕选中行(以 `> ` 开头)取一个窗口,保证选中的那一行始终看得见。 */
|
|
53
|
+
function windowAround(lines, height) {
|
|
54
|
+
if (lines.length <= height)
|
|
55
|
+
return lines;
|
|
56
|
+
const focus = Math.max(0, lines.findIndex((l) => l.startsWith('> ')));
|
|
57
|
+
const start = Math.max(0, Math.min(focus - Math.floor(height / 2), lines.length - height));
|
|
58
|
+
return lines.slice(start, start + height);
|
|
59
|
+
}
|
|
60
|
+
/** 整屏内容:每行恰好 cols 列;窗口过窄只给一行提示。输出里不含任何 ANSI 序列。 */
|
|
61
|
+
export function renderView(s, data, size) {
|
|
62
|
+
if (size.cols < MIN_COLS)
|
|
63
|
+
return ['窗口太窄'];
|
|
64
|
+
const head = header(s, data);
|
|
65
|
+
const foot = footer(s, size.cols);
|
|
66
|
+
const height = Math.max(1, size.rows - head.length - foot.length);
|
|
67
|
+
const main = [...windowAround(body(s, data, height), height)];
|
|
68
|
+
while (main.length < height)
|
|
69
|
+
main.push('');
|
|
70
|
+
return [...head, ...main, ...foot].map((l) => fit(l, size.cols));
|
|
71
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Screen 只需要能写字符串、知道窗口大小。 */
|
|
2
|
+
export interface ScreenOut {
|
|
3
|
+
write(s: string): unknown;
|
|
4
|
+
columns: number;
|
|
5
|
+
rows: number;
|
|
6
|
+
}
|
|
7
|
+
/** 备用屏幕 + 按行差分重绘。restore 幂等,可以放心在 finally / exit / 信号处理里重复调用。 */
|
|
8
|
+
export declare class Screen {
|
|
9
|
+
private readonly out;
|
|
10
|
+
private prev;
|
|
11
|
+
private entered;
|
|
12
|
+
private restored;
|
|
13
|
+
private clearNext;
|
|
14
|
+
constructor(out: ScreenOut);
|
|
15
|
+
enter(): void;
|
|
16
|
+
/** 逐行比较上一帧,只重写变了的行;上一帧多出来的行清空。 */
|
|
17
|
+
render(lines: string[]): void;
|
|
18
|
+
/** 尺寸变化时调用:下一帧先清屏再整屏重绘。 */
|
|
19
|
+
invalidate(): void;
|
|
20
|
+
restore(): void;
|
|
21
|
+
size(): {
|
|
22
|
+
cols: number;
|
|
23
|
+
rows: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const ENTER = '\x1b[?1049h\x1b[?25l';
|
|
2
|
+
const LEAVE = '\x1b[?25h\x1b[?1049l';
|
|
3
|
+
const at = (row) => `\x1b[${row};1H`;
|
|
4
|
+
/** 备用屏幕 + 按行差分重绘。restore 幂等,可以放心在 finally / exit / 信号处理里重复调用。 */
|
|
5
|
+
export class Screen {
|
|
6
|
+
out;
|
|
7
|
+
prev = [];
|
|
8
|
+
entered = false;
|
|
9
|
+
restored = false;
|
|
10
|
+
clearNext = false;
|
|
11
|
+
constructor(out) {
|
|
12
|
+
this.out = out;
|
|
13
|
+
}
|
|
14
|
+
enter() {
|
|
15
|
+
this.out.write(ENTER);
|
|
16
|
+
this.entered = true;
|
|
17
|
+
this.restored = false;
|
|
18
|
+
}
|
|
19
|
+
/** 逐行比较上一帧,只重写变了的行;上一帧多出来的行清空。 */
|
|
20
|
+
render(lines) {
|
|
21
|
+
if (this.restored)
|
|
22
|
+
return;
|
|
23
|
+
let w = '';
|
|
24
|
+
if (this.clearNext) {
|
|
25
|
+
w = '\x1b[2J';
|
|
26
|
+
this.prev = [];
|
|
27
|
+
this.clearNext = false;
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < Math.max(lines.length, this.prev.length); i++) {
|
|
30
|
+
const next = lines[i];
|
|
31
|
+
if (next === this.prev[i])
|
|
32
|
+
continue;
|
|
33
|
+
w += `${at(i + 1)}${next ?? ''}\x1b[K`;
|
|
34
|
+
}
|
|
35
|
+
this.prev = [...lines];
|
|
36
|
+
if (w)
|
|
37
|
+
this.out.write(w);
|
|
38
|
+
}
|
|
39
|
+
/** 尺寸变化时调用:下一帧先清屏再整屏重绘。 */
|
|
40
|
+
invalidate() {
|
|
41
|
+
this.clearNext = true;
|
|
42
|
+
}
|
|
43
|
+
restore() {
|
|
44
|
+
if (!this.entered || this.restored)
|
|
45
|
+
return;
|
|
46
|
+
this.restored = true;
|
|
47
|
+
this.out.write(LEAVE);
|
|
48
|
+
}
|
|
49
|
+
size() {
|
|
50
|
+
return { cols: this.out.columns, rows: this.out.rows };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type TimelineEvent } from '@weiping/wingline-core';
|
|
2
|
+
/**
|
|
3
|
+
* 外部文本(提问正文、任务标题、时间线字段……)显示前一律经这里:剥掉 ANSI/OSC 序列和控制字符,折成一行。
|
|
4
|
+
* 否则一条带 `\x1b]0;…\x07` 的提问就能改掉终端标题(标题是岗位身份)或清屏。
|
|
5
|
+
*/
|
|
6
|
+
export declare function oneLine(s: string): string;
|
|
7
|
+
/** 时间线事件里所有字符串(含 detail 的嵌套内容)都先清洗,再交给 renderTimelineEvent 排版。 */
|
|
8
|
+
export declare function cleanEvent(e: TimelineEvent): TimelineEvent;
|
package/dist/tui/text.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { plainText } from '@weiping/wingline-core';
|
|
2
|
+
/**
|
|
3
|
+
* 外部文本(提问正文、任务标题、时间线字段……)显示前一律经这里:剥掉 ANSI/OSC 序列和控制字符,折成一行。
|
|
4
|
+
* 否则一条带 `\x1b]0;…\x07` 的提问就能改掉终端标题(标题是岗位身份)或清屏。
|
|
5
|
+
*/
|
|
6
|
+
export function oneLine(s) {
|
|
7
|
+
return plainText(s).replace(/[\r\n\t]+/g, ' ');
|
|
8
|
+
}
|
|
9
|
+
function cleanDeep(v) {
|
|
10
|
+
if (typeof v === 'string')
|
|
11
|
+
return oneLine(v);
|
|
12
|
+
if (Array.isArray(v))
|
|
13
|
+
return v.map(cleanDeep);
|
|
14
|
+
if (v && typeof v === 'object')
|
|
15
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [oneLine(k), cleanDeep(x)]));
|
|
16
|
+
return v;
|
|
17
|
+
}
|
|
18
|
+
/** 时间线事件里所有字符串(含 detail 的嵌套内容)都先清洗,再交给 renderTimelineEvent 排版。 */
|
|
19
|
+
export function cleanEvent(e) {
|
|
20
|
+
return {
|
|
21
|
+
...e,
|
|
22
|
+
actor: oneLine(e.actor),
|
|
23
|
+
action: oneLine(e.action),
|
|
24
|
+
target: e.target === null ? null : oneLine(e.target),
|
|
25
|
+
outcome: e.outcome === null ? null : oneLine(e.outcome),
|
|
26
|
+
detail: cleanDeep(e.detail),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AttentionReport, PsRow, TaskRow, TimelineEvent, TimelineSource, WriteCommands } from '@weiping/wingline-core';
|
|
2
|
+
import type { LineInput } from './input.js';
|
|
3
|
+
import type { ScreenOut } from './screen.js';
|
|
4
|
+
export type View = 'overview' | 'inbox' | 'timeline';
|
|
5
|
+
/** 时间线过滤:`source:` / `post:` / `task:` 三选一。 */
|
|
6
|
+
export interface TimelineFilterSpec {
|
|
7
|
+
source?: TimelineSource;
|
|
8
|
+
post?: string;
|
|
9
|
+
task?: string;
|
|
10
|
+
}
|
|
11
|
+
/** 等用户按 y 确认后才执行的一次写操作。args 原样交给核心命令。 */
|
|
12
|
+
export interface PendingAction {
|
|
13
|
+
command: keyof WriteCommands;
|
|
14
|
+
args: Record<string, unknown>;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Step {
|
|
18
|
+
prompt: string;
|
|
19
|
+
/** 留空也算数(比如静音时长)。 */
|
|
20
|
+
optional?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** 操作菜单里的一项:依次问完 steps,再由 build 给出要执行的操作;build 返回字符串表示输入有误。 */
|
|
23
|
+
export interface MenuItem {
|
|
24
|
+
label: string;
|
|
25
|
+
steps: Step[];
|
|
26
|
+
build(values: string[]): PendingAction | string;
|
|
27
|
+
}
|
|
28
|
+
export interface TuiData {
|
|
29
|
+
team: string;
|
|
30
|
+
posts: PsRow[];
|
|
31
|
+
attention: AttentionReport;
|
|
32
|
+
tasks: TaskRow[];
|
|
33
|
+
/** 最旧的在前。 */
|
|
34
|
+
timeline: TimelineEvent[];
|
|
35
|
+
/** 静音中的岗位地址。 */
|
|
36
|
+
hushed: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface TuiState {
|
|
39
|
+
view: View;
|
|
40
|
+
selected: number;
|
|
41
|
+
menu: {
|
|
42
|
+
items: MenuItem[];
|
|
43
|
+
selected: number;
|
|
44
|
+
} | null;
|
|
45
|
+
/** 正在逐项回答的操作。 */
|
|
46
|
+
flow: {
|
|
47
|
+
item: MenuItem;
|
|
48
|
+
values: string[];
|
|
49
|
+
} | null;
|
|
50
|
+
input: LineInput | null;
|
|
51
|
+
inputKind: 'flow' | 'filter' | null;
|
|
52
|
+
confirm: PendingAction | null;
|
|
53
|
+
filter: TimelineFilterSpec | null;
|
|
54
|
+
help: boolean;
|
|
55
|
+
status: string;
|
|
56
|
+
/** 结果不确定的上一次写操作(带 retryRequest),R 原样重试。 */
|
|
57
|
+
lastUncertain: PendingAction | null;
|
|
58
|
+
/** 最近一次读取失败,显示的数据可能过期。 */
|
|
59
|
+
stale: boolean;
|
|
60
|
+
}
|
|
61
|
+
export type Effect = {
|
|
62
|
+
kind: 'run';
|
|
63
|
+
action: PendingAction;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'refresh';
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'quit';
|
|
68
|
+
};
|
|
69
|
+
export interface TuiInput {
|
|
70
|
+
isTTY?: boolean;
|
|
71
|
+
on(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
|
|
72
|
+
off(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
|
|
73
|
+
setRawMode?(mode: boolean): unknown;
|
|
74
|
+
setEncoding?(enc: string): unknown;
|
|
75
|
+
resume?(): unknown;
|
|
76
|
+
pause?(): unknown;
|
|
77
|
+
}
|
|
78
|
+
export interface TuiOutput extends ScreenOut {
|
|
79
|
+
isTTY?: boolean;
|
|
80
|
+
on(event: 'resize', fn: () => void): unknown;
|
|
81
|
+
off(event: 'resize', fn: () => void): unknown;
|
|
82
|
+
}
|
|
83
|
+
export interface TuiIo {
|
|
84
|
+
stdin: TuiInput;
|
|
85
|
+
stdout: TuiOutput;
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { oneLine } from '../text.js';
|
|
2
|
+
import { marker } from './overview.js';
|
|
3
|
+
/** 收件箱:告警(只读)在最前,然后按分节列出可处理的条目。 */
|
|
4
|
+
export function inboxBody(rows, selected, data) {
|
|
5
|
+
const a = data.attention;
|
|
6
|
+
const lines = [];
|
|
7
|
+
if (a.warnings.length)
|
|
8
|
+
lines.push(`告警(${a.warnings.length})`, ...a.warnings.map((w) => ` ${oneLine(w)}`), '');
|
|
9
|
+
let section = '';
|
|
10
|
+
rows.forEach((r, i) => {
|
|
11
|
+
if (r.section !== section) {
|
|
12
|
+
section = r.section ?? '';
|
|
13
|
+
const count = rows.filter((x) => x.section === section).length;
|
|
14
|
+
lines.push(`${section}(${count})`);
|
|
15
|
+
}
|
|
16
|
+
lines.push(`${marker(i === selected)}${r.text}`);
|
|
17
|
+
});
|
|
18
|
+
if (rows.length === 0 && a.warnings.length === 0)
|
|
19
|
+
lines.push('没有需要处理的事项');
|
|
20
|
+
if (a.unavailable.length)
|
|
21
|
+
lines.push('', `无法读取:${a.unavailable.map(oneLine).join(';')}`);
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Row } from '../items.js';
|
|
2
|
+
import type { TuiData } from '../types.js';
|
|
3
|
+
export declare const RECENT_EVENTS = 8;
|
|
4
|
+
export declare const marker: (selected: boolean) => " " | "> ";
|
|
5
|
+
/** 总览:岗位表,底部最近 8 条时间线。选中行用 `> ` 标出,不用任何终端样式。 */
|
|
6
|
+
export declare function overviewBody(rows: Row[], selected: number, data: TuiData): string[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { eventLine } from '../items.js';
|
|
2
|
+
export const RECENT_EVENTS = 8;
|
|
3
|
+
export const marker = (selected) => (selected ? '> ' : ' ');
|
|
4
|
+
/** 总览:岗位表,底部最近 8 条时间线。选中行用 `> ` 标出,不用任何终端样式。 */
|
|
5
|
+
export function overviewBody(rows, selected, data) {
|
|
6
|
+
const lines = [`岗位(${rows.length})`, ...rows.map((r, i) => `${marker(i === selected)}${r.text}`)];
|
|
7
|
+
if (rows.length === 0)
|
|
8
|
+
lines.push(' 还没有岗位');
|
|
9
|
+
lines.push('', '最近事件');
|
|
10
|
+
const recent = data.timeline.slice(-RECENT_EVENTS);
|
|
11
|
+
lines.push(...(recent.length ? recent.map((e) => ` ${eventLine(e)}`) : [' 暂无']));
|
|
12
|
+
return lines;
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { parseKeys } from '../keys.js';
|
|
2
|
+
import { oneLine } from '../text.js';
|
|
3
|
+
import { Screen } from '../screen.js';
|
|
4
|
+
import { fit } from '../width.js';
|
|
5
|
+
/** 没有指定团队名且登记了多个团队时的选择列表:↑↓ 选择、Enter 进入、q 退出(返回 null)。 */
|
|
6
|
+
export function pickTeam(teams, io) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const screen = new Screen(io.stdout);
|
|
9
|
+
let sel = 0;
|
|
10
|
+
const draw = () => {
|
|
11
|
+
const cols = Math.max(20, screen.size().cols);
|
|
12
|
+
screen.render([
|
|
13
|
+
fit('选择团队(↑↓ 选择,Enter 进入,q 退出)', cols),
|
|
14
|
+
...teams.map((t, i) => fit(`${i === sel ? '> ' : ' '}${oneLine(t.name)} ${t.status === 'up' ? '运行中' : '已停止'}`, cols)),
|
|
15
|
+
]);
|
|
16
|
+
};
|
|
17
|
+
const done = (value) => {
|
|
18
|
+
io.stdin.off('data', onData);
|
|
19
|
+
io.stdin.setRawMode?.(false);
|
|
20
|
+
io.stdin.pause?.();
|
|
21
|
+
screen.restore();
|
|
22
|
+
resolve(value);
|
|
23
|
+
};
|
|
24
|
+
const onData = (chunk) => {
|
|
25
|
+
for (const k of parseKeys(String(chunk))) {
|
|
26
|
+
if (k.name === 'up' || (k.name === 'char' && k.ch === 'k'))
|
|
27
|
+
sel = Math.max(0, sel - 1);
|
|
28
|
+
else if (k.name === 'down' || (k.name === 'char' && k.ch === 'j'))
|
|
29
|
+
sel = Math.min(teams.length - 1, sel + 1);
|
|
30
|
+
else if (k.name === 'enter')
|
|
31
|
+
return done(teams[sel].name);
|
|
32
|
+
else if (k.name === 'escape' || k.name === 'ctrl-c' || (k.name === 'char' && k.ch === 'q'))
|
|
33
|
+
return done(null);
|
|
34
|
+
}
|
|
35
|
+
draw();
|
|
36
|
+
};
|
|
37
|
+
io.stdin.setRawMode?.(true);
|
|
38
|
+
io.stdin.setEncoding?.('utf8');
|
|
39
|
+
io.stdin.resume?.();
|
|
40
|
+
screen.enter();
|
|
41
|
+
draw();
|
|
42
|
+
io.stdin.on('data', onData);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Row } from '../items.js';
|
|
2
|
+
import type { TimelineFilterSpec } from '../types.js';
|
|
3
|
+
/** 时间线:旧的在上、新的在下,选中行默认贴着最新一条;超出屏幕高度只留选中行附近。 */
|
|
4
|
+
export declare function timelineBody(rows: Row[], selected: number, filter: TimelineFilterSpec | null, height: number): string[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { filterText } from '../items.js';
|
|
2
|
+
import { marker } from './overview.js';
|
|
3
|
+
/** 时间线:旧的在上、新的在下,选中行默认贴着最新一条;超出屏幕高度只留选中行附近。 */
|
|
4
|
+
export function timelineBody(rows, selected, filter, height) {
|
|
5
|
+
const head = filter ? `时间线(过滤:${filterText(filter)},共 ${rows.length} 条)` : `时间线(共 ${rows.length} 条)`;
|
|
6
|
+
if (rows.length === 0)
|
|
7
|
+
return [head, ' 暂无事件'];
|
|
8
|
+
const room = Math.max(1, height - 1);
|
|
9
|
+
const start = Math.max(0, Math.min(selected - Math.floor(room / 2), rows.length - room));
|
|
10
|
+
return [head, ...rows.slice(start, start + room).map((r, i) => `${marker(start + i === selected)}${r.text}`)];
|
|
11
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 终端显示宽度:东亚宽字符与常见 emoji 占 2 列,组合字符与控制字符占 0 列。只处理纯文本(外部文本先经 plainText)。
|
|
2
|
+
const ANSI = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9:;<=>?]*[ -/]*[@-~]|\x1b[@-Z\\-_]/g;
|
|
3
|
+
/** 占两列的码点区间(闭区间)。 */
|
|
4
|
+
const WIDE = [
|
|
5
|
+
[0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f],
|
|
6
|
+
[0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x1fa70, 0x1faff],
|
|
7
|
+
// BMP 里默认就是 emoji 样式(占两列)的符号;⚠ ❄ ✓ ✔ ★ © 这类文本样式的符号占一列,不在这里
|
|
8
|
+
[0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653],
|
|
9
|
+
[0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce],
|
|
10
|
+
[0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705],
|
|
11
|
+
[0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
|
|
12
|
+
[0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
13
|
+
];
|
|
14
|
+
function charWidth(cp) {
|
|
15
|
+
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
|
|
16
|
+
return 0;
|
|
17
|
+
if (cp >= 0x300 && cp <= 0x36f)
|
|
18
|
+
return 0;
|
|
19
|
+
return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1;
|
|
20
|
+
}
|
|
21
|
+
/** 字符串占的终端列数;ANSI 序列不占列。 */
|
|
22
|
+
export function cellWidth(s) {
|
|
23
|
+
let w = 0;
|
|
24
|
+
for (const ch of s.replace(ANSI, ''))
|
|
25
|
+
w += charWidth(ch.codePointAt(0));
|
|
26
|
+
return w;
|
|
27
|
+
}
|
|
28
|
+
/** 截到刚好 cols 列:放得下就补空格,放不下就截断并以 … 结尾(… 占最后一列,宽字符不会被切开)。 */
|
|
29
|
+
export function fit(s, cols) {
|
|
30
|
+
if (cols <= 0)
|
|
31
|
+
return '';
|
|
32
|
+
const total = cellWidth(s);
|
|
33
|
+
if (total <= cols)
|
|
34
|
+
return s + ' '.repeat(cols - total);
|
|
35
|
+
let kept = '';
|
|
36
|
+
let w = 0;
|
|
37
|
+
for (const ch of s) {
|
|
38
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
39
|
+
if (w + cw > cols - 1)
|
|
40
|
+
break;
|
|
41
|
+
kept += ch;
|
|
42
|
+
w += cw;
|
|
43
|
+
}
|
|
44
|
+
return `${kept}…${' '.repeat(cols - w - 1)}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "verifiedOrca": ">=1.4.222 <1.5.0" }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color-scheme:light dark;--bg: #f6f5f1;--surface: #ffffff;--ink: #1d1d1b;--muted: #6b6a64;--line: #e2dfd6;--accent: #2f5d50;--ok: #2f7d4f;--warn: #a2650c;--bad: #b03a2e;--radius: 10px;--gap: 14px;font-family:system-ui,-apple-system,Segoe UI,PingFang SC,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.5}@media(prefers-color-scheme:dark){:root{--bg: #171816;--surface: #1f201e;--ink: #e8e6df;--muted: #9a988f;--line: #33342f;--accent: #7fb8a3;--ok: #6fbf8a;--warn: #e0a84f;--bad: #e7796c}}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}h1,h2,h3{margin:0 0 var(--gap);font-weight:650;letter-spacing:.01em}h1{font-size:1.5rem}h2{font-size:1.05rem;display:flex;align-items:center;gap:8px}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}code,pre,.term{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85em}code{background:var(--line);padding:1px 5px;border-radius:4px}pre{white-space:pre-wrap;word-break:break-word;margin:0}.muted{color:var(--muted)}.small{font-size:.85em}.error{color:var(--bad)}.banner{background:var(--surface);border:1px solid var(--line);border-left:4px solid var(--warn);padding:8px 12px;border-radius:var(--radius);margin:0 0 var(--gap)}.banner.error{border-left-color:var(--bad)}.shell{display:grid;grid-template-columns:210px 1fr;min-height:100vh}.side{border-right:1px solid var(--line);padding:20px 16px;display:flex;flex-direction:column;gap:var(--gap)}.brand{font-weight:700;font-size:1.1rem;letter-spacing:.08em;color:var(--accent)}.team-switch,.field input,.field select,.field textarea{font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%}.field textarea{min-height:84px;resize:vertical}.nav-link{display:block;padding:6px 8px;border-radius:6px;color:var(--ink)}.nav-link:hover{background:var(--line);text-decoration:none}.main{padding:20px 28px 48px;min-width:0}.topbar{display:flex;align-items:center;gap:12px;margin-bottom:var(--gap);flex-wrap:wrap}.badge{background:var(--line);padding:2px 10px;border-radius:999px;font-size:.85em}.page{display:flex;flex-direction:column;gap:20px}.page-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}.page-head h1{margin:0}.section{display:flex;flex-direction:column;gap:8px}.section h2 .count,.col h2 .count{color:var(--muted);font-weight:500;font-size:.9em}.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--gap)}.post header{display:flex;align-items:center;gap:8px;justify-content:space-between}.post-name{font-weight:650;font-size:1.02rem}.flags,.actions{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px}.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}.pill{display:inline-block;font-size:.78em;padding:1px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted)}.pill-ok{color:var(--ok);border-color:var(--ok)}.pill-warn{color:var(--warn);border-color:var(--warn)}.pill-bad{color:var(--bad);border-color:var(--bad)}.chip{font-size:.78em;background:var(--line);padding:1px 7px;border-radius:4px}.btn{font:inherit;font-size:.9em;padding:5px 12px;border-radius:6px;border:1px solid var(--accent);background:var(--accent);color:var(--surface);cursor:pointer}.btn:disabled{opacity:.55;cursor:default}.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line)}.btn.danger{background:var(--bad);border-color:var(--bad)}.btn.small{padding:2px 8px;font-size:.8em}.dialog-backdrop{position:fixed;inset:0;background:#00000059;display:grid;place-items:center;z-index:10}.dialog{background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius);padding:20px;width:min(440px,92vw)}.dialog form{display:flex;flex-direction:column;gap:10px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px}.field{display:flex;flex-direction:column;gap:4px;font-size:.9em}.field.inline{flex-direction:row;align-items:center;gap:6px}.field.inline input,.field.inline select{width:auto;min-width:160px}.field.check{flex-direction:row;align-items:center}.filters{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.cmd{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:.9em}.toasts{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:20}.toast{background:var(--surface);border:1px solid var(--line);border-left:4px solid var(--ok);padding:8px 12px;border-radius:6px}.toast-err{border-left-color:var(--bad)}.events{list-style:none;padding:0;margin:0;display:flex;flex-direction:column}.event{display:grid;grid-template-columns:70px 64px 150px 150px 1fr auto;gap:8px;align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);font-size:.9em}.event time{color:var(--muted);font-variant-numeric:tabular-nums}.event .tag{color:var(--muted)}.event details{grid-column:2 / -1}.event details pre{margin-top:4px;color:var(--muted)}.facts{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0}.facts dt{color:var(--muted)}.facts dd{margin:0}.board{display:grid;grid-template-columns:repeat(5,minmax(180px,1fr));gap:var(--gap);overflow-x:auto}.col{display:flex;flex-direction:column;gap:10px;min-width:0}.col h2{font-size:.95rem}.task{display:flex;flex-direction:column;gap:4px}.task-title{font-weight:600}.expired{max-width:520px;margin:15vh auto;padding:0 20px}.team-list{list-style:none;padding:0;display:flex;flex-direction:column;gap:8px}.side-foot{margin-top:auto}.tabs{display:flex;gap:6px}.spec-form{display:flex;flex-direction:column;gap:var(--gap)}.spec-form fieldset{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px var(--gap);margin:0}.spec-form legend{font-weight:650;padding:0 6px}.field label{display:flex;flex-direction:column;gap:4px}.field.check label{flex-direction:row;align-items:center;gap:8px}.field.check input{width:auto}textarea.yaml{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85em;width:100%;min-height:360px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius);padding:10px}.spec-errors{margin:0;padding-left:18px}.save-panel{display:flex;flex-direction:column;gap:8px;align-items:flex-start}.preview-posts,.preview-fields{margin:0 0 var(--gap);padding-left:18px}.table{border-collapse:collapse;width:100%;font-size:.92em}.table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}@media(max-width:760px){.shell{grid-template-columns:1fr}.side{border-right:none;border-bottom:1px solid var(--line);flex-direction:row;flex-wrap:wrap;align-items:center}.event{grid-template-columns:62px 1fr}.event>*{grid-column:auto}}
|