kimaki 0.4.33 → 0.4.34

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
@@ -213,7 +213,9 @@ async function registerCommands(token, appId, userCommands = []) {
213
213
  if (SKIP_USER_COMMANDS.includes(cmd.name)) {
214
214
  continue;
215
215
  }
216
- const commandName = `${cmd.name}-cmd`;
216
+ // Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
217
+ const sanitizedName = cmd.name.replace(/:/g, '-');
218
+ const commandName = `${sanitizedName}-cmd`;
217
219
  const description = cmd.description || `Run /${cmd.name} command`;
218
220
  commands.push(new SlashCommandBuilder()
219
221
  .setName(commandName)
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
  }
@@ -9,7 +9,7 @@ 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');
@@ -154,11 +154,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
154
154
  pendingPermissions.delete(thread.id);
155
155
  }
156
156
  }
157
- // Cancel any pending question tool if user sends a new message
157
+ // Cancel any pending question tool if user sends a new message (silently, no thread message)
158
158
  const questionCancelled = await cancelPendingQuestion(thread.id);
159
159
  if (questionCancelled) {
160
160
  sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
161
- await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`);
162
161
  }
163
162
  const abortController = new AbortController();
164
163
  abortControllers.set(session.id, abortController);
@@ -310,7 +309,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
310
309
  currentParts.push(part);
311
310
  }
312
311
  if (part.type === 'step-start') {
313
- stopTyping = startTyping();
312
+ // Don't start typing if user needs to respond to a question or permission
313
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
314
+ const hasPendingPermission = pendingPermissions.has(thread.id);
315
+ if (!hasPendingQuestion && !hasPendingPermission) {
316
+ stopTyping = startTyping();
317
+ }
314
318
  }
315
319
  if (part.type === 'tool' && part.state.status === 'running') {
316
320
  // Flush any pending text/reasoning parts before showing the tool
@@ -348,6 +352,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
348
352
  if (part.type === 'reasoning') {
349
353
  await sendPartMessage(part);
350
354
  }
355
+ // Send text parts when complete (time.end is set)
356
+ // Text parts stream incrementally; only send when finished to avoid partial text
357
+ if (part.type === 'text' && part.time?.end) {
358
+ await sendPartMessage(part);
359
+ }
351
360
  if (part.type === 'step-finish') {
352
361
  for (const p of currentParts) {
353
362
  if (p.type !== 'step-start' && p.type !== 'step-finish') {
@@ -357,6 +366,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
357
366
  setTimeout(() => {
358
367
  if (abortController.signal.aborted)
359
368
  return;
369
+ // Don't restart typing if user needs to respond to a question or permission
370
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
371
+ const hasPendingPermission = pendingPermissions.has(thread.id);
372
+ if (hasPendingQuestion || hasPendingPermission)
373
+ return;
360
374
  stopTyping = startTyping();
361
375
  }, 300);
362
376
  }
@@ -391,6 +405,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
391
405
  continue;
392
406
  }
393
407
  sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
408
+ // Stop typing - user needs to respond now, not the bot
409
+ if (stopTyping) {
410
+ stopTyping();
411
+ stopTyping = null;
412
+ }
394
413
  // Show dropdown instead of text message
395
414
  const { messageId, contextHash } = await showPermissionDropdown({
396
415
  thread,
@@ -423,6 +442,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
423
442
  continue;
424
443
  }
425
444
  sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
445
+ // Stop typing - user needs to respond now, not the bot
446
+ if (stopTyping) {
447
+ stopTyping();
448
+ stopTyping = null;
449
+ }
426
450
  // Flush any pending text/reasoning parts before showing the dropdown
427
451
  // This ensures text the LLM generated before the question tool is shown first
428
452
  for (const p of currentParts) {
@@ -438,6 +462,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
438
462
  input: { questions: questionRequest.questions },
439
463
  });
440
464
  }
465
+ else if (event.type === 'session.idle') {
466
+ // Session is done processing - abort to signal completion
467
+ if (event.properties.sessionID === session.id) {
468
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
469
+ abortController.abort('finished');
470
+ }
471
+ }
441
472
  }
442
473
  }
443
474
  catch (e) {
@@ -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();
@@ -211,3 +211,197 @@ test('handles empty list item with code', () => {
211
211
  "
212
212
  `);
213
213
  });
