kimaki 0.4.33 → 0.4.35
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/cli.js +10 -1
- package/dist/commands/session.js +55 -0
- package/dist/logger.js +13 -6
- package/dist/opencode.js +1 -2
- package/dist/session-handler.js +53 -15
- package/dist/system-message.js +13 -0
- package/dist/unnest-code-blocks.js +10 -3
- package/dist/unnest-code-blocks.test.js +231 -12
- package/package.json +5 -6
- package/src/cli.ts +11 -1
- package/src/commands/session.ts +68 -0
- package/src/logger.ts +20 -12
- package/src/opencode.ts +1 -2
- package/src/session-handler.ts +61 -19
- package/src/system-message.ts +13 -0
- package/src/unnest-code-blocks.test.ts +242 -12
- package/src/unnest-code-blocks.ts +10 -3
package/dist/cli.js
CHANGED
|
@@ -135,6 +135,13 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
135
135
|
.setAutocomplete(true)
|
|
136
136
|
.setMaxLength(6000);
|
|
137
137
|
return option;
|
|
138
|
+
})
|
|
139
|
+
.addStringOption((option) => {
|
|
140
|
+
option
|
|
141
|
+
.setName('agent')
|
|
142
|
+
.setDescription('Agent to use for this session')
|
|
143
|
+
.setAutocomplete(true);
|
|
144
|
+
return option;
|
|
138
145
|
})
|
|
139
146
|
.toJSON(),
|
|
140
147
|
new SlashCommandBuilder()
|
|
@@ -213,7 +220,9 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
213
220
|
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
214
221
|
continue;
|
|
215
222
|
}
|
|
216
|
-
|
|
223
|
+
// Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
|
|
224
|
+
const sanitizedName = cmd.name.replace(/:/g, '-');
|
|
225
|
+
const commandName = `${sanitizedName}-cmd`;
|
|
217
226
|
const description = cmd.description || `Run /${cmd.name} command`;
|
|
218
227
|
commands.push(new SlashCommandBuilder()
|
|
219
228
|
.setName(commandName)
|
package/dist/commands/session.js
CHANGED
|
@@ -13,6 +13,7 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
13
13
|
await command.deferReply({ ephemeral: false });
|
|
14
14
|
const prompt = command.options.getString('prompt', true);
|
|
15
15
|
const filesString = command.options.getString('files') || '';
|
|
16
|
+
const agent = command.options.getString('agent') || undefined;
|
|
16
17
|
const channel = command.channel;
|
|
17
18
|
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
18
19
|
await command.editReply('This command can only be used in text channels');
|
|
@@ -66,6 +67,7 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
66
67
|
thread,
|
|
67
68
|
projectDirectory,
|
|
68
69
|
channelId: textChannel.id,
|
|
70
|
+
agent,
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
catch (error) {
|
|
@@ -73,8 +75,61 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
73
75
|
await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
74
76
|
}
|
|
75
77
|
}
|
|
78
|
+
async function handleAgentAutocomplete({ interaction, appId, }) {
|
|
79
|
+
const focusedValue = interaction.options.getFocused();
|
|
80
|
+
let projectDirectory;
|
|
81
|
+
if (interaction.channel) {
|
|
82
|
+
const channel = interaction.channel;
|
|
83
|
+
if (channel.type === ChannelType.GuildText) {
|
|
84
|
+
const textChannel = channel;
|
|
85
|
+
if (textChannel.topic) {
|
|
86
|
+
const extracted = extractTagsArrays({
|
|
87
|
+
xml: textChannel.topic,
|
|
88
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
89
|
+
});
|
|
90
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
91
|
+
if (channelAppId && channelAppId !== appId) {
|
|
92
|
+
await interaction.respond([]);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!projectDirectory) {
|
|
100
|
+
await interaction.respond([]);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
105
|
+
const agentsResponse = await getClient().app.agents({
|
|
106
|
+
query: { directory: projectDirectory },
|
|
107
|
+
});
|
|
108
|
+
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
109
|
+
await interaction.respond([]);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const agents = agentsResponse.data
|
|
113
|
+
.filter((a) => a.mode === 'primary' || a.mode === 'all')
|
|
114
|
+
.filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
|
115
|
+
.slice(0, 25);
|
|
116
|
+
const choices = agents.map((agent) => ({
|
|
117
|
+
name: agent.name.slice(0, 100),
|
|
118
|
+
value: agent.name,
|
|
119
|
+
}));
|
|
120
|
+
await interaction.respond(choices);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
logger.error('[AUTOCOMPLETE] Error fetching agents:', error);
|
|
124
|
+
await interaction.respond([]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
76
127
|
export async function handleSessionAutocomplete({ interaction, appId, }) {
|
|
77
128
|
const focusedOption = interaction.options.getFocused(true);
|
|
129
|
+
if (focusedOption.name === 'agent') {
|
|
130
|
+
await handleAgentAutocomplete({ interaction, appId });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
78
133
|
if (focusedOption.name !== 'files') {
|
|
79
134
|
return;
|
|
80
135
|
}
|
package/dist/logger.js
CHANGED
|
@@ -5,6 +5,7 @@ import { log } from '@clack/prompts';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path, { dirname } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import util from 'node:util';
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = dirname(__filename);
|
|
10
11
|
const isDev = !__dirname.includes('node_modules');
|
|
@@ -17,35 +18,41 @@ if (isDev) {
|
|
|
17
18
|
}
|
|
18
19
|
fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`);
|
|
19
20
|
}
|
|
21
|
+
function formatArg(arg) {
|
|
22
|
+
if (typeof arg === 'string') {
|
|
23
|
+
return arg;
|
|
24
|
+
}
|
|
25
|
+
return util.inspect(arg, { colors: true, depth: 4 });
|
|
26
|
+
}
|
|
20
27
|
function writeToFile(level, prefix, args) {
|
|
21
28
|
if (!isDev) {
|
|
22
29
|
return;
|
|
23
30
|
}
|
|
24
31
|
const timestamp = new Date().toISOString();
|
|
25
|
-
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(
|
|
32
|
+
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
|
|
26
33
|
fs.appendFileSync(logFilePath, message);
|
|
27
34
|
}
|
|
28
35
|
export function createLogger(prefix) {
|
|
29
36
|
return {
|
|
30
37
|
log: (...args) => {
|
|
31
38
|
writeToFile('INFO', prefix, args);
|
|
32
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
39
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
33
40
|
},
|
|
34
41
|
error: (...args) => {
|
|
35
42
|
writeToFile('ERROR', prefix, args);
|
|
36
|
-
log.error([`[${prefix}]`, ...args.map(
|
|
43
|
+
log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
37
44
|
},
|
|
38
45
|
warn: (...args) => {
|
|
39
46
|
writeToFile('WARN', prefix, args);
|
|
40
|
-
log.warn([`[${prefix}]`, ...args.map(
|
|
47
|
+
log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
41
48
|
},
|
|
42
49
|
info: (...args) => {
|
|
43
50
|
writeToFile('INFO', prefix, args);
|
|
44
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
51
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
45
52
|
},
|
|
46
53
|
debug: (...args) => {
|
|
47
54
|
writeToFile('DEBUG', prefix, args);
|
|
48
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
55
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
49
56
|
},
|
|
50
57
|
};
|
|
51
58
|
}
|
package/dist/opencode.js
CHANGED
|
@@ -87,8 +87,7 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
87
87
|
throw new Error(`Directory does not exist or is not accessible: ${directory}`);
|
|
88
88
|
}
|
|
89
89
|
const port = await getOpenPort();
|
|
90
|
-
const
|
|
91
|
-
const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
|
|
90
|
+
const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
|
|
92
91
|
const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
|
|
93
92
|
stdio: 'pipe',
|
|
94
93
|
detached: false,
|
package/dist/session-handler.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
|
|
3
3
|
// Handles streaming events, permissions, abort signals, and message queuing.
|
|
4
4
|
import prettyMilliseconds from 'pretty-ms';
|
|
5
|
-
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
|
|
5
|
+
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent } from './database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
|
|
7
7
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
|
|
8
8
|
import { formatPart } from './message-formatting.js';
|
|
9
9
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
10
10
|
import { createLogger } from './logger.js';
|
|
11
11
|
import { isAbortError } from './utils.js';
|
|
12
|
-
import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js';
|
|
12
|
+
import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts } from './commands/ask-question.js';
|
|
13
13
|
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
|
|
14
14
|
const sessionLogger = createLogger('SESSION');
|
|
15
15
|
const voiceLogger = createLogger('VOICE');
|
|
@@ -85,7 +85,7 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
|
|
|
85
85
|
});
|
|
86
86
|
return true;
|
|
87
87
|
}
|
|
88
|
-
export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
|
|
88
|
+
export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, agent, }) {
|
|
89
89
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
90
90
|
const sessionStartTime = Date.now();
|
|
91
91
|
const directory = projectDirectory || process.cwd();
|
|
@@ -127,6 +127,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
127
127
|
.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
|
|
128
128
|
.run(thread.id, session.id);
|
|
129
129
|
sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
|
|
130
|
+
// Store agent preference if provided
|
|
131
|
+
if (agent) {
|
|
132
|
+
setSessionAgent(session.id, agent);
|
|
133
|
+
sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`);
|
|
134
|
+
}
|
|
130
135
|
const existingController = abortControllers.get(session.id);
|
|
131
136
|
if (existingController) {
|
|
132
137
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
|
|
@@ -154,11 +159,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
154
159
|
pendingPermissions.delete(thread.id);
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
|
-
// Cancel any pending question tool if user sends a new message
|
|
162
|
+
// Cancel any pending question tool if user sends a new message (silently, no thread message)
|
|
158
163
|
const questionCancelled = await cancelPendingQuestion(thread.id);
|
|
159
164
|
if (questionCancelled) {
|
|
160
165
|
sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
|
|
161
|
-
await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`);
|
|
162
166
|
}
|
|
163
167
|
const abortController = new AbortController();
|
|
164
168
|
abortControllers.set(session.id, abortController);
|
|
@@ -310,7 +314,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
310
314
|
currentParts.push(part);
|
|
311
315
|
}
|
|
312
316
|
if (part.type === 'step-start') {
|
|
313
|
-
|
|
317
|
+
// Don't start typing if user needs to respond to a question or permission
|
|
318
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
319
|
+
const hasPendingPermission = pendingPermissions.has(thread.id);
|
|
320
|
+
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
321
|
+
stopTyping = startTyping();
|
|
322
|
+
}
|
|
314
323
|
}
|
|
315
324
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
316
325
|
// Flush any pending text/reasoning parts before showing the tool
|
|
@@ -348,6 +357,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
348
357
|
if (part.type === 'reasoning') {
|
|
349
358
|
await sendPartMessage(part);
|
|
350
359
|
}
|
|
360
|
+
// Send text parts when complete (time.end is set)
|
|
361
|
+
// Text parts stream incrementally; only send when finished to avoid partial text
|
|
362
|
+
if (part.type === 'text' && part.time?.end) {
|
|
363
|
+
await sendPartMessage(part);
|
|
364
|
+
}
|
|
351
365
|
if (part.type === 'step-finish') {
|
|
352
366
|
for (const p of currentParts) {
|
|
353
367
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -357,6 +371,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
357
371
|
setTimeout(() => {
|
|
358
372
|
if (abortController.signal.aborted)
|
|
359
373
|
return;
|
|
374
|
+
// Don't restart typing if user needs to respond to a question or permission
|
|
375
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
376
|
+
const hasPendingPermission = pendingPermissions.has(thread.id);
|
|
377
|
+
if (hasPendingQuestion || hasPendingPermission)
|
|
378
|
+
return;
|
|
360
379
|
stopTyping = startTyping();
|
|
361
380
|
}, 300);
|
|
362
381
|
}
|
|
@@ -391,6 +410,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
391
410
|
continue;
|
|
392
411
|
}
|
|
393
412
|
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
413
|
+
// Stop typing - user needs to respond now, not the bot
|
|
414
|
+
if (stopTyping) {
|
|
415
|
+
stopTyping();
|
|
416
|
+
stopTyping = null;
|
|
417
|
+
}
|
|
394
418
|
// Show dropdown instead of text message
|
|
395
419
|
const { messageId, contextHash } = await showPermissionDropdown({
|
|
396
420
|
thread,
|
|
@@ -423,6 +447,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
423
447
|
continue;
|
|
424
448
|
}
|
|
425
449
|
sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
450
|
+
// Stop typing - user needs to respond now, not the bot
|
|
451
|
+
if (stopTyping) {
|
|
452
|
+
stopTyping();
|
|
453
|
+
stopTyping = null;
|
|
454
|
+
}
|
|
426
455
|
// Flush any pending text/reasoning parts before showing the dropdown
|
|
427
456
|
// This ensures text the LLM generated before the question tool is shown first
|
|
428
457
|
for (const p of currentParts) {
|
|
@@ -438,6 +467,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
438
467
|
input: { questions: questionRequest.questions },
|
|
439
468
|
});
|
|
440
469
|
}
|
|
470
|
+
else if (event.type === 'session.idle') {
|
|
471
|
+
// Session is done processing - abort to signal completion
|
|
472
|
+
if (event.properties.sessionID === session.id) {
|
|
473
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
|
|
474
|
+
abortController.abort('finished');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
441
477
|
}
|
|
442
478
|
}
|
|
443
479
|
catch (e) {
|
|
@@ -617,15 +653,17 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
617
653
|
discordLogger.log(`Could not update reaction:`, e);
|
|
618
654
|
}
|
|
619
655
|
}
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
656
|
+
const errorDisplay = (() => {
|
|
657
|
+
if (error instanceof Error) {
|
|
658
|
+
const name = error.constructor.name || 'Error';
|
|
659
|
+
return `[${name}]\n${error.stack || error.message}`;
|
|
660
|
+
}
|
|
661
|
+
if (typeof error === 'string') {
|
|
662
|
+
return error;
|
|
663
|
+
}
|
|
664
|
+
return String(error);
|
|
665
|
+
})();
|
|
666
|
+
await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
|
|
629
667
|
}
|
|
630
668
|
}
|
|
631
669
|
}
|
package/dist/system-message.js
CHANGED
|
@@ -65,5 +65,18 @@ headings are discouraged anyway. instead try to use bold text for titles which r
|
|
|
65
65
|
## diagrams
|
|
66
66
|
|
|
67
67
|
you can create diagrams wrapping them in code blocks.
|
|
68
|
+
|
|
69
|
+
## ending conversations with options
|
|
70
|
+
|
|
71
|
+
IMPORTANT: At the end of each response, especially after completing a task or presenting a plan, use the question tool to offer the user clear options for what to do next.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
- After showing a plan: offer "Start implementing?" with Yes/No options
|
|
75
|
+
- After completing edits: offer "Commit changes?" with Yes/No options
|
|
76
|
+
- After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
|
|
77
|
+
|
|
78
|
+
The user can always select "Other" to type a custom response if the provided options don't fit their needs, or if the plan needs updating.
|
|
79
|
+
|
|
80
|
+
This makes the interaction more guided and reduces friction for the user.
|
|
68
81
|
`;
|
|
69
82
|
}
|
|
@@ -31,10 +31,14 @@ function processListToken(list) {
|
|
|
31
31
|
function processListItem(item, prefix) {
|
|
32
32
|
const segments = [];
|
|
33
33
|
let currentText = [];
|
|
34
|
+
// Track if we've seen a code block - text after code uses continuation prefix
|
|
35
|
+
let seenCodeBlock = false;
|
|
34
36
|
const flushText = () => {
|
|
35
37
|
const text = currentText.join('').trim();
|
|
36
38
|
if (text) {
|
|
37
|
-
|
|
39
|
+
// After a code block, use '-' as continuation prefix to avoid repeating numbers
|
|
40
|
+
const effectivePrefix = seenCodeBlock ? '- ' : prefix;
|
|
41
|
+
segments.push({ type: 'list-item', prefix: effectivePrefix, content: text });
|
|
38
42
|
}
|
|
39
43
|
currentText = [];
|
|
40
44
|
};
|
|
@@ -47,6 +51,7 @@ function processListItem(item, prefix) {
|
|
|
47
51
|
type: 'code',
|
|
48
52
|
content: '```' + lang + '\n' + codeToken.text + '\n```\n',
|
|
49
53
|
});
|
|
54
|
+
seenCodeBlock = true;
|
|
50
55
|
}
|
|
51
56
|
else if (token.type === 'list') {
|
|
52
57
|
flushText();
|
|
@@ -102,9 +107,11 @@ function renderSegments(segments) {
|
|
|
102
107
|
}
|
|
103
108
|
else {
|
|
104
109
|
// Raw content (no prefix means it's original raw)
|
|
105
|
-
|
|
110
|
+
// Ensure raw ends with newline for proper separation from next segment
|
|
111
|
+
const raw = segment.content.trimEnd();
|
|
112
|
+
result.push(raw + '\n');
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
|
-
return result.join('');
|
|
116
|
+
return result.join('').trimEnd();
|
|
110
117
|
}
|
|
@@ -11,8 +11,7 @@ test('basic - single item with code block', () => {
|
|
|
11
11
|
|
|
12
12
|
\`\`\`js
|
|
13
13
|
const x = 1
|
|
14
|
-
\`\`\`
|
|
15
|
-
"
|
|
14
|
+
\`\`\`"
|
|
16
15
|
`);
|
|
17
16
|
});
|
|
18
17
|
test('multiple items - code in middle item only', () => {
|
|
@@ -50,8 +49,7 @@ test('multiple code blocks in one item', () => {
|
|
|
50
49
|
\`\`\`
|
|
51
50
|
\`\`\`python
|
|
52
51
|
b = 2
|
|
53
|
-
\`\`\`
|
|
54
|
-
"
|
|
52
|
+
\`\`\`"
|
|
55
53
|
`);
|
|
56
54
|
});
|
|
57
55
|
test('nested list with code', () => {
|
|
@@ -125,8 +123,7 @@ test('mixed - some items have code, some dont', () => {
|
|
|
125
123
|
|
|
126
124
|
\`\`\`python
|
|
127
125
|
y = 2
|
|
128
|
-
\`\`\`
|
|
129
|
-
"
|
|
126
|
+
\`\`\`"
|
|
130
127
|
`);
|
|
131
128
|
});
|
|
132
129
|
test('text before and after code in same item', () => {
|
|
@@ -142,8 +139,7 @@ test('text before and after code in same item', () => {
|
|
|
142
139
|
\`\`\`js
|
|
143
140
|
const x = 1
|
|
144
141
|
\`\`\`
|
|
145
|
-
- End text
|
|
146
|
-
"
|
|
142
|
+
- End text"
|
|
147
143
|
`);
|
|
148
144
|
});
|
|
149
145
|
test('preserves content outside lists', () => {
|
|
@@ -169,7 +165,6 @@ More text after.`;
|
|
|
169
165
|
const x = 1
|
|
170
166
|
\`\`\`
|
|
171
167
|
|
|
172
|
-
|
|
173
168
|
More text after."
|
|
174
169
|
`);
|
|
175
170
|
});
|
|
@@ -195,8 +190,7 @@ test('handles code block without language', () => {
|
|
|
195
190
|
|
|
196
191
|
\`\`\`
|
|
197
192
|
plain code
|
|
198
|
-
\`\`\`
|
|
199
|
-
"
|
|
193
|
+
\`\`\`"
|
|
200
194
|
`);
|
|
201
195
|
});
|
|
202
196
|
test('handles empty list item with code', () => {
|
|
@@ -207,7 +201,232 @@ test('handles empty list item with code', () => {
|
|
|
207
201
|
expect(result).toMatchInlineSnapshot(`
|
|
208
202
|
"\`\`\`js
|
|
209
203
|
const x = 1
|
|
204
|
+
\`\`\`"
|
|
205
|
+
`);
|
|
206
|
+
});
|
|
207
|
+
test('numbered list with text after code block', () => {
|
|
208
|
+
const input = `1. First item
|
|
209
|
+
\`\`\`js
|
|
210
|
+
const a = 1
|
|
211
|
+
\`\`\`
|
|
212
|
+
Text after the code
|
|
213
|
+
2. Second item`;
|
|
214
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
215
|
+
expect(result).toMatchInlineSnapshot(`
|
|
216
|
+
"1. First item
|
|
217
|
+
|
|
218
|
+
\`\`\`js
|
|
219
|
+
const a = 1
|
|
220
|
+
\`\`\`
|
|
221
|
+
- Text after the code
|
|
222
|
+
2. Second item"
|
|
223
|
+
`);
|
|
224
|
+
});
|
|
225
|
+
test('numbered list with multiple code blocks and text between', () => {
|
|
226
|
+
const input = `1. First item
|
|
227
|
+
\`\`\`js
|
|
228
|
+
const a = 1
|
|
229
|
+
\`\`\`
|
|
230
|
+
Middle text
|
|
231
|
+
\`\`\`python
|
|
232
|
+
b = 2
|
|
233
|
+
\`\`\`
|
|
234
|
+
Final text
|
|
235
|
+
2. Second item`;
|
|
236
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
237
|
+
expect(result).toMatchInlineSnapshot(`
|
|
238
|
+
"1. First item
|
|
239
|
+
|
|
240
|
+
\`\`\`js
|
|
241
|
+
const a = 1
|
|
242
|
+
\`\`\`
|
|
243
|
+
- Middle text
|
|
244
|
+
|
|
245
|
+
\`\`\`python
|
|
246
|
+
b = 2
|
|
247
|
+
\`\`\`
|
|
248
|
+
- Final text
|
|
249
|
+
2. Second item"
|
|
250
|
+
`);
|
|
251
|
+
});
|
|
252
|
+
test('unordered list with multiple code blocks and text between', () => {
|
|
253
|
+
const input = `- First item
|
|
254
|
+
\`\`\`js
|
|
255
|
+
const a = 1
|
|
256
|
+
\`\`\`
|
|
257
|
+
Middle text
|
|
258
|
+
\`\`\`python
|
|
259
|
+
b = 2
|
|
260
|
+
\`\`\`
|
|
261
|
+
Final text
|
|
262
|
+
- Second item`;
|
|
263
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
264
|
+
expect(result).toMatchInlineSnapshot(`
|
|
265
|
+
"- First item
|
|
266
|
+
|
|
267
|
+
\`\`\`js
|
|
268
|
+
const a = 1
|
|
269
|
+
\`\`\`
|
|
270
|
+
- Middle text
|
|
271
|
+
|
|
272
|
+
\`\`\`python
|
|
273
|
+
b = 2
|
|
274
|
+
\`\`\`
|
|
275
|
+
- Final text
|
|
276
|
+
- Second item"
|
|
277
|
+
`);
|
|
278
|
+
});
|
|
279
|
+
test('numbered list starting from 5', () => {
|
|
280
|
+
const input = `5. Fifth item
|
|
281
|
+
\`\`\`js
|
|
282
|
+
code
|
|
283
|
+
\`\`\`
|
|
284
|
+
Text after
|
|
285
|
+
6. Sixth item`;
|
|
286
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
287
|
+
expect(result).toMatchInlineSnapshot(`
|
|
288
|
+
"5. Fifth item
|
|
289
|
+
|
|
290
|
+
\`\`\`js
|
|
291
|
+
code
|
|
292
|
+
\`\`\`
|
|
293
|
+
- Text after
|
|
294
|
+
6. Sixth item"
|
|
295
|
+
`);
|
|
296
|
+
});
|
|
297
|
+
test('deeply nested list with code', () => {
|
|
298
|
+
const input = `- Level 1
|
|
299
|
+
- Level 2
|
|
300
|
+
- Level 3
|
|
301
|
+
\`\`\`js
|
|
302
|
+
deep code
|
|
303
|
+
\`\`\`
|
|
304
|
+
Text after deep code
|
|
305
|
+
- Another level 3
|
|
306
|
+
- Back to level 2`;
|
|
307
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
308
|
+
expect(result).toMatchInlineSnapshot(`
|
|
309
|
+
"- Level 1
|
|
310
|
+
- Level 2
|
|
311
|
+
- Level 3
|
|
312
|
+
|
|
313
|
+
\`\`\`js
|
|
314
|
+
deep code
|
|
315
|
+
\`\`\`
|
|
316
|
+
- Text after deep code
|
|
317
|
+
- Another level 3
|
|
318
|
+
- Back to level 2"
|
|
319
|
+
`);
|
|
320
|
+
});
|
|
321
|
+
test('nested numbered list inside unordered with code', () => {
|
|
322
|
+
const input = `- Unordered item
|
|
323
|
+
1. Nested numbered
|
|
324
|
+
\`\`\`js
|
|
325
|
+
code
|
|
326
|
+
\`\`\`
|
|
327
|
+
Text after
|
|
328
|
+
2. Second nested
|
|
329
|
+
- Another unordered`;
|
|
330
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
331
|
+
expect(result).toMatchInlineSnapshot(`
|
|
332
|
+
"- Unordered item
|
|
333
|
+
1. Nested numbered
|
|
334
|
+
|
|
335
|
+
\`\`\`js
|
|
336
|
+
code
|
|
337
|
+
\`\`\`
|
|
338
|
+
- Text after
|
|
339
|
+
2. Second nested
|
|
340
|
+
- Another unordered"
|
|
341
|
+
`);
|
|
342
|
+
});
|
|
343
|
+
test('code block at end of numbered item no text after', () => {
|
|
344
|
+
const input = `1. First with text
|
|
345
|
+
\`\`\`js
|
|
346
|
+
code here
|
|
347
|
+
\`\`\`
|
|
348
|
+
2. Second item
|
|
349
|
+
3. Third item`;
|
|
350
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
351
|
+
expect(result).toMatchInlineSnapshot(`
|
|
352
|
+
"1. First with text
|
|
353
|
+
|
|
354
|
+
\`\`\`js
|
|
355
|
+
code here
|
|
356
|
+
\`\`\`
|
|
357
|
+
2. Second item
|
|
358
|
+
3. Third item"
|
|
359
|
+
`);
|
|
360
|
+
});
|
|
361
|
+
test('multiple items each with code and text after', () => {
|
|
362
|
+
const input = `1. First
|
|
363
|
+
\`\`\`js
|
|
364
|
+
code1
|
|
365
|
+
\`\`\`
|
|
366
|
+
After first
|
|
367
|
+
2. Second
|
|
368
|
+
\`\`\`python
|
|
369
|
+
code2
|
|
370
|
+
\`\`\`
|
|
371
|
+
After second
|
|
372
|
+
3. Third no code`;
|
|
373
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
374
|
+
expect(result).toMatchInlineSnapshot(`
|
|
375
|
+
"1. First
|
|
376
|
+
|
|
377
|
+
\`\`\`js
|
|
378
|
+
code1
|
|
379
|
+
\`\`\`
|
|
380
|
+
- After first
|
|
381
|
+
2. Second
|
|
382
|
+
|
|
383
|
+
\`\`\`python
|
|
384
|
+
code2
|
|
385
|
+
\`\`\`
|
|
386
|
+
- After second
|
|
387
|
+
3. Third no code"
|
|
388
|
+
`);
|
|
389
|
+
});
|
|
390
|
+
test('code block immediately after list marker', () => {
|
|
391
|
+
const input = `1. \`\`\`js
|
|
392
|
+
immediate code
|
|
393
|
+
\`\`\`
|
|
394
|
+
2. Normal item`;
|
|
395
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
396
|
+
expect(result).toMatchInlineSnapshot(`
|
|
397
|
+
"\`\`\`js
|
|
398
|
+
immediate code
|
|
399
|
+
\`\`\`
|
|
400
|
+
2. Normal item"
|
|
401
|
+
`);
|
|
402
|
+
});
|
|
403
|
+
test('code block with filename metadata', () => {
|
|
404
|
+
const input = `- Item with code
|
|
405
|
+
\`\`\`tsx filename=example.tsx
|
|
406
|
+
const x = 1
|
|
407
|
+
\`\`\``;
|
|
408
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
409
|
+
expect(result).toMatchInlineSnapshot(`
|
|
410
|
+
"- Item with code
|
|
411
|
+
|
|
412
|
+
\`\`\`tsx filename=example.tsx
|
|
413
|
+
const x = 1
|
|
414
|
+
\`\`\`"
|
|
415
|
+
`);
|
|
416
|
+
});
|
|
417
|
+
test('numbered list with filename metadata code block', () => {
|
|
418
|
+
const input = `1. First item
|
|
419
|
+
\`\`\`tsx filename=app.tsx
|
|
420
|
+
export default function App() {}
|
|
421
|
+
\`\`\`
|
|
422
|
+
2. Second item`;
|
|
423
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
424
|
+
expect(result).toMatchInlineSnapshot(`
|
|
425
|
+
"1. First item
|
|
426
|
+
|
|
427
|
+
\`\`\`tsx filename=app.tsx
|
|
428
|
+
export default function App() {}
|
|
210
429
|
\`\`\`
|
|
211
|
-
"
|
|
430
|
+
2. Second item"
|
|
212
431
|
`);
|
|
213
432
|
});
|