@gitim-runtime/cli 0.1.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/dist/client.d.ts +34 -0
- package/dist/client.js +99 -0
- package/dist/commands/archive-channel.d.ts +1 -0
- package/dist/commands/archive-channel.js +19 -0
- package/dist/commands/archived-channels.d.ts +1 -0
- package/dist/commands/archived-channels.js +26 -0
- package/dist/commands/channels.d.ts +1 -0
- package/dist/commands/channels.js +18 -0
- package/dist/commands/create-channel.d.ts +4 -0
- package/dist/commands/create-channel.js +19 -0
- package/dist/commands/dm.d.ts +10 -0
- package/dist/commands/dm.js +81 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/join-channel.d.ts +3 -0
- package/dist/commands/join-channel.js +20 -0
- package/dist/commands/onboard.d.ts +17 -0
- package/dist/commands/onboard.js +261 -0
- package/dist/commands/read.d.ts +4 -0
- package/dist/commands/read.js +20 -0
- package/dist/commands/reindex.d.ts +1 -0
- package/dist/commands/reindex.js +18 -0
- package/dist/commands/search.d.ts +7 -0
- package/dist/commands/search.js +41 -0
- package/dist/commands/send.d.ts +4 -0
- package/dist/commands/send.js +19 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +18 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +21 -0
- package/dist/commands/tui.d.ts +1 -0
- package/dist/commands/tui.js +57 -0
- package/dist/commands/users.d.ts +1 -0
- package/dist/commands/users.js +18 -0
- package/dist/commands/webui.d.ts +5 -0
- package/dist/commands/webui.js +41 -0
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +73 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +141 -0
- package/dist/tui/app.d.ts +43 -0
- package/dist/tui/app.js +461 -0
- package/dist/tui/channel-sidebar.d.ts +14 -0
- package/dist/tui/channel-sidebar.js +62 -0
- package/dist/tui/daemon-connection.d.ts +38 -0
- package/dist/tui/daemon-connection.js +118 -0
- package/dist/tui/mention-popup.d.ts +13 -0
- package/dist/tui/mention-popup.js +59 -0
- package/dist/tui/message-view.d.ts +38 -0
- package/dist/tui/message-view.js +166 -0
- package/dist/tui/split-layout.d.ts +17 -0
- package/dist/tui/split-layout.js +48 -0
- package/dist/tui/status-bar.d.ts +12 -0
- package/dist/tui/status-bar.js +39 -0
- package/dist/tui/themes.d.ts +22 -0
- package/dist/tui/themes.js +49 -0
- package/dist/tui/thread-view.d.ts +14 -0
- package/dist/tui/thread-view.js +72 -0
- package/dist/webui/assets/index-YH6ztb9g.css +1 -0
- package/dist/webui/assets/index-nFs-lh9F.js +49 -0
- package/dist/webui/index.html +13 -0
- package/dist/webui/server.d.ts +7 -0
- package/dist/webui/server.js +229 -0
- package/package.json +28 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 主应用 — 组装所有组件并管理交互
|
|
3
|
+
*/
|
|
4
|
+
import { TUI, ProcessTerminal, Editor, matchesKey, Key } from '@mariozechner/pi-tui';
|
|
5
|
+
import { DaemonConnection } from './daemon-connection.js';
|
|
6
|
+
import { ChannelSidebar, SIDEBAR_WIDTH } from './channel-sidebar.js';
|
|
7
|
+
import { MessageView } from './message-view.js';
|
|
8
|
+
import { SplitLayout } from './split-layout.js';
|
|
9
|
+
import { StatusBar } from './status-bar.js';
|
|
10
|
+
import { ThreadView } from './thread-view.js';
|
|
11
|
+
import { MentionPopup } from './mention-popup.js';
|
|
12
|
+
import { editorTheme } from './themes.js';
|
|
13
|
+
export class TuiApp {
|
|
14
|
+
tui;
|
|
15
|
+
terminal;
|
|
16
|
+
conn;
|
|
17
|
+
user;
|
|
18
|
+
repoRoot;
|
|
19
|
+
// 组件
|
|
20
|
+
sidebar;
|
|
21
|
+
messageView;
|
|
22
|
+
editor;
|
|
23
|
+
statusBar;
|
|
24
|
+
splitLayout;
|
|
25
|
+
threadView;
|
|
26
|
+
mentionPopup;
|
|
27
|
+
// 状态
|
|
28
|
+
mode = 'normal';
|
|
29
|
+
users = [];
|
|
30
|
+
threadOverlay = null;
|
|
31
|
+
mentionOverlay = null;
|
|
32
|
+
mentionFilter = '';
|
|
33
|
+
loadMessagesInFlight = false;
|
|
34
|
+
loadMessagesPending = false;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.repoRoot = options.repoRoot;
|
|
37
|
+
this.user = options.user;
|
|
38
|
+
this.terminal = new ProcessTerminal();
|
|
39
|
+
this.tui = new TUI(this.terminal);
|
|
40
|
+
this.conn = new DaemonConnection(this.repoRoot);
|
|
41
|
+
// 初始化组件
|
|
42
|
+
this.sidebar = new ChannelSidebar();
|
|
43
|
+
this.messageView = new MessageView();
|
|
44
|
+
this.editor = new Editor(this.tui, editorTheme);
|
|
45
|
+
this.statusBar = new StatusBar();
|
|
46
|
+
this.threadView = new ThreadView();
|
|
47
|
+
this.mentionPopup = new MentionPopup();
|
|
48
|
+
this.splitLayout = new SplitLayout(this.sidebar, this.messageView, SIDEBAR_WIDTH);
|
|
49
|
+
// 配置状态栏
|
|
50
|
+
this.statusBar.user = this.user;
|
|
51
|
+
this.statusBar.mode = 'normal';
|
|
52
|
+
// 配置 Editor
|
|
53
|
+
this.editor.onSubmit = (text) => {
|
|
54
|
+
this.sendMessage(text);
|
|
55
|
+
};
|
|
56
|
+
// 配置线程视图回调
|
|
57
|
+
this.threadView.onReply = (msg) => {
|
|
58
|
+
this.statusBar.replyTarget = msg.line_number;
|
|
59
|
+
this.closeThread();
|
|
60
|
+
this.setMode('normal');
|
|
61
|
+
this.tui.requestRender();
|
|
62
|
+
};
|
|
63
|
+
this.threadView.onClose = () => {
|
|
64
|
+
this.closeThread();
|
|
65
|
+
};
|
|
66
|
+
// 配置提及弹窗回调
|
|
67
|
+
this.mentionPopup.onSelect = (user) => {
|
|
68
|
+
this.editor.insertTextAtCursor(user + ' ');
|
|
69
|
+
this.closeMention();
|
|
70
|
+
};
|
|
71
|
+
this.mentionPopup.onCancel = () => {
|
|
72
|
+
this.closeMention();
|
|
73
|
+
};
|
|
74
|
+
// 推送事件处理
|
|
75
|
+
this.conn.onEvent = (event) => {
|
|
76
|
+
if (event.event === 'thread_changed' && event.channel === this.sidebar.currentChannel) {
|
|
77
|
+
this.loadMessages();
|
|
78
|
+
}
|
|
79
|
+
else if (event.event === 'thread_changed' && event.channel !== this.sidebar.currentChannel) {
|
|
80
|
+
// 更新未读计数
|
|
81
|
+
const count = this.sidebar.unreadCounts.get(event.channel) ?? 0;
|
|
82
|
+
this.sidebar.unreadCounts.set(event.channel, count + 1);
|
|
83
|
+
this.tui.requestRender();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
// 全局输入监听
|
|
87
|
+
this.tui.addInputListener((data) => {
|
|
88
|
+
return this.handleGlobalInput(data);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async start() {
|
|
92
|
+
// 连接 daemon
|
|
93
|
+
try {
|
|
94
|
+
await this.conn.connect(true);
|
|
95
|
+
this.statusBar.connected = true;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
this.statusBar.connected = false;
|
|
99
|
+
}
|
|
100
|
+
// 加载频道和用户
|
|
101
|
+
await this.loadChannels();
|
|
102
|
+
await this.loadUsers();
|
|
103
|
+
// 加载第一个频道的消息
|
|
104
|
+
if (this.sidebar.channels.length > 0) {
|
|
105
|
+
this.statusBar.channel = this.sidebar.currentChannel;
|
|
106
|
+
await this.loadMessages();
|
|
107
|
+
}
|
|
108
|
+
// 组装布局:SplitLayout + Editor + StatusBar
|
|
109
|
+
this.tui.addChild(this.splitLayout);
|
|
110
|
+
this.tui.addChild(this.editor);
|
|
111
|
+
this.tui.addChild(this.statusBar);
|
|
112
|
+
// 设置焦点到 Editor
|
|
113
|
+
this.tui.setFocus(this.editor);
|
|
114
|
+
// 计算布局高度
|
|
115
|
+
this.updateLayoutHeight();
|
|
116
|
+
// 启动 TUI
|
|
117
|
+
this.tui.start();
|
|
118
|
+
this.tui.requestRender();
|
|
119
|
+
// 监听终端大小变化
|
|
120
|
+
process.stdout.on('resize', () => {
|
|
121
|
+
this.updateLayoutHeight();
|
|
122
|
+
this.tui.requestRender();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
updateLayoutHeight() {
|
|
126
|
+
const totalH = this.terminal.rows;
|
|
127
|
+
// 状态栏 1 行,Editor 约 3 行(边框+内容)
|
|
128
|
+
this.splitLayout.height = Math.max(1, totalH - 4);
|
|
129
|
+
}
|
|
130
|
+
handleGlobalInput(data) {
|
|
131
|
+
// Ctrl+Q 退出
|
|
132
|
+
if (matchesKey(data, Key.ctrl('q'))) {
|
|
133
|
+
this.shutdown();
|
|
134
|
+
return { consume: true };
|
|
135
|
+
}
|
|
136
|
+
// 提及弹窗活动时
|
|
137
|
+
if (this.mentionOverlay) {
|
|
138
|
+
// Esc/Enter/↑↓ 由弹窗处理
|
|
139
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter) ||
|
|
140
|
+
matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
141
|
+
this.mentionPopup.handleInput(data);
|
|
142
|
+
this.tui.requestRender();
|
|
143
|
+
return { consume: true };
|
|
144
|
+
}
|
|
145
|
+
// Backspace 更新过滤词
|
|
146
|
+
if (matchesKey(data, Key.backspace)) {
|
|
147
|
+
if (this.mentionFilter.length > 0) {
|
|
148
|
+
this.mentionFilter = this.mentionFilter.slice(0, -1);
|
|
149
|
+
this.mentionPopup.setFilter(this.mentionFilter);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.closeMention();
|
|
153
|
+
}
|
|
154
|
+
this.tui.requestRender();
|
|
155
|
+
// 不消费,让 editor 也处理 backspace
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
// 普通字符输入 → 更新过滤词,同时让字符进入 editor
|
|
159
|
+
if (data.length === 1 && data >= ' ') {
|
|
160
|
+
if (data === ' ') {
|
|
161
|
+
// 空格关闭弹窗
|
|
162
|
+
this.closeMention();
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
this.mentionFilter += data;
|
|
166
|
+
this.mentionPopup.setFilter(this.mentionFilter);
|
|
167
|
+
this.tui.requestRender();
|
|
168
|
+
}
|
|
169
|
+
return undefined; // 让 editor 也收到字符
|
|
170
|
+
}
|
|
171
|
+
// 其他键交给弹窗
|
|
172
|
+
this.mentionPopup.handleInput(data);
|
|
173
|
+
this.tui.requestRender();
|
|
174
|
+
return { consume: true };
|
|
175
|
+
}
|
|
176
|
+
// 线程模式
|
|
177
|
+
if (this.mode === 'chain') {
|
|
178
|
+
return this.handleChainInput(data);
|
|
179
|
+
}
|
|
180
|
+
// 浏览模式
|
|
181
|
+
if (this.mode === 'browse') {
|
|
182
|
+
return this.handleBrowseInput(data);
|
|
183
|
+
}
|
|
184
|
+
// 普通模式
|
|
185
|
+
return this.handleNormalInput(data);
|
|
186
|
+
}
|
|
187
|
+
handleNormalInput(data) {
|
|
188
|
+
// Ctrl+B 进入浏览模式
|
|
189
|
+
if (matchesKey(data, Key.ctrl('b'))) {
|
|
190
|
+
this.setMode('browse');
|
|
191
|
+
return { consume: true };
|
|
192
|
+
}
|
|
193
|
+
// Alt+↑/↓ 切换频道
|
|
194
|
+
if (matchesKey(data, Key.alt('up'))) {
|
|
195
|
+
this.sidebar.moveUp();
|
|
196
|
+
this.onChannelChanged();
|
|
197
|
+
return { consume: true };
|
|
198
|
+
}
|
|
199
|
+
if (matchesKey(data, Key.alt('down'))) {
|
|
200
|
+
this.sidebar.moveDown();
|
|
201
|
+
this.onChannelChanged();
|
|
202
|
+
return { consume: true };
|
|
203
|
+
}
|
|
204
|
+
// Alt+1~9 跳转频道
|
|
205
|
+
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
|
206
|
+
for (let i = 0; i < digits.length; i++) {
|
|
207
|
+
if (matchesKey(data, Key.alt(digits[i]))) {
|
|
208
|
+
this.sidebar.jumpTo(i);
|
|
209
|
+
this.onChannelChanged();
|
|
210
|
+
return { consume: true };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// @ 触发提及弹窗(在 editor 有焦点时)
|
|
214
|
+
if (data === '@') {
|
|
215
|
+
this.showMention();
|
|
216
|
+
// 不消费,让 @ 字符进入 editor
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
// 不消费,让 editor 处理
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
handleBrowseInput(data) {
|
|
223
|
+
if (matchesKey(data, Key.escape)) {
|
|
224
|
+
this.setMode('normal');
|
|
225
|
+
return { consume: true };
|
|
226
|
+
}
|
|
227
|
+
if (data === 'j' || matchesKey(data, Key.down)) {
|
|
228
|
+
this.messageView.moveDown();
|
|
229
|
+
this.tui.requestRender();
|
|
230
|
+
return { consume: true };
|
|
231
|
+
}
|
|
232
|
+
if (data === 'k' || matchesKey(data, Key.up)) {
|
|
233
|
+
this.messageView.moveUp();
|
|
234
|
+
this.tui.requestRender();
|
|
235
|
+
return { consume: true };
|
|
236
|
+
}
|
|
237
|
+
if (data === 'G') {
|
|
238
|
+
this.messageView.jumpToBottom();
|
|
239
|
+
this.tui.requestRender();
|
|
240
|
+
return { consume: true };
|
|
241
|
+
}
|
|
242
|
+
if (data === 'r') {
|
|
243
|
+
const msg = this.messageView.messages[this.messageView.selectedIndex];
|
|
244
|
+
if (msg) {
|
|
245
|
+
this.statusBar.replyTarget = msg.line_number;
|
|
246
|
+
this.setMode('normal');
|
|
247
|
+
}
|
|
248
|
+
return { consume: true };
|
|
249
|
+
}
|
|
250
|
+
if (matchesKey(data, Key.enter)) {
|
|
251
|
+
const msg = this.messageView.messages[this.messageView.selectedIndex];
|
|
252
|
+
if (msg) {
|
|
253
|
+
this.showThread(msg);
|
|
254
|
+
}
|
|
255
|
+
return { consume: true };
|
|
256
|
+
}
|
|
257
|
+
// Alt+↑/↓ 切换频道(在浏览模式也生效)
|
|
258
|
+
if (matchesKey(data, Key.alt('up'))) {
|
|
259
|
+
this.sidebar.moveUp();
|
|
260
|
+
this.onChannelChanged();
|
|
261
|
+
return { consume: true };
|
|
262
|
+
}
|
|
263
|
+
if (matchesKey(data, Key.alt('down'))) {
|
|
264
|
+
this.sidebar.moveDown();
|
|
265
|
+
this.onChannelChanged();
|
|
266
|
+
return { consume: true };
|
|
267
|
+
}
|
|
268
|
+
return { consume: true }; // 浏览模式消费所有输入
|
|
269
|
+
}
|
|
270
|
+
handleChainInput(data) {
|
|
271
|
+
if (matchesKey(data, Key.escape)) {
|
|
272
|
+
this.closeThread();
|
|
273
|
+
return { consume: true };
|
|
274
|
+
}
|
|
275
|
+
if (data === 'j' || matchesKey(data, Key.down)) {
|
|
276
|
+
this.threadView.moveDown();
|
|
277
|
+
this.tui.requestRender();
|
|
278
|
+
return { consume: true };
|
|
279
|
+
}
|
|
280
|
+
if (data === 'k' || matchesKey(data, Key.up)) {
|
|
281
|
+
this.threadView.moveUp();
|
|
282
|
+
this.tui.requestRender();
|
|
283
|
+
return { consume: true };
|
|
284
|
+
}
|
|
285
|
+
if (data === 'r') {
|
|
286
|
+
const msg = this.threadView.messages[this.threadView.selectedIndex];
|
|
287
|
+
if (msg) {
|
|
288
|
+
this.statusBar.replyTarget = msg.line_number;
|
|
289
|
+
this.closeThread();
|
|
290
|
+
this.setMode('normal');
|
|
291
|
+
}
|
|
292
|
+
return { consume: true };
|
|
293
|
+
}
|
|
294
|
+
return { consume: true };
|
|
295
|
+
}
|
|
296
|
+
setMode(mode) {
|
|
297
|
+
this.mode = mode;
|
|
298
|
+
this.statusBar.mode = mode;
|
|
299
|
+
if (mode === 'normal') {
|
|
300
|
+
this.messageView.exitBrowse();
|
|
301
|
+
this.tui.setFocus(this.editor);
|
|
302
|
+
}
|
|
303
|
+
else if (mode === 'browse') {
|
|
304
|
+
this.messageView.enterBrowse();
|
|
305
|
+
this.tui.setFocus(null);
|
|
306
|
+
}
|
|
307
|
+
else if (mode === 'chain') {
|
|
308
|
+
this.tui.setFocus(null);
|
|
309
|
+
}
|
|
310
|
+
this.tui.requestRender();
|
|
311
|
+
}
|
|
312
|
+
async sendMessage(text) {
|
|
313
|
+
this.statusBar.error = null;
|
|
314
|
+
const trimmed = text.trim();
|
|
315
|
+
if (!trimmed)
|
|
316
|
+
return;
|
|
317
|
+
const channel = this.sidebar.currentChannel;
|
|
318
|
+
if (!channel)
|
|
319
|
+
return;
|
|
320
|
+
const params = {
|
|
321
|
+
channel,
|
|
322
|
+
body: trimmed,
|
|
323
|
+
author: this.user,
|
|
324
|
+
};
|
|
325
|
+
if (this.statusBar.replyTarget) {
|
|
326
|
+
params.reply_to = this.statusBar.replyTarget;
|
|
327
|
+
this.statusBar.replyTarget = null;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const res = await this.conn.request('send', params);
|
|
331
|
+
if (res.ok) {
|
|
332
|
+
this.editor.setText('');
|
|
333
|
+
await this.loadMessages();
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
this.statusBar.error = res.error ?? '发送失败';
|
|
337
|
+
this.tui.requestRender();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
this.statusBar.error = e.message ?? '发送失败';
|
|
342
|
+
this.tui.requestRender();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async loadChannels() {
|
|
346
|
+
try {
|
|
347
|
+
const res = await this.conn.request('channels');
|
|
348
|
+
if (res.ok && res.data?.channels) {
|
|
349
|
+
this.sidebar.channels = res.data.channels;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// 忽略
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async loadUsers() {
|
|
357
|
+
try {
|
|
358
|
+
const res = await this.conn.request('users');
|
|
359
|
+
if (res.ok && res.data?.users) {
|
|
360
|
+
this.users = res.data.users;
|
|
361
|
+
this.mentionPopup.allUsers = this.users;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// 忽略
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async loadMessages() {
|
|
369
|
+
const channel = this.sidebar.currentChannel;
|
|
370
|
+
if (!channel)
|
|
371
|
+
return;
|
|
372
|
+
// 防止并发请求导致 FIFO 响应错配
|
|
373
|
+
if (this.loadMessagesInFlight) {
|
|
374
|
+
this.loadMessagesPending = true;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this.loadMessagesInFlight = true;
|
|
378
|
+
try {
|
|
379
|
+
const res = await this.conn.request('read', { channel, limit: 50, since: 0 });
|
|
380
|
+
if (res.ok && res.data?.messages) {
|
|
381
|
+
this.messageView.messages = res.data.messages;
|
|
382
|
+
this.tui.requestRender();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// 忽略
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
this.loadMessagesInFlight = false;
|
|
390
|
+
// 如果有待处理的请求,执行最后一次
|
|
391
|
+
if (this.loadMessagesPending) {
|
|
392
|
+
this.loadMessagesPending = false;
|
|
393
|
+
this.loadMessages();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
onChannelChanged() {
|
|
398
|
+
this.statusBar.channel = this.sidebar.currentChannel;
|
|
399
|
+
this.statusBar.replyTarget = null;
|
|
400
|
+
this.sidebar.unreadCounts.delete(this.sidebar.currentChannel);
|
|
401
|
+
this.messageView.messages = [];
|
|
402
|
+
this.messageView.exitBrowse();
|
|
403
|
+
this.loadMessages();
|
|
404
|
+
this.tui.requestRender();
|
|
405
|
+
}
|
|
406
|
+
async showThread(msg) {
|
|
407
|
+
try {
|
|
408
|
+
const res = await this.conn.request('thread', {
|
|
409
|
+
channel: this.sidebar.currentChannel,
|
|
410
|
+
line_number: msg.line_number,
|
|
411
|
+
});
|
|
412
|
+
if (res.ok && res.data?.messages) {
|
|
413
|
+
this.threadView.messages = res.data.messages;
|
|
414
|
+
this.threadView.rootLine = msg.line_number;
|
|
415
|
+
this.threadView.selectedIndex = 0;
|
|
416
|
+
this.threadOverlay = this.tui.showOverlay(this.threadView, {
|
|
417
|
+
anchor: 'center',
|
|
418
|
+
width: '70%',
|
|
419
|
+
maxHeight: '60%',
|
|
420
|
+
});
|
|
421
|
+
this.setMode('chain');
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// 忽略
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
closeThread() {
|
|
429
|
+
if (this.threadOverlay) {
|
|
430
|
+
this.threadOverlay.hide();
|
|
431
|
+
this.threadOverlay = null;
|
|
432
|
+
}
|
|
433
|
+
if (this.mode === 'chain') {
|
|
434
|
+
this.setMode('browse');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
showMention() {
|
|
438
|
+
this.mentionFilter = '';
|
|
439
|
+
this.mentionPopup.setFilter('');
|
|
440
|
+
this.mentionOverlay = this.tui.showOverlay(this.mentionPopup, {
|
|
441
|
+
anchor: 'bottom-left',
|
|
442
|
+
width: 30,
|
|
443
|
+
maxHeight: 12,
|
|
444
|
+
offsetY: -4,
|
|
445
|
+
offsetX: SIDEBAR_WIDTH + 2,
|
|
446
|
+
});
|
|
447
|
+
this.tui.requestRender();
|
|
448
|
+
}
|
|
449
|
+
closeMention() {
|
|
450
|
+
if (this.mentionOverlay) {
|
|
451
|
+
this.mentionOverlay.hide();
|
|
452
|
+
this.mentionOverlay = null;
|
|
453
|
+
}
|
|
454
|
+
this.tui.requestRender();
|
|
455
|
+
}
|
|
456
|
+
shutdown() {
|
|
457
|
+
this.conn.disconnect();
|
|
458
|
+
this.tui.stop();
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Component } from '@mariozechner/pi-tui';
|
|
2
|
+
export declare const SIDEBAR_WIDTH = 20;
|
|
3
|
+
export declare class ChannelSidebar implements Component {
|
|
4
|
+
channels: string[];
|
|
5
|
+
currentIndex: number;
|
|
6
|
+
unreadCounts: Map<string, number>;
|
|
7
|
+
invalidate(): void;
|
|
8
|
+
get currentChannel(): string;
|
|
9
|
+
selectChannel(name: string): boolean;
|
|
10
|
+
moveUp(): void;
|
|
11
|
+
moveDown(): void;
|
|
12
|
+
jumpTo(n: number): void;
|
|
13
|
+
render(width: number): string[];
|
|
14
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 频道侧边栏组件
|
|
3
|
+
*/
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { truncateToWidth } from '@mariozechner/pi-tui';
|
|
6
|
+
import { colors } from './themes.js';
|
|
7
|
+
export const SIDEBAR_WIDTH = 20;
|
|
8
|
+
export class ChannelSidebar {
|
|
9
|
+
channels = [];
|
|
10
|
+
currentIndex = 0;
|
|
11
|
+
unreadCounts = new Map();
|
|
12
|
+
invalidate() { }
|
|
13
|
+
get currentChannel() {
|
|
14
|
+
return this.channels[this.currentIndex] ?? '';
|
|
15
|
+
}
|
|
16
|
+
selectChannel(name) {
|
|
17
|
+
const idx = this.channels.indexOf(name);
|
|
18
|
+
if (idx >= 0) {
|
|
19
|
+
this.currentIndex = idx;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
moveUp() {
|
|
25
|
+
if (this.currentIndex > 0)
|
|
26
|
+
this.currentIndex--;
|
|
27
|
+
}
|
|
28
|
+
moveDown() {
|
|
29
|
+
if (this.currentIndex < this.channels.length - 1)
|
|
30
|
+
this.currentIndex++;
|
|
31
|
+
}
|
|
32
|
+
jumpTo(n) {
|
|
33
|
+
if (n >= 0 && n < this.channels.length)
|
|
34
|
+
this.currentIndex = n;
|
|
35
|
+
}
|
|
36
|
+
render(width) {
|
|
37
|
+
const w = Math.min(width, SIDEBAR_WIDTH);
|
|
38
|
+
const lines = [];
|
|
39
|
+
// 标题
|
|
40
|
+
lines.push(truncateToWidth(chalk.bold(' 频道'), w));
|
|
41
|
+
lines.push(truncateToWidth(chalk.gray('─'.repeat(w)), w));
|
|
42
|
+
for (let i = 0; i < this.channels.length; i++) {
|
|
43
|
+
const ch = this.channels[i];
|
|
44
|
+
const unread = this.unreadCounts.get(ch) ?? 0;
|
|
45
|
+
const prefix = i === this.currentIndex ? '▸' : ' ';
|
|
46
|
+
let label = `${prefix} #${ch}`;
|
|
47
|
+
if (unread > 0) {
|
|
48
|
+
label += ` (${unread})`;
|
|
49
|
+
}
|
|
50
|
+
if (i === this.currentIndex) {
|
|
51
|
+
lines.push(truncateToWidth(colors.channelActive(label.padEnd(w)), w));
|
|
52
|
+
}
|
|
53
|
+
else if (unread > 0) {
|
|
54
|
+
lines.push(truncateToWidth(colors.unread(label.padEnd(w)), w));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
lines.push(truncateToWidth(colors.channelNormal(label.padEnd(w)), w));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
export interface ApiResponse {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
data?: any;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PushEvent {
|
|
8
|
+
event: string;
|
|
9
|
+
channel: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Message {
|
|
13
|
+
line_number: number;
|
|
14
|
+
point_to: number;
|
|
15
|
+
author: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
body: string;
|
|
18
|
+
}
|
|
19
|
+
export declare class DaemonConnection extends EventEmitter {
|
|
20
|
+
private socketPath;
|
|
21
|
+
private socket;
|
|
22
|
+
private rl;
|
|
23
|
+
private connected;
|
|
24
|
+
private subscribed;
|
|
25
|
+
private pendingRequests;
|
|
26
|
+
private requestId;
|
|
27
|
+
/** Callback for push events (set by consumer) */
|
|
28
|
+
onEvent: ((event: PushEvent) => void) | null;
|
|
29
|
+
constructor(repoRoot: string);
|
|
30
|
+
get isConnected(): boolean;
|
|
31
|
+
/** Connect to daemon and optionally subscribe for push events */
|
|
32
|
+
connect(subscribe?: boolean): Promise<void>;
|
|
33
|
+
/** Send a request and wait for response */
|
|
34
|
+
request(method: string, params?: Record<string, any>): Promise<ApiResponse>;
|
|
35
|
+
/** Disconnect from daemon */
|
|
36
|
+
disconnect(): void;
|
|
37
|
+
private handleLine;
|
|
38
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaemonConnection — 持久连接 + 订阅推送
|
|
3
|
+
*
|
|
4
|
+
* 保持一个到 daemon 的长连接,支持:
|
|
5
|
+
* 1. request-response 模式(发送请求,等待响应)
|
|
6
|
+
* 2. subscribe 模式(接收实时推送事件)
|
|
7
|
+
*/
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import readline from 'node:readline';
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
export class DaemonConnection extends EventEmitter {
|
|
13
|
+
socketPath;
|
|
14
|
+
socket = null;
|
|
15
|
+
rl = null;
|
|
16
|
+
connected = false;
|
|
17
|
+
subscribed = false;
|
|
18
|
+
pendingRequests = new Map();
|
|
19
|
+
requestId = 0;
|
|
20
|
+
/** Callback for push events (set by consumer) */
|
|
21
|
+
onEvent = null;
|
|
22
|
+
constructor(repoRoot) {
|
|
23
|
+
super();
|
|
24
|
+
this.socketPath = path.join(repoRoot, '.gitim', 'run', 'gitim.sock');
|
|
25
|
+
}
|
|
26
|
+
get isConnected() {
|
|
27
|
+
return this.connected;
|
|
28
|
+
}
|
|
29
|
+
/** Connect to daemon and optionally subscribe for push events */
|
|
30
|
+
async connect(subscribe = true) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
this.socket = net.createConnection(this.socketPath);
|
|
33
|
+
this.socket.on('connect', async () => {
|
|
34
|
+
this.connected = true;
|
|
35
|
+
this.rl = readline.createInterface({ input: this.socket });
|
|
36
|
+
this.rl.on('line', (line) => {
|
|
37
|
+
this.handleLine(line);
|
|
38
|
+
});
|
|
39
|
+
if (subscribe) {
|
|
40
|
+
try {
|
|
41
|
+
await this.request('subscribe');
|
|
42
|
+
this.subscribed = true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// subscribe may not be available, continue without it
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.emit('connected');
|
|
49
|
+
resolve();
|
|
50
|
+
});
|
|
51
|
+
this.socket.on('error', (err) => {
|
|
52
|
+
this.connected = false;
|
|
53
|
+
this.emit('error', err);
|
|
54
|
+
reject(new Error(`Cannot connect to daemon: ${err.message}`));
|
|
55
|
+
});
|
|
56
|
+
this.socket.on('close', () => {
|
|
57
|
+
this.connected = false;
|
|
58
|
+
this.subscribed = false;
|
|
59
|
+
this.emit('disconnected');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Send a request and wait for response */
|
|
64
|
+
async request(method, params = {}) {
|
|
65
|
+
if (!this.socket || !this.connected) {
|
|
66
|
+
throw new Error('Not connected to daemon');
|
|
67
|
+
}
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const id = ++this.requestId;
|
|
70
|
+
const payload = JSON.stringify({ method, _req_id: id, ...params }) + '\n';
|
|
71
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
72
|
+
this.socket.write(payload);
|
|
73
|
+
// Timeout after 10 seconds
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
if (this.pendingRequests.has(id)) {
|
|
76
|
+
this.pendingRequests.delete(id);
|
|
77
|
+
reject(new Error(`Request timeout: ${method}`));
|
|
78
|
+
}
|
|
79
|
+
}, 10000);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Disconnect from daemon */
|
|
83
|
+
disconnect() {
|
|
84
|
+
if (this.rl) {
|
|
85
|
+
this.rl.close();
|
|
86
|
+
this.rl = null;
|
|
87
|
+
}
|
|
88
|
+
if (this.socket) {
|
|
89
|
+
this.socket.end();
|
|
90
|
+
this.socket = null;
|
|
91
|
+
}
|
|
92
|
+
this.connected = false;
|
|
93
|
+
this.subscribed = false;
|
|
94
|
+
this.pendingRequests.clear();
|
|
95
|
+
}
|
|
96
|
+
handleLine(line) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(line);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Check if this is a push event (has "event" field)
|
|
105
|
+
if (parsed.event) {
|
|
106
|
+
this.onEvent?.(parsed);
|
|
107
|
+
this.emit('push', parsed);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Otherwise it's a response — resolve the oldest pending request
|
|
111
|
+
// (daemon doesn't echo _req_id back, so we use FIFO order)
|
|
112
|
+
if (this.pendingRequests.size > 0) {
|
|
113
|
+
const [id, handler] = this.pendingRequests.entries().next().value;
|
|
114
|
+
this.pendingRequests.delete(id);
|
|
115
|
+
handler.resolve(parsed);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|