214
+ test('numbered list with text after code block', () => {
215
+ const input = `1. First item
216
+ \`\`\`js
217
+ const a = 1
218
+ \`\`\`
219
+ Text after the code
220
+ 2. Second item`;
221
+ const result = unnestCodeBlocksFromLists(input);
222
+ expect(result).toMatchInlineSnapshot(`
223
+ "1. First item
224
+
225
+ \`\`\`js
226
+ const a = 1
227
+ \`\`\`
228
+ - Text after the code
229
+ 2. Second item"
230
+ `);
231
+ });
232
+ test('numbered list with multiple code blocks and text between', () => {
233
+ const input = `1. First item
234
+ \`\`\`js
235
+ const a = 1
236
+ \`\`\`
237
+ Middle text
238
+ \`\`\`python
239
+ b = 2
240
+ \`\`\`
241
+ Final text
242
+ 2. Second item`;
243
+ const result = unnestCodeBlocksFromLists(input);
244
+ expect(result).toMatchInlineSnapshot(`
245
+ "1. First item
246
+
247
+ \`\`\`js
248
+ const a = 1
249
+ \`\`\`
250
+ - Middle text
251
+
252
+ \`\`\`python
253
+ b = 2
254
+ \`\`\`
255
+ - Final text
256
+ 2. Second item"
257
+ `);
258
+ });
259
+ test('unordered list with multiple code blocks and text between', () => {
260
+ const input = `- First item
261
+ \`\`\`js
262
+ const a = 1
263
+ \`\`\`
264
+ Middle text
265
+ \`\`\`python
266
+ b = 2
267
+ \`\`\`
268
+ Final text
269
+ - Second item`;
270
+ const result = unnestCodeBlocksFromLists(input);
271
+ expect(result).toMatchInlineSnapshot(`
272
+ "- First item
273
+
274
+ \`\`\`js
275
+ const a = 1
276
+ \`\`\`
277
+ - Middle text
278
+
279
+ \`\`\`python
280
+ b = 2
281
+ \`\`\`
282
+ - Final text
283
+ - Second item"
284
+ `);
285
+ });
286
+ test('numbered list starting from 5', () => {
287
+ const input = `5. Fifth item
288
+ \`\`\`js
289
+ code
290
+ \`\`\`
291
+ Text after
292
+ 6. Sixth item`;
293
+ const result = unnestCodeBlocksFromLists(input);
294
+ expect(result).toMatchInlineSnapshot(`
295
+ "5. Fifth item
296
+
297
+ \`\`\`js
298
+ code
299
+ \`\`\`
300
+ - Text after
301
+ 6. Sixth item"
302
+ `);
303
+ });
304
+ test('deeply nested list with code', () => {
305
+ const input = `- Level 1
306
+ - Level 2
307
+ - Level 3
308
+ \`\`\`js
309
+ deep code
310
+ \`\`\`
311
+ Text after deep code
312
+ - Another level 3
313
+ - Back to level 2`;
314
+ const result = unnestCodeBlocksFromLists(input);
315
+ expect(result).toMatchInlineSnapshot(`
316
+ "- Level 1
317
+ - Level 2
318
+ - Level 3
319
+
320
+ \`\`\`js
321
+ deep code
322
+ \`\`\`
323
+ - Text after deep code
324
+ - Another level 3- Back to level 2"
325
+ `);
326
+ });
327
+ test('nested numbered list inside unordered with code', () => {
328
+ const input = `- Unordered item
329
+ 1. Nested numbered
330
+ \`\`\`js
331
+ code
332
+ \`\`\`
333
+ Text after
334
+ 2. Second nested
335
+ - Another unordered`;
336
+ const result = unnestCodeBlocksFromLists(input);
337
+ expect(result).toMatchInlineSnapshot(`
338
+ "- Unordered item
339
+ 1. Nested numbered
340
+
341
+ \`\`\`js
342
+ code
343
+ \`\`\`
344
+ - Text after
345
+ 2. Second nested- Another unordered"
346
+ `);
347
+ });
348
+ test('code block at end of numbered item no text after', () => {
349
+ const input = `1. First with text
350
+ \`\`\`js
351
+ code here
352
+ \`\`\`
353
+ 2. Second item
354
+ 3. Third item`;
355
+ const result = unnestCodeBlocksFromLists(input);
356
+ expect(result).toMatchInlineSnapshot(`
357
+ "1. First with text
358
+
359
+ \`\`\`js
360
+ code here
361
+ \`\`\`
362
+ 2. Second item
363
+ 3. Third item"
364
+ `);
365
+ });
366
+ test('multiple items each with code and text after', () => {
367
+ const input = `1. First
368
+ \`\`\`js
369
+ code1
370
+ \`\`\`
371
+ After first
372
+ 2. Second
373
+ \`\`\`python
374
+ code2
375
+ \`\`\`
376
+ After second
377
+ 3. Third no code`;
378
+ const result = unnestCodeBlocksFromLists(input);
379
+ expect(result).toMatchInlineSnapshot(`
380
+ "1. First
381
+
382
+ \`\`\`js
383
+ code1
384
+ \`\`\`
385
+ - After first
386
+ 2. Second
387
+
388
+ \`\`\`python
389
+ code2
390
+ \`\`\`
391
+ - After second
392
+ 3. Third no code"
393
+ `);
394
+ });
395
+ test('code block immediately after list marker', () => {
396
+ const input = `1. \`\`\`js
397
+ immediate code
398
+ \`\`\`
399
+ 2. Normal item`;
400
+ const result = unnestCodeBlocksFromLists(input);
401
+ expect(result).toMatchInlineSnapshot(`
402
+ "\`\`\`js
403
+ immediate code
404
+ \`\`\`
405
+ 2. Normal item"
406
+ `);
407
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.33",
5
+ "version": "0.4.34",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -30,30 +30,29 @@
30
30
  "tsx": "^4.20.5"
31
31
  },
32
32
  "dependencies": {
33
- "@ai-sdk/google": "^2.0.47",
34
33
  "@clack/prompts": "^0.11.0",
35
- "@discordjs/opus": "^0.10.0",
36
34
  "@discordjs/voice": "^0.19.0",
37
35
  "@google/genai": "^1.34.0",
38
36
  "@opencode-ai/sdk": "^1.1.12",
39
37
  "@purinton/resampler": "^1.0.4",
40
- "@snazzah/davey": "^0.1.6",
41
38
  "ai": "^5.0.114",
42
39
  "better-sqlite3": "^12.3.0",
43
40
  "cac": "^6.7.14",
44
41
  "discord.js": "^14.16.3",
45
42
  "domhandler": "^5.0.3",
46
43
  "glob": "^13.0.0",
47
- "go-try": "^3.0.2",
48
44
  "htmlparser2": "^10.0.0",
49
45
  "js-yaml": "^4.1.0",
50
46
  "marked": "^16.3.0",
51
47
  "picocolors": "^1.1.1",
52
48
  "pretty-ms": "^9.3.0",
53
- "prism-media": "^1.3.5",
54
49
  "ripgrep-js": "^3.0.0",
55
50
  "string-dedent": "^3.0.2",
56
51
  "undici": "^7.16.0",
57
52
  "zod": "^4.2.1"
53
+ },
54
+ "optionalDependencies": {
55
+ "@discordjs/opus": "^0.10.0",
56
+ "prism-media": "^1.3.5"
58
57
  }
59
58
  }
