Skip to content

Commit 87661ba

Browse files
feat(cli): add commands and menu (#22)
* fix(cli): disallow sending empty messages * feat(cli): add command menu * feat(cli): implement resuming conversations * feat(cli): implement commands new, delete-all * feat: add copy command * fix: always show command hint before message input * fix: rename command values for better future compability with autocomplete * refactor: use inquirer-autocomplete-prompt to provide menu instead * fix: check for active conversation before copy
1 parent e7bead1 commit 87661ba

File tree

4 files changed

+220
-69
lines changed

4 files changed

+220
-69
lines changed

bin/cli.js

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
#!/usr/bin/env node
22
import fs from 'fs';
33
import { pathToFileURL } from 'url'
4+
import { KeyvFile } from 'keyv-file';
45
import ChatGPTClient from '../src/ChatGPTClient.js';
56
import boxen from 'boxen';
67
import ora from 'ora';
78
import clipboard from 'clipboardy';
89
import inquirer from 'inquirer';
9-
import { KeyvFile } from 'keyv-file';
10+
import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt';
1011

1112
const arg = process.argv.find((arg) => arg.startsWith('--settings'));
1213
let path;
@@ -41,26 +42,83 @@ if (settings.storageFilePath && !settings.cacheOptions.store) {
4142
}
4243

4344
settings.cacheOptions.store = new KeyvFile({ filename: settings.storageFilePath });
44-
// TODO: actually do something with this
4545
}
4646

47+
let conversationId = null;
48+
let parentMessageId = null;
49+
50+
const availableCommands = [
51+
{
52+
name: '!resume - Resume last conversation',
53+
value: '!resume',
54+
},
55+
{
56+
name: '!new - Start new conversation',
57+
value: '!new',
58+
},
59+
{
60+
name: '!copy - Copy conversation to clipboard',
61+
value: '!copy',
62+
},
63+
{
64+
name: '!delete-all - Delete all conversations',
65+
value: '!delete-all',
66+
},
67+
{
68+
name: '!exit - Exit ChatGPT CLI',
69+
value: '!exit',
70+
},
71+
];
72+
73+
inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt);
74+
4775
const chatGptClient = new ChatGPTClient(settings.openaiApiKey, settings.chatGptClient, settings.cacheOptions);
4876

4977
console.log(boxen('ChatGPT CLI', { padding: 0.7, margin: 1, borderStyle: 'double', dimBorder: true }));
5078

5179
await conversation();
5280

53-
async function conversation(conversationId = null, parentMessageId = null) {
54-
const { message } = await inquirer.prompt([
81+
async function conversation() {
82+
console.log('Type "!" to access the command menu.');
83+
const prompt = inquirer.prompt([
5584
{
56-
type: 'input',
85+
type: 'autocomplete',
5786
name: 'message',
5887
message: 'Write a message:',
88+
searchText: '​',
89+
emptyText: '​',
90+
source: (answers, input) => {
91+
return Promise.resolve(
92+
input ? availableCommands.filter((command) => command.value.startsWith(input)) : []
93+
);
94+
}
5995
},
6096
]);
61-
if (message === '!exit') {
62-
return true;
97+
// hiding the ugly autocomplete hint
98+
prompt.ui.activePrompt.firstRender = false;
99+
let { message } = await prompt;
100+
message = message.trim();
101+
if (!message) {
102+
return conversation();
103+
}
104+
if (message.startsWith('!')) {
105+
switch (message) {
106+
case '!resume':
107+
return resumeConversation();
108+
case '!new':
109+
return newConversation();
110+
case '!copy':
111+
return copyConversation();
112+
case '!delete-all':
113+
return deleteAllConversations();
114+
case '!exit':
115+
return true;
116+
}
63117
}
118+
return onMessage(message);
119+
}
120+
121+
async function onMessage(message) {
64122
const chatGptLabel = settings.chatGptClient?.chatGptLabel || 'ChatGPT';
65123
const spinner = ora(`${chatGptLabel} is typing...`);
66124
spinner.prefixText = '\n';
@@ -71,10 +129,68 @@ async function conversation(conversationId = null, parentMessageId = null) {
71129
spinner.stop();
72130
conversationId = response.conversationId;
73131
parentMessageId = response.messageId;
132+
await chatGptClient.conversationsCache.set('lastConversation', {
133+
conversationId,
134+
parentMessageId,
135+
});
74136
console.log(boxen(response.response, { title: chatGptLabel, padding: 0.7, margin: 1, dimBorder: true }));
75137
} catch (error) {
76138
spinner.stop();
77-
console.log(boxen(error?.json?.error?.message || error.body, { title: 'Error', padding: 0.7, margin: 1, borderColor: 'red' }));
139+
logError(error?.json?.error?.message || error.body);
140+
}
141+
return conversation();
142+
}
143+
144+
async function resumeConversation() {
145+
({ conversationId, parentMessageId } = (await chatGptClient.conversationsCache.get('lastConversation')) || {});
146+
if (conversationId) {
147+
logSuccess(`Resumed conversation ${conversationId}.`);
148+
} else {
149+
logWarning('No conversation to resume.');
150+
}
151+
return conversation();
152+
}
153+
154+
async function newConversation() {
155+
conversationId = null;
156+
parentMessageId = null;
157+
logSuccess('Started new conversation.');
158+
return conversation();
159+
}
160+
161+
async function deleteAllConversations() {
162+
await chatGptClient.conversationsCache.clear();
163+
logSuccess('Deleted all conversations.');
164+
return conversation();
165+
}
166+
167+
async function copyConversation() {
168+
if (!conversationId) {
169+
logWarning('No conversation to copy.');
170+
return conversation();
171+
}
172+
const { messages } = await chatGptClient.conversationsCache.get(conversationId);
173+
// get the last message ID
174+
const lastMessageId = messages[messages.length - 1].id;
175+
const orderedMessages = ChatGPTClient.getMessagesForConversation(messages, lastMessageId);
176+
const conversationString = orderedMessages.map((message) => `#### ${message.role}:\n${message.message}`).join('\n\n');
177+
try {
178+
await clipboard.write(`${conversationString}\n\n----\nMade with ChatGPT CLI: <https://github.com/waylaidwanderer/node-chatgpt-api>`);
179+
logSuccess('Copied conversation to clipboard.');
180+
} catch (error) {
181+
logError(error?.message || error);
78182
}
79-
return conversation(conversationId, parentMessageId);
183+
return conversation();
184+
}
185+
186+
function logError(message) {
187+
console.log(boxen(message, { title: 'Error', padding: 0.7, margin: 1, borderColor: 'red' }));
188+
}
189+
190+
function logSuccess(message) {
191+
console.log(boxen(message, { title: 'Success', padding: 0.7, margin: 1, borderColor: 'green' }));
192+
}
193+
194+
function logWarning(message) {
195+
console.log(boxen(message, { title: 'Warning', padding: 0.7, margin: 1, borderColor: 'yellow' }));
80196
}

package-lock.json

Lines changed: 71 additions & 47 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"fastify": "^4.11.0",
3939
"gpt-3-encoder": "^1.1.4",
4040
"inquirer": "^9.1.4",
41+
"inquirer-autocomplete-prompt": "^3.0.0",
4142
"keyv": "^4.5.2",
4243
"keyv-file": "^0.2.0",
4344
"node-fetch": "^3.3.0",

0 commit comments

Comments
 (0)