@ask-thane/thane-cli 0.1.2 → 0.1.6
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/README.md +7 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +493 -162
- package/dist/chat.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +12 -0
- package/dist/commands.js.map +1 -1
- package/dist/index.js +239 -0
- package/dist/index.js.map +1 -1
- package/dist/model.d.ts +1 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/slash-commands.d.ts.map +1 -1
- package/dist/slash-commands.js +2 -1
- package/dist/slash-commands.js.map +1 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +61 -0
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
package/dist/chat.js
CHANGED
|
@@ -1,195 +1,526 @@
|
|
|
1
|
-
import { createInterface } from "node:readline/promises";
|
|
2
1
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
-
import {
|
|
4
|
-
import { renderInbox, renderMessages, renderUsers, renderWorkspaces } from "./render.js";
|
|
2
|
+
import { emitKeypressEvents } from "node:readline";
|
|
5
3
|
import { completeSlashCommand, renderSlashCommands, slashCommands } from "./slash-commands.js";
|
|
6
4
|
import { ThaneStore } from "./store.js";
|
|
7
|
-
|
|
5
|
+
const CLEAR = "\x1b[2J\x1b[H";
|
|
6
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
7
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
8
|
+
const RESET = "\x1b[0m";
|
|
9
|
+
const DIM = "\x1b[2m";
|
|
10
|
+
const INVERSE = "\x1b[7m";
|
|
11
|
+
const BOLD = "\x1b[1m";
|
|
12
|
+
function size() {
|
|
13
|
+
return {
|
|
14
|
+
columns: Math.max(60, Number(output.columns ?? 100)),
|
|
15
|
+
rows: Math.max(20, Number(output.rows ?? 30))
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function stripAnsi(value) {
|
|
19
|
+
return value.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
20
|
+
}
|
|
21
|
+
function visibleLength(value) {
|
|
22
|
+
return stripAnsi(value).length;
|
|
23
|
+
}
|
|
24
|
+
function fit(value, width) {
|
|
25
|
+
const plain = stripAnsi(value);
|
|
26
|
+
if (plain.length <= width) {
|
|
27
|
+
return value + " ".repeat(width - plain.length);
|
|
28
|
+
}
|
|
29
|
+
return `${plain.slice(0, Math.max(0, width - 1))}…`;
|
|
30
|
+
}
|
|
31
|
+
function wrap(value, width) {
|
|
32
|
+
if (width <= 0) {
|
|
33
|
+
return [""];
|
|
34
|
+
}
|
|
35
|
+
const words = value.split(/\s+/);
|
|
36
|
+
const lines = [];
|
|
37
|
+
let line = "";
|
|
38
|
+
for (const word of words) {
|
|
39
|
+
if (!line) {
|
|
40
|
+
line = word;
|
|
41
|
+
}
|
|
42
|
+
else if (line.length + word.length + 1 <= width) {
|
|
43
|
+
line = `${line} ${word}`;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
lines.push(line);
|
|
47
|
+
line = word;
|
|
48
|
+
}
|
|
49
|
+
while (line.length > width) {
|
|
50
|
+
lines.push(line.slice(0, width));
|
|
51
|
+
line = line.slice(width);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (line) {
|
|
55
|
+
lines.push(line);
|
|
56
|
+
}
|
|
57
|
+
return lines.length ? lines : [""];
|
|
58
|
+
}
|
|
59
|
+
function channelLabel(channel) {
|
|
60
|
+
return channel.kind === "dm" ? `@${channel.name}` : `#${channel.name}`;
|
|
61
|
+
}
|
|
62
|
+
function conversations(store, activeId) {
|
|
63
|
+
const activity = new Map(store.inbox({ includeQuiet: true }).map((summary) => [summary.conversationId, summary]));
|
|
64
|
+
const channels = store.listChannels().map((channel) => {
|
|
65
|
+
const summary = activity.get(channel.id);
|
|
66
|
+
return {
|
|
67
|
+
id: channel.id,
|
|
68
|
+
label: channelLabel(channel),
|
|
69
|
+
name: channel.name,
|
|
70
|
+
kind: channel.kind,
|
|
71
|
+
unreadCount: summary?.unreadCount ?? 0,
|
|
72
|
+
mentionCount: summary?.mentionCount ?? 0
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
const dms = store.listDms().map((dm) => {
|
|
76
|
+
const summary = activity.get(dm.id);
|
|
77
|
+
return {
|
|
78
|
+
id: dm.id,
|
|
79
|
+
label: channelLabel(dm),
|
|
80
|
+
name: dm.name,
|
|
81
|
+
kind: dm.kind,
|
|
82
|
+
unreadCount: summary?.unreadCount ?? 0,
|
|
83
|
+
mentionCount: summary?.mentionCount ?? 0
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const all = [...channels, ...dms];
|
|
87
|
+
if (!all.some((item) => item.id === activeId)) {
|
|
88
|
+
const channel = store.findChannel(activeId);
|
|
89
|
+
if (channel) {
|
|
90
|
+
all.unshift({
|
|
91
|
+
id: channel.id,
|
|
92
|
+
label: channelLabel(channel),
|
|
93
|
+
name: channel.name,
|
|
94
|
+
kind: channel.kind,
|
|
95
|
+
unreadCount: 0,
|
|
96
|
+
mentionCount: 0
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return all;
|
|
101
|
+
}
|
|
102
|
+
async function selectConversation(store, target) {
|
|
8
103
|
if (target.startsWith("@")) {
|
|
9
|
-
|
|
10
|
-
|
|
104
|
+
return store.findOrCreateDm(target.slice(1));
|
|
105
|
+
}
|
|
106
|
+
return store.createChannel(target.replace(/^#/, ""));
|
|
107
|
+
}
|
|
108
|
+
function renderMessage(message, width) {
|
|
109
|
+
const date = new Date(message.createdAt);
|
|
110
|
+
const time = Number.isNaN(date.getTime())
|
|
111
|
+
? message.createdAt
|
|
112
|
+
: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
113
|
+
const prefix = `${DIM}${time}${RESET} ${BOLD}${message.author}${RESET}: `;
|
|
114
|
+
const bodyWidth = Math.max(10, width - visibleLength(prefix));
|
|
115
|
+
const lines = wrap(message.text, bodyWidth);
|
|
116
|
+
return lines.map((line, index) => (index === 0 ? `${prefix}${line}` : `${" ".repeat(visibleLength(prefix))}${line}`));
|
|
117
|
+
}
|
|
118
|
+
function commandMatches(inputText) {
|
|
119
|
+
if (!inputText.startsWith("/")) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const [names] = completeSlashCommand(inputText);
|
|
123
|
+
return names
|
|
124
|
+
.map((name) => slashCommands.find((command) => command.name === name))
|
|
125
|
+
.filter((command) => Boolean(command));
|
|
126
|
+
}
|
|
127
|
+
function replaceTrailingToken(inputText, value) {
|
|
128
|
+
const match = inputText.match(/(^|\s)(\S*)$/);
|
|
129
|
+
if (!match || match.index === undefined) {
|
|
130
|
+
return value;
|
|
11
131
|
}
|
|
12
|
-
const
|
|
13
|
-
return {
|
|
132
|
+
const start = match.index + (match[1]?.length ?? 0);
|
|
133
|
+
return `${inputText.slice(0, start)}${value}`;
|
|
134
|
+
}
|
|
135
|
+
function completionCandidates(store, inputText) {
|
|
136
|
+
if (inputText.startsWith("/join ")) {
|
|
137
|
+
const prefix = inputText.slice("/join ".length).replace(/^#/, "").toLowerCase();
|
|
138
|
+
return store
|
|
139
|
+
.listChannels()
|
|
140
|
+
.filter((channel) => channel.name.startsWith(prefix))
|
|
141
|
+
.map((channel) => ({ label: `#${channel.name}`, value: `/join ${channel.name}` }));
|
|
142
|
+
}
|
|
143
|
+
if (inputText.startsWith("/workspace ")) {
|
|
144
|
+
const prefix = inputText.slice("/workspace ".length).toLowerCase();
|
|
145
|
+
return store
|
|
146
|
+
.listWorkspaces()
|
|
147
|
+
.filter((workspace) => workspace.slug.startsWith(prefix))
|
|
148
|
+
.map((workspace) => ({ label: workspace.slug, value: `/workspace ${workspace.slug}` }));
|
|
149
|
+
}
|
|
150
|
+
if (inputText.startsWith("/dm ")) {
|
|
151
|
+
const prefix = inputText.slice("/dm ".length).replace(/^@/, "").toLowerCase();
|
|
152
|
+
return store
|
|
153
|
+
.listUsers()
|
|
154
|
+
.filter((user) => user.handle.startsWith(prefix) && user.id !== store.currentUser.id)
|
|
155
|
+
.map((user) => ({ label: `@${user.handle}`, value: `/dm ${user.handle}` }));
|
|
156
|
+
}
|
|
157
|
+
const trailingMention = inputText.match(/(^|\s)@([a-z0-9._-]*)$/i);
|
|
158
|
+
if (trailingMention) {
|
|
159
|
+
const prefix = trailingMention[2]?.toLowerCase() ?? "";
|
|
160
|
+
return store
|
|
161
|
+
.listUsers()
|
|
162
|
+
.filter((user) => user.handle.startsWith(prefix))
|
|
163
|
+
.map((user) => ({ label: `@${user.handle}`, value: replaceTrailingToken(inputText, `@${user.handle}`) }));
|
|
164
|
+
}
|
|
165
|
+
return commandMatches(inputText).map((command) => ({
|
|
166
|
+
label: command.usage,
|
|
167
|
+
value: commandInput(command)
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
function renderMenuLines(selectedIndex, availableRows) {
|
|
171
|
+
const visibleRows = Math.max(1, availableRows - 4);
|
|
172
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(visibleRows / 2), slashCommands.length - visibleRows));
|
|
173
|
+
const visibleCommands = slashCommands.slice(start, start + visibleRows);
|
|
174
|
+
return [
|
|
175
|
+
`${BOLD}Command Menu${RESET}`,
|
|
176
|
+
`${DIM}Use arrows, Return to choose, Esc to close.${RESET}`,
|
|
177
|
+
"",
|
|
178
|
+
...visibleCommands.map((command, index) => {
|
|
179
|
+
const actualIndex = start + index;
|
|
180
|
+
const pointer = actualIndex === selectedIndex ? "> " : " ";
|
|
181
|
+
const row = `${pointer}${command.usage.padEnd(26)} ${command.description}`;
|
|
182
|
+
return actualIndex === selectedIndex ? `${INVERSE}${row}${RESET}` : row;
|
|
183
|
+
})
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
function commandInput(command) {
|
|
187
|
+
return command.needsArgument ? `${command.name} ` : command.name;
|
|
188
|
+
}
|
|
189
|
+
function renderScreen(inputText, state) {
|
|
190
|
+
const { columns, rows } = size();
|
|
191
|
+
const sidebarWidth = Math.min(32, Math.max(24, Math.floor(columns * 0.28)));
|
|
192
|
+
const mainWidth = columns - sidebarWidth - 1;
|
|
193
|
+
const contentRows = rows - 4;
|
|
194
|
+
const active = state.store.findChannel(state.activeChannelId);
|
|
195
|
+
const items = conversations(state.store, state.activeChannelId);
|
|
196
|
+
const messages = state.store.recent(state.activeChannelId, 200);
|
|
197
|
+
const lines = [];
|
|
198
|
+
lines.push(`${CLEAR}${HIDE_CURSOR}${BOLD}Thane Chat${RESET} ${DIM}${state.store.activeWorkspace.slug}${RESET}`);
|
|
199
|
+
lines.push(`${"─".repeat(columns)}`);
|
|
200
|
+
const renderedMessages = messages.flatMap((message) => renderMessage(message, mainWidth - 2));
|
|
201
|
+
const helpLines = state.showMenu
|
|
202
|
+
? renderMenuLines(state.menuIndex, contentRows)
|
|
203
|
+
: state.showHelp
|
|
204
|
+
? [
|
|
205
|
+
`${BOLD}Commands${RESET}`,
|
|
206
|
+
"/join <channel> /dm <handle> /workspace <slug>",
|
|
207
|
+
"/commands /help /quit",
|
|
208
|
+
"",
|
|
209
|
+
...renderSlashCommands().split("\n").slice(0, Math.max(0, contentRows - 5))
|
|
210
|
+
]
|
|
211
|
+
: renderedMessages.slice(-contentRows);
|
|
212
|
+
for (let row = 0; row < contentRows; row += 1) {
|
|
213
|
+
const item = items[row];
|
|
214
|
+
let left = "";
|
|
215
|
+
if (item) {
|
|
216
|
+
const unread = item.unreadCount > 0 ? ` ${item.unreadCount}` : "";
|
|
217
|
+
const mention = item.mentionCount > 0 ? " @" : "";
|
|
218
|
+
const marker = item.id === state.activeChannelId ? ">" : " ";
|
|
219
|
+
left = `${marker} ${item.label}${mention}${unread}`;
|
|
220
|
+
if (item.id === state.activeChannelId) {
|
|
221
|
+
left = `${INVERSE}${fit(left, sidebarWidth)}${RESET}`;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
left = fit(left, sidebarWidth);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
left = " ".repeat(sidebarWidth);
|
|
229
|
+
}
|
|
230
|
+
const right = fit(helpLines[row] ?? "", mainWidth - 1);
|
|
231
|
+
lines.push(`${left}│${right}`);
|
|
232
|
+
}
|
|
233
|
+
const matches = state.showMenu ? [] : completionCandidates(state.store, inputText).slice(0, 4);
|
|
234
|
+
const suggestionStatus = matches.length > 0
|
|
235
|
+
? `${DIM}Tab completes:${RESET} ${matches.map((candidate) => candidate.label).join(" ")}`
|
|
236
|
+
: "";
|
|
237
|
+
const status = suggestionStatus || state.status || `${active ? channelLabel(active) : "conversation"} ${DIM}Enter sends. Up/down recalls input. Alt+up/down switches. /menu opens menu.${RESET}`;
|
|
238
|
+
lines.push(`${"─".repeat(columns)}`);
|
|
239
|
+
lines.push(fit(status, columns));
|
|
240
|
+
lines.push(fit(`> ${inputText}`, columns));
|
|
241
|
+
output.write(lines.join("\n"));
|
|
14
242
|
}
|
|
15
243
|
export async function runChat(initialChannel = "general") {
|
|
16
244
|
let store = await ThaneStore.open();
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
let
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
245
|
+
let activeChannel = await selectConversation(store, initialChannel);
|
|
246
|
+
let inputText = "";
|
|
247
|
+
let status = "";
|
|
248
|
+
let showHelp = false;
|
|
249
|
+
let showMenu = false;
|
|
250
|
+
let menuIndex = 0;
|
|
251
|
+
const inputHistory = [];
|
|
252
|
+
let historyIndex;
|
|
253
|
+
let draftInput = "";
|
|
26
254
|
let isOpen = true;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
store = freshStore;
|
|
36
|
-
const activity = freshStore
|
|
37
|
-
.inbox({ allWorkspaces: true, onlyUnread: true })
|
|
38
|
-
.filter((summary) => !(summary.workspaceId === freshStore.activeWorkspace.id && summary.conversationId === channel));
|
|
39
|
-
const signature = JSON.stringify(activity.map((summary) => [summary.workspaceId, summary.conversationId, summary.unreadCount, summary.mentionCount]));
|
|
40
|
-
if (activity.length > 0 && signature !== lastActivitySignature) {
|
|
41
|
-
lastActivitySignature = signature;
|
|
42
|
-
output.write(`\nActivity elsewhere:\n${renderInbox(activity)}\n`);
|
|
43
|
-
rl.prompt();
|
|
44
|
-
}
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
lastSeenAt = freshMessages.at(-1)?.createdAt ?? lastSeenAt;
|
|
48
|
-
store = freshStore;
|
|
49
|
-
output.write(`\n${renderMessages(freshMessages)}\n`);
|
|
50
|
-
rl.prompt();
|
|
51
|
-
})().catch((error) => {
|
|
52
|
-
output.write(`\n${error.message}\n`);
|
|
53
|
-
rl.prompt();
|
|
54
|
-
});
|
|
55
|
-
}, 2_000);
|
|
56
|
-
output.write(`Thane chat - workspace ${store.activeWorkspace.slug}\n`);
|
|
57
|
-
output.write("Type /commands to see commands, /menu for an arrow-key menu, or press Tab after / for completion.\n\n");
|
|
58
|
-
output.write(`${renderMessages(store.recent(channel, 20))}\n\n`);
|
|
59
|
-
rl.prompt();
|
|
60
|
-
const runSlashLine = async (trimmed) => {
|
|
61
|
-
if (trimmed === "/quit" || trimmed === "/exit") {
|
|
62
|
-
return "quit";
|
|
63
|
-
}
|
|
64
|
-
if (trimmed === "/commands") {
|
|
65
|
-
output.write(`${renderSlashCommands()}\n`);
|
|
66
|
-
}
|
|
67
|
-
else if (trimmed === "/menu") {
|
|
68
|
-
rl.pause();
|
|
69
|
-
const selected = await runSlashMenu(slashCommands);
|
|
70
|
-
rl.resume();
|
|
71
|
-
if (selected) {
|
|
72
|
-
if (selected.needsArgument) {
|
|
73
|
-
output.write(`${selected.usage} - ${selected.description}\n`);
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
return runSlashLine(selected.name);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
255
|
+
const refresh = async () => {
|
|
256
|
+
store = await ThaneStore.open();
|
|
257
|
+
renderScreen(inputText, { store, activeChannelId: activeChannel.id, status, showHelp, showMenu, menuIndex });
|
|
258
|
+
};
|
|
259
|
+
const switchTo = async (conversationId) => {
|
|
260
|
+
const channel = store.findChannel(conversationId);
|
|
261
|
+
if (!channel) {
|
|
262
|
+
return;
|
|
79
263
|
}
|
|
80
|
-
|
|
81
|
-
|
|
264
|
+
activeChannel = channel;
|
|
265
|
+
showHelp = false;
|
|
266
|
+
showMenu = false;
|
|
267
|
+
status = `Switched to ${channelLabel(channel)}`;
|
|
268
|
+
await store.markReadConversation(activeChannel.id);
|
|
269
|
+
await refresh();
|
|
270
|
+
};
|
|
271
|
+
const switchConversation = async (direction) => {
|
|
272
|
+
const items = conversations(store, activeChannel.id);
|
|
273
|
+
const index = Math.max(0, items.findIndex((item) => item.id === activeChannel.id));
|
|
274
|
+
const next = items[(index + direction + items.length) % items.length];
|
|
275
|
+
if (next) {
|
|
276
|
+
await switchTo(next.id);
|
|
82
277
|
}
|
|
83
|
-
|
|
84
|
-
|
|
278
|
+
};
|
|
279
|
+
const rememberInput = (line) => {
|
|
280
|
+
if (!line.trim()) {
|
|
281
|
+
return;
|
|
85
282
|
}
|
|
86
|
-
|
|
87
|
-
|
|
283
|
+
if (inputHistory[inputHistory.length - 1] !== line) {
|
|
284
|
+
inputHistory.push(line);
|
|
88
285
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
output.write(`${renderMessages(store.recent(channel, 20))}\n`);
|
|
96
|
-
}
|
|
97
|
-
else if (trimmed === "/channels") {
|
|
98
|
-
output.write(`${store.listChannels().map((item) => `#${item.name}`).join("\n")}\n`);
|
|
99
|
-
}
|
|
100
|
-
else if (trimmed.startsWith("/join ")) {
|
|
101
|
-
({ channelId: channel, promptName } = await selectChannel(store, trimmed.slice("/join ".length).trim()));
|
|
102
|
-
lastSeenAt = store.recent(channel, 50).at(-1)?.createdAt ?? "";
|
|
103
|
-
lastActivitySignature = "";
|
|
104
|
-
rl.setPrompt(`${store.activeWorkspace.slug}${promptName}> `);
|
|
105
|
-
output.write(`${renderMessages(store.recent(channel, 20))}\n`);
|
|
106
|
-
}
|
|
107
|
-
else if (trimmed === "/leave") {
|
|
108
|
-
if (promptName.startsWith("@")) {
|
|
109
|
-
output.write("DMs cannot be left in the MVP.\n");
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
const leftChannel = await store.leaveChannel(promptName.slice(1));
|
|
113
|
-
({ channelId: channel, promptName } = await selectChannel(store, "general"));
|
|
114
|
-
lastSeenAt = store.recent(channel, 50).at(-1)?.createdAt ?? "";
|
|
115
|
-
lastActivitySignature = "";
|
|
116
|
-
rl.setPrompt(`${store.activeWorkspace.slug}${promptName}> `);
|
|
117
|
-
output.write(`left #${leftChannel.name}\n${renderMessages(store.recent(channel, 20))}\n`);
|
|
118
|
-
}
|
|
286
|
+
historyIndex = undefined;
|
|
287
|
+
draftInput = "";
|
|
288
|
+
};
|
|
289
|
+
const recallInput = (direction) => {
|
|
290
|
+
if (inputHistory.length === 0) {
|
|
291
|
+
return;
|
|
119
292
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
output.write(`${renderUsers(store.channelMembers(promptName.slice(1)))}\n`);
|
|
126
|
-
}
|
|
293
|
+
if (historyIndex === undefined) {
|
|
294
|
+
draftInput = inputText;
|
|
295
|
+
historyIndex = direction === -1 ? inputHistory.length - 1 : 0;
|
|
127
296
|
}
|
|
128
|
-
else
|
|
129
|
-
|
|
130
|
-
lastSeenAt = store.recent(channel, 50).at(-1)?.createdAt ?? "";
|
|
131
|
-
lastActivitySignature = "";
|
|
132
|
-
rl.setPrompt(`${store.activeWorkspace.slug}${promptName}> `);
|
|
133
|
-
output.write(`${renderMessages(store.recent(channel, 20))}\n`);
|
|
297
|
+
else {
|
|
298
|
+
historyIndex += direction;
|
|
134
299
|
}
|
|
135
|
-
|
|
136
|
-
|
|
300
|
+
if (historyIndex < 0) {
|
|
301
|
+
historyIndex = undefined;
|
|
302
|
+
inputText = draftInput;
|
|
303
|
+
draftInput = "";
|
|
304
|
+
return;
|
|
137
305
|
}
|
|
138
|
-
|
|
139
|
-
|
|
306
|
+
if (historyIndex >= inputHistory.length) {
|
|
307
|
+
historyIndex = undefined;
|
|
308
|
+
inputText = draftInput;
|
|
309
|
+
draftInput = "";
|
|
310
|
+
return;
|
|
140
311
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const message = await store.reply(messageId, textParts.join(" "));
|
|
148
|
-
output.write(`sent ${message.id}\n`);
|
|
149
|
-
}
|
|
312
|
+
inputText = inputHistory[historyIndex] ?? "";
|
|
313
|
+
};
|
|
314
|
+
const runLine = async (line) => {
|
|
315
|
+
const trimmed = line.trim();
|
|
316
|
+
if (!trimmed) {
|
|
317
|
+
return;
|
|
150
318
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
319
|
+
if (trimmed === "/quit" || trimmed === "/exit") {
|
|
320
|
+
isOpen = false;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (trimmed === "/menu") {
|
|
324
|
+
showMenu = true;
|
|
325
|
+
showHelp = false;
|
|
326
|
+
status = "Command menu";
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (trimmed === "/help" || trimmed === "/commands") {
|
|
330
|
+
showHelp = !showHelp;
|
|
331
|
+
showMenu = false;
|
|
332
|
+
status = showHelp ? "Command help" : "";
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (trimmed.startsWith("/join ")) {
|
|
336
|
+
activeChannel = await selectConversation(store, trimmed.slice("/join ".length).trim());
|
|
337
|
+
await store.markReadConversation(activeChannel.id);
|
|
338
|
+
status = `Joined ${channelLabel(activeChannel)}`;
|
|
339
|
+
showHelp = false;
|
|
340
|
+
showMenu = false;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (trimmed.startsWith("/dm ")) {
|
|
344
|
+
activeChannel = await selectConversation(store, `@${trimmed.slice("/dm ".length).trim()}`);
|
|
345
|
+
await store.markReadConversation(activeChannel.id);
|
|
346
|
+
status = `Opened ${channelLabel(activeChannel)}`;
|
|
347
|
+
showHelp = false;
|
|
348
|
+
showMenu = false;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (trimmed.startsWith("/workspace ")) {
|
|
352
|
+
const workspace = await store.useWorkspace(trimmed.slice("/workspace ".length).trim());
|
|
353
|
+
activeChannel = await selectConversation(store, "general");
|
|
354
|
+
status = `Switched to workspace ${workspace.slug}`;
|
|
355
|
+
showHelp = false;
|
|
356
|
+
showMenu = false;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (trimmed.startsWith("/")) {
|
|
360
|
+
status = "Unknown command. Type /help.";
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const sent = await store.sendMessage(activeChannel.id, trimmed);
|
|
364
|
+
const messages = store.recent(activeChannel.id, 200);
|
|
365
|
+
status = messages.some((message) => message.id === sent.id) ? "" : "";
|
|
366
|
+
showHelp = false;
|
|
367
|
+
showMenu = false;
|
|
368
|
+
await store.markReadConversation(activeChannel.id);
|
|
369
|
+
};
|
|
370
|
+
const completeInput = () => {
|
|
371
|
+
const matches = completionCandidates(store, inputText);
|
|
372
|
+
if (matches.length === 0) {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
if (matches.length === 1) {
|
|
376
|
+
const match = matches[0];
|
|
377
|
+
if (!match) {
|
|
378
|
+
return false;
|
|
159
379
|
}
|
|
380
|
+
inputText = match.value;
|
|
381
|
+
return true;
|
|
160
382
|
}
|
|
161
|
-
|
|
162
|
-
|
|
383
|
+
const values = matches.map((candidate) => candidate.value);
|
|
384
|
+
let prefix = values[0] ?? inputText;
|
|
385
|
+
for (const value of values.slice(1)) {
|
|
386
|
+
while (!value.startsWith(prefix) && prefix.length > 1) {
|
|
387
|
+
prefix = prefix.slice(0, -1);
|
|
388
|
+
}
|
|
163
389
|
}
|
|
164
|
-
|
|
165
|
-
|
|
390
|
+
if (prefix.length > inputText.length) {
|
|
391
|
+
inputText = prefix;
|
|
166
392
|
}
|
|
167
393
|
else {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
394
|
+
const firstCommand = commandMatches(inputText)[0];
|
|
395
|
+
if (firstCommand) {
|
|
396
|
+
menuIndex = Math.max(0, slashCommands.findIndex((command) => command.name === firstCommand.name));
|
|
397
|
+
showMenu = true;
|
|
398
|
+
showHelp = false;
|
|
399
|
+
status = "Command menu";
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
status = `Matches: ${matches.slice(0, 4).map((candidate) => candidate.label).join(" ")}`;
|
|
403
|
+
}
|
|
171
404
|
}
|
|
172
|
-
return
|
|
405
|
+
return true;
|
|
173
406
|
};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
407
|
+
emitKeypressEvents(input);
|
|
408
|
+
if (input.isTTY) {
|
|
409
|
+
input.setRawMode?.(true);
|
|
410
|
+
}
|
|
411
|
+
input.resume();
|
|
412
|
+
const onKeypress = (_chunk, key) => {
|
|
413
|
+
void (async () => {
|
|
414
|
+
try {
|
|
415
|
+
if ((key.ctrl && key.name === "c") || key.sequence === "\u0003") {
|
|
416
|
+
isOpen = false;
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (showMenu) {
|
|
420
|
+
if (key.name === "escape") {
|
|
421
|
+
showMenu = false;
|
|
422
|
+
status = "";
|
|
423
|
+
}
|
|
424
|
+
else if (key.name === "up") {
|
|
425
|
+
menuIndex = menuIndex === 0 ? slashCommands.length - 1 : menuIndex - 1;
|
|
426
|
+
}
|
|
427
|
+
else if (key.name === "down" || key.name === "tab") {
|
|
428
|
+
menuIndex = menuIndex === slashCommands.length - 1 ? 0 : menuIndex + 1;
|
|
429
|
+
}
|
|
430
|
+
else if (key.name === "return") {
|
|
431
|
+
const selected = slashCommands[menuIndex];
|
|
432
|
+
if (!selected) {
|
|
433
|
+
showMenu = false;
|
|
434
|
+
status = "";
|
|
435
|
+
await refresh();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
showMenu = false;
|
|
439
|
+
if (selected.needsArgument) {
|
|
440
|
+
inputText = commandInput(selected);
|
|
441
|
+
status = `${selected.usage} ${DIM}${selected.description}${RESET}`;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
inputText = "";
|
|
445
|
+
await runLine(selected.name);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!isOpen) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
await refresh();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (key.name === "return") {
|
|
455
|
+
const submitted = inputText;
|
|
456
|
+
inputText = "";
|
|
457
|
+
rememberInput(submitted);
|
|
458
|
+
await runLine(submitted);
|
|
459
|
+
}
|
|
460
|
+
else if (key.name === "backspace") {
|
|
461
|
+
inputText = inputText.slice(0, -1);
|
|
462
|
+
historyIndex = undefined;
|
|
463
|
+
}
|
|
464
|
+
else if (key.name === "up") {
|
|
465
|
+
if (key.ctrl || key.meta || key.sequence === "\u001b[1;5A" || key.sequence === "\u001b[1;3A") {
|
|
466
|
+
await switchConversation(-1);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
recallInput(-1);
|
|
470
|
+
}
|
|
471
|
+
else if (key.name === "down") {
|
|
472
|
+
if (key.ctrl || key.meta || key.sequence === "\u001b[1;5B" || key.sequence === "\u001b[1;3B") {
|
|
473
|
+
await switchConversation(1);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
recallInput(1);
|
|
477
|
+
}
|
|
478
|
+
else if (key.name === "tab") {
|
|
479
|
+
if (completeInput()) {
|
|
480
|
+
await refresh();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (key.name === "escape") {
|
|
485
|
+
showHelp = false;
|
|
486
|
+
showMenu = false;
|
|
487
|
+
status = "";
|
|
488
|
+
}
|
|
489
|
+
else if (key.sequence && key.sequence >= " " && !key.ctrl) {
|
|
490
|
+
inputText += key.sequence;
|
|
491
|
+
historyIndex = undefined;
|
|
492
|
+
}
|
|
493
|
+
if (!isOpen) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
await refresh();
|
|
180
497
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
498
|
+
catch (error) {
|
|
499
|
+
status = error.message;
|
|
500
|
+
await refresh();
|
|
184
501
|
}
|
|
502
|
+
})();
|
|
503
|
+
};
|
|
504
|
+
input.on("keypress", onKeypress);
|
|
505
|
+
const poll = setInterval(() => {
|
|
506
|
+
if (isOpen) {
|
|
507
|
+
void refresh();
|
|
508
|
+
}
|
|
509
|
+
}, 1_500);
|
|
510
|
+
try {
|
|
511
|
+
await store.markReadConversation(activeChannel.id);
|
|
512
|
+
await refresh();
|
|
513
|
+
while (isOpen) {
|
|
514
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
185
515
|
}
|
|
186
|
-
|
|
187
|
-
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
clearInterval(poll);
|
|
519
|
+
input.off("keypress", onKeypress);
|
|
520
|
+
if (input.isTTY) {
|
|
521
|
+
input.setRawMode?.(false);
|
|
188
522
|
}
|
|
189
|
-
|
|
523
|
+
output.write(`${SHOW_CURSOR}${RESET}\x1b[2J\x1b[H`);
|
|
190
524
|
}
|
|
191
|
-
isOpen = false;
|
|
192
|
-
clearInterval(poll);
|
|
193
|
-
rl.close();
|
|
194
525
|
}
|
|
195
526
|
//# sourceMappingURL=chat.js.map
|