package/src/cli.ts CHANGED
@@ -279,7 +279,9 @@ async function registerCommands(token: string, appId: string, userCommands: Open
279
279
  continue
280
280
  }
281
281
 
282
- const commandName = `${cmd.name}-cmd`
282
+ // Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
283
+ const sanitizedName = cmd.name.replace(/:/g, '-')
284
+ const commandName = `${sanitizedName}-cmd`
283
285
  const description = cmd.description || `Run /${cmd.name} command`
284
286
 
285
287
  commands.push(
package/src/logger.ts CHANGED
@@ -6,6 +6,7 @@ import { log } from '@clack/prompts'
6
6
  import fs from 'node:fs'
7
7
  import path, { dirname } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
+ import util from 'node:util'
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url)
11
12
  const __dirname = dirname(__filename)
@@ -22,36 +23,43 @@ if (isDev) {
22
23
  fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`)
23
24
  }
24
25
 
25
- function writeToFile(level: string, prefix: string, args: any[]) {
26
+ function formatArg(arg: unknown): string {
27
+ if (typeof arg === 'string') {
28
+ return arg
29
+ }
30
+ return util.inspect(arg, { colors: true, depth: 4 })
31
+ }
32
+
33
+ function writeToFile(level: string, prefix: string, args: unknown[]) {
26
34
  if (!isDev) {
27
35
  return
28
36
  }
29
37
  const timestamp = new Date().toISOString()
30
- const message = `[${timestamp}] [${level}] [${prefix}] ${args.map((arg) => String(arg)).join(' ')}\n`
38
+ const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`
31
39
  fs.appendFileSync(logFilePath, message)
32
40
  }
