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 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
- const commandName = `${cmd.name}-cmd`;
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)
@@ -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((arg) => String(arg)).join(' ')}\n`;
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((arg) => String(arg))].join(' '));
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((arg) => String(arg))].join(' '));
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((arg) => String(arg))].join(' '));
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((arg) => String(arg))].join(' '));
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((arg) => String(arg))].join(' '));
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 opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
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,
@@ -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
- stopTyping = startTyping();
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 errorName = error &&
621
- typeof error === 'object' &&
622
- 'constructor' in error &&
623
- error.constructor &&
624
- typeof error.constructor.name === 'string'
625
- ? error.constructor.name
626
- : typeof error;
627
- const errorMsg = error instanceof Error ? error.stack || error.message : String(error);
628
- await sendThreadMessage(thread, `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`);
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
  }
@@ -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
- segments.push({ type: 'list-item', prefix, content: text });
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
- result.push(segment.content);
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
  });