33
41
 
34
42
  export function createLogger(prefix: string) {
35
43
  return {
36
- log: (...args: any[]) => {
44
+ log: (...args: unknown[]) => {
37
45
  writeToFile('INFO', prefix, args)
38
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
46
+ log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
39
47
  },
40
- error: (...args: any[]) => {
48
+ error: (...args: unknown[]) => {
41
49
  writeToFile('ERROR', prefix, args)
42
- log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
50
+ log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '))
43
51
  },
44
- warn: (...args: any[]) => {
52
+ warn: (...args: unknown[]) => {
45
53
  writeToFile('WARN', prefix, args)
46
- log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
54
+ log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '))
47
55
  },
48
- info: (...args: any[]) => {
56
+ info: (...args: unknown[]) => {
49
57
  writeToFile('INFO', prefix, args)
50
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
58
+ log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
51
59
  },
52
- debug: (...args: any[]) => {
60
+ debug: (...args: unknown[]) => {
53
61
  writeToFile('DEBUG', prefix, args)
54
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
62
+ log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
55
63
  },
56
64
  }
57
65
  }
@@ -13,7 +13,7 @@ import { formatPart } from './message-formatting.js'
13
13
  import { getOpencodeSystemMessage } from './system-message.js'
14
14
  import { createLogger } from './logger.js'
15
15
  import { isAbortError } from './utils.js'
16
- import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js'
16
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts } from './commands/ask-question.js'
17
17
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
18
18
 
19
19
  const sessionLogger = createLogger('SESSION')
@@ -239,11 +239,10 @@ export async function handleOpencodeSession({
239
239
  }
240
240
  }
241
241
 
242
- // Cancel any pending question tool if user sends a new message
242
+ // Cancel any pending question tool if user sends a new message (silently, no thread message)
243
243
  const questionCancelled = await cancelPendingQuestion(thread.id)
244
244
  if (questionCancelled) {
245
245
  sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
246
- await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`)
247
246
  }
248
247
 
249
248
  const abortController = new AbortController()
@@ -433,7 +432,14 @@ export async function handleOpencodeSession({
433
432
  }
434
433
 
435
434
  if (part.type === 'step-start') {
436
- stopTyping = startTyping()
435
+ // Don't start typing if user needs to respond to a question or permission
436
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
437
+ (ctx) => ctx.thread.id === thread.id,
438
+ )
439
+ const hasPendingPermission = pendingPermissions.has(thread.id)
440
+ if (!hasPendingQuestion && !hasPendingPermission) {
441
+ stopTyping = startTyping()
442
+ }
437
443
  }
438
444
 
439
445
  if (part.type === 'tool' && part.state.status === 'running') {
@@ -475,6 +481,12 @@ export async function handleOpencodeSession({
475
481
  await sendPartMessage(part)
476
482
  }
477
483
 
484
+ // Send text parts when complete (time.end is set)
485
+ // Text parts stream incrementally; only send when finished to avoid partial text
486
+ if (part.type === 'text' && part.time?.end) {
487
+ await sendPartMessage(part)
488
+ }
489
+
478
490
  if (part.type === 'step-finish') {
479
491
  for (const p of currentParts) {
480
492
  if (p.type !== 'step-start' && p.type !== 'step-finish') {
@@ -483,6 +495,12 @@ export async function handleOpencodeSession({
483
495
  }
484
496
  setTimeout(() => {
485
497
  if (abortController.signal.aborted) return
498
+ // Don't restart typing if user needs to respond to a question or permission
499
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
500
+ (ctx) => ctx.thread.id === thread.id,
501
+ )
502
+ const hasPendingPermission = pendingPermissions.has(thread.id)
503
+ if (hasPendingQuestion || hasPendingPermission) return
486
504
  stopTyping = startTyping()
487
505
  }, 300)
488
506
  }
@@ -527,6 +545,12 @@ export async function handleOpencodeSession({
527
545
  `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
528
546
  )
529
547
 
548
+ // Stop typing - user needs to respond now, not the bot
549
+ if (stopTyping) {
550
+ stopTyping()
551
+ stopTyping = null
552
+ }
553
+
530
554
  // Show dropdown instead of text message
531
555
  const { messageId, contextHash } = await showPermissionDropdown({
532
556
  thread,
@@ -569,6 +593,12 @@ export async function handleOpencodeSession({
569
593
  `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
570
594
  )
571
595
 
596
+ // Stop typing - user needs to respond now, not the bot
597
+ if (stopTyping) {
598
+ stopTyping()
599
+ stopTyping = null
600
+ }
601
+
572
602
  // Flush any pending text/reasoning parts before showing the dropdown
573
603
  // This ensures text the LLM generated before the question tool is shown first
574
604
  for (const p of currentParts) {
@@ -584,6 +614,12 @@ export async function handleOpencodeSession({
584
614
  requestId: questionRequest.id,
585
615
  input: { questions: questionRequest.questions },
586
616
  })
617
+ } else if (event.type === 'session.idle') {
618
+ // Session is done processing - abort to signal completion
619
+ if (event.properties.sessionID === session.id) {
620
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
621
+ abortController.abort('finished')
622
+ }
587
623
  }
588
624
  }
589
625
  } catch (e) {
@@ -66,5 +66,18 @@ headings are discouraged anyway. instead try to use bold text for titles which r
66
66
  ## diagrams
67
67
 
68
68
  you can create diagrams wrapping them in code blocks.
69
+
70
+ ## ending conversations with options
71
+
72
+ 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.
73
+
74
+ Examples:
75
+ - After showing a plan: offer "Start implementing?" with Yes/No options
76
+ - After completing edits: offer "Commit changes?" with Yes/No options
77
+ - After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
78
+
79
+ 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.
80
+
81
+ This makes the interaction more guided and reduces friction for the user.
69
82
  `
70
83
  }
@@ -223,3 +223,206 @@ test('handles empty list item with code', () => {
223
223
  "
224
224
  `)
225
225
  })
226
+
227
+ test('numbered list with text after code block', () => {
228
+ const input = `1. First item
229
+ \`\`\`js
230
+ const a = 1
231
+ \`\`\`
232
+ Text after the code
233
+ 2. Second item`
234
+ const result = unnestCodeBlocksFromLists(input)
235
+ expect(result).toMatchInlineSnapshot(`
236
+ "1. First item
237
+
238
+ \`\`\`js
239
+ const a = 1
240
+ \`\`\`
241
+ - Text after the code
242
+ 2. Second item"
243
+ `)
244
+ })
245
+
246
+ test('numbered list with multiple code blocks and text between', () => {
247
+ const input = `1. First item
248
+ \`\`\`js
249
+ const a = 1
250
+ \`\`\`
251
+ Middle text
252
+ \`\`\`python
253
+ b = 2
254
+ \`\`\`
255
+ Final text
256
+ 2. Second item`
257
+ const result = unnestCodeBlocksFromLists(input)
258
+ expect(result).toMatchInlineSnapshot(`
259
+ "1. First item
260
+
261
+ \`\`\`js
262
+ const a = 1
263
+ \`\`\`
264
+ - Middle text
265
+
266
+ \`\`\`python
267
+ b = 2
268
+ \`\`\`
269
+ - Final text
270
+ 2. Second item"
271
+ `)
272
+ })
273
+
274
+ test('unordered list with multiple code blocks and text between', () => {
275
+ const input = `- First item
276
+ \`\`\`js
277
+ const a = 1
278
+ \`\`\`
279
+ Middle text
280
+ \`\`\`python
281
+ b = 2
282
+ \`\`\`
283
+ Final text
284
+ - Second item`
285
+ const result = unnestCodeBlocksFromLists(input)
286
+ expect(result).toMatchInlineSnapshot(`
287
+ "- First item
288
+
289
+ \`\`\`js
290
+ const a = 1
291
+ \`\`\`
292
+ - Middle text
293
+
294
+ \`\`\`python
295
+ b = 2
296
+ \`\`\`
297
+ - Final text
298
+ - Second item"
299
+ `)
300
+ })
301
+
302
+ test('numbered list starting from 5', () => {
303
+ const input = `5. Fifth item
304
+ \`\`\`js
305
+ code
306
+ \`\`\`
307
+ Text after
308
+ 6. Sixth item`
309
+ const result = unnestCodeBlocksFromLists(input)
310
+ expect(result).toMatchInlineSnapshot(`
311
+ "5. Fifth item
312
+
313
+ \`\`\`js
314
+ code
315
+ \`\`\`
316
+ - Text after
317
+ 6. Sixth item"
318
+ `)
319
+ })
320
+
321
+ test('deeply nested list with code', () => {
322
+ const input = `- Level 1
323
+ - Level 2
324
+ - Level 3
325
+ \`\`\`js
326
+ deep code
327
+ \`\`\`
328
+ Text after deep code
329
+ - Another level 3
330
+ - Back to level 2`
331
+ const result = unnestCodeBlocksFromLists(input)
332
+ expect(result).toMatchInlineSnapshot(`
333
+ "- Level 1
334
+ - Level 2
335
+ - Level 3
336
+
337
+ \`\`\`js
338
+ deep code
339
+ \`\`\`
340
+ - Text after deep code
341
+ - Another level 3- Back to level 2"
342
+ `)
343
+ })
344
+
345
+ test('nested numbered list inside unordered with code', () => {
346
+ const input = `- Unordered item
347
+ 1. Nested numbered
348
+ \`\`\`js
349
+ code
350
+ \`\`\`
351
+ Text after
352
+ 2. Second nested
353
+ - Another unordered`
354
+ const result = unnestCodeBlocksFromLists(input)
355
+ expect(result).toMatchInlineSnapshot(`
356
+ "- Unordered item
357
+ 1. Nested numbered
358
+
359
+ \`\`\`js
360
+ code
361
+ \`\`\`
362
+ - Text after
363
+ 2. Second nested- Another unordered"
364
+ `)
365
+ })
366
+
367
+ test('code block at end of numbered item no text after', () => {
368
+ const input = `1. First with text
369
+ \`\`\`js
370
+ code here
371
+ \`\`\`
372
+ 2. Second item
373
+ 3. Third item`
374
+ const result = unnestCodeBlocksFromLists(input)
375
+ expect(result).toMatchInlineSnapshot(`
376
+ "1. First with text
377
+
378
+ \`\`\`js
379
+ code here
380
+ \`\`\`
381
+ 2. Second item
382
+ 3. Third item"
383
+ `)
384
+ })
385
+
386
+ test('multiple items each with code and text after', () => {
387
+ const input = `1. First
388
+ \`\`\`js
389
+ code1
390
+ \`\`\`
391
+ After first
392
+ 2. Second
393
+ \`\`\`python
394
+ code2
395
+ \`\`\`
396
+ After second
397
+ 3. Third no code`
398
+ const result = unnestCodeBlocksFromLists(input)
399
+ expect(result).toMatchInlineSnapshot(`
400
+ "1. First
401
+
402
+ \`\`\`js
403
+ code1
404
+ \`\`\`
405
+ - After first
406
+ 2. Second
407
+
408
+ \`\`\`python
409
+ code2
410
+ \`\`\`
411
+ - After second
412
+ 3. Third no code"
413
+ `)
414
+ })
415
+
416
+ test('code block immediately after list marker', () => {
417
+ const input = `1. \`\`\`js
418
+ immediate code
419
+ \`\`\`
420
+ 2. Normal item`
421
+ const result = unnestCodeBlocksFromLists(input)
422
+ expect(result).toMatchInlineSnapshot(`
423
+ "\`\`\`js
424
+ immediate code
425
+ \`\`\`
426
+ 2. Normal item"
427
+ `)
428
+ })
@@ -41,11 +41,15 @@ function processListToken(list: Tokens.List): Segment[] {
41
41
  function processListItem(item: Tokens.ListItem, prefix: string): Segment[] {
42
42
  const segments: Segment[] = []
43
43
  let currentText: string[] = []
44
+ // Track if we've seen a code block - text after code uses continuation prefix
45
+ let seenCodeBlock = false
44
46
 
45
47
  const flushText = (): void => {
46
48
  const text = currentText.join('').trim()
47
49
  if (text) {
48
- segments.push({ type: 'list-item', prefix, content: text })
50
+ // After a code block, use '-' as continuation prefix to avoid repeating numbers
51
+ const effectivePrefix = seenCodeBlock ? '- ' : prefix
52
+ segments.push({ type: 'list-item', prefix: effectivePrefix, content: text })
49
53
  }
50
54
  currentText = []
51
55
  }
@@ -59,6 +63,7 @@ function processListItem(item: Tokens.ListItem, prefix: string): Segment[] {
59
63
  type: 'code',
60
64
  content: '```' + lang + '\n' + codeToken.text + '\n```\n',
61
65
  })
66
+ seenCodeBlock = true
62
67
  } else if (token.type === 'list') {
63
68
  flushText()
64
69
  // Recursively process nested list - segments bubble up