kimaki 0.4.30 → 0.4.32

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.
@@ -0,0 +1,213 @@
1
+ import { test, expect } from 'vitest';
2
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
3
+ test('basic - single item with code block', () => {
4
+ const input = `- Item 1
5
+ \`\`\`js
6
+ const x = 1
7
+ \`\`\``;
8
+ const result = unnestCodeBlocksFromLists(input);
9
+ expect(result).toMatchInlineSnapshot(`
10
+ "- Item 1
11
+
12
+ \`\`\`js
13
+ const x = 1
14
+ \`\`\`
15
+ "
16
+ `);
17
+ });
18
+ test('multiple items - code in middle item only', () => {
19
+ const input = `- Item 1
20
+ - Item 2
21
+ \`\`\`js
22
+ const x = 1
23
+ \`\`\`
24
+ - Item 3`;
25
+ const result = unnestCodeBlocksFromLists(input);
26
+ expect(result).toMatchInlineSnapshot(`
27
+ "- Item 1
28
+ - Item 2
29
+
30
+ \`\`\`js
31
+ const x = 1
32
+ \`\`\`
33
+ - Item 3"
34
+ `);
35
+ });
36
+ test('multiple code blocks in one item', () => {
37
+ const input = `- Item with two code blocks
38
+ \`\`\`js
39
+ const a = 1
40
+ \`\`\`
41
+ \`\`\`python
42
+ b = 2
43
+ \`\`\``;
44
+ const result = unnestCodeBlocksFromLists(input);
45
+ expect(result).toMatchInlineSnapshot(`
46
+ "- Item with two code blocks
47
+
48
+ \`\`\`js
49
+ const a = 1
50
+ \`\`\`
51
+ \`\`\`python
52
+ b = 2
53
+ \`\`\`
54
+ "
55
+ `);
56
+ });
57
+ test('nested list with code', () => {
58
+ const input = `- Item 1
59
+ - Nested item
60
+ \`\`\`js
61
+ const x = 1
62
+ \`\`\`
63
+ - Item 2`;
64
+ const result = unnestCodeBlocksFromLists(input);
65
+ expect(result).toMatchInlineSnapshot(`
66
+ "- Item 1
67
+ - Nested item
68
+
69
+ \`\`\`js
70
+ const x = 1
71
+ \`\`\`
72
+ - Item 2"
73
+ `);
74
+ });
75
+ test('ordered list preserves numbering', () => {
76
+ const input = `1. First item
77
+ \`\`\`js
78
+ const a = 1
79
+ \`\`\`
80
+ 2. Second item
81
+ 3. Third item`;
82
+ const result = unnestCodeBlocksFromLists(input);
83
+ expect(result).toMatchInlineSnapshot(`
84
+ "1. First item
85
+
86
+ \`\`\`js
87
+ const a = 1
88
+ \`\`\`
89
+ 2. Second item
90
+ 3. Third item"
91
+ `);
92
+ });
93
+ test('list without code blocks unchanged', () => {
94
+ const input = `- Item 1
95
+ - Item 2
96
+ - Item 3`;
97
+ const result = unnestCodeBlocksFromLists(input);
98
+ expect(result).toMatchInlineSnapshot(`
99
+ "- Item 1
100
+ - Item 2
101
+ - Item 3"
102
+ `);
103
+ });
104
+ test('mixed - some items have code, some dont', () => {
105
+ const input = `- Normal item
106
+ - Item with code
107
+ \`\`\`js
108
+ const x = 1
109
+ \`\`\`
110
+ - Another normal item
111
+ - Another with code
112
+ \`\`\`python
113
+ y = 2
114
+ \`\`\``;
115
+ const result = unnestCodeBlocksFromLists(input);
116
+ expect(result).toMatchInlineSnapshot(`
117
+ "- Normal item
118
+ - Item with code
119
+
120
+ \`\`\`js
121
+ const x = 1
122
+ \`\`\`
123
+ - Another normal item
124
+ - Another with code
125
+
126
+ \`\`\`python
127
+ y = 2
128
+ \`\`\`
129
+ "
130
+ `);
131
+ });
132
+ test('text before and after code in same item', () => {
133
+ const input = `- Start text
134
+ \`\`\`js
135
+ const x = 1
136
+ \`\`\`
137
+ End text`;
138
+ const result = unnestCodeBlocksFromLists(input);
139
+ expect(result).toMatchInlineSnapshot(`
140
+ "- Start text
141
+
142
+ \`\`\`js
143
+ const x = 1
144
+ \`\`\`
145
+ - End text
146
+ "
147
+ `);
148
+ });
149
+ test('preserves content outside lists', () => {
150
+ const input = `# Heading
151
+
152
+ Some paragraph text.
153
+
154
+ - List item
155
+ \`\`\`js
156
+ const x = 1
157
+ \`\`\`
158
+
159
+ More text after.`;
160
+ const result = unnestCodeBlocksFromLists(input);
161
+ expect(result).toMatchInlineSnapshot(`
162
+ "# Heading
163
+
164
+ Some paragraph text.
165
+
166
+ - List item
167
+
168
+ \`\`\`js
169
+ const x = 1
170
+ \`\`\`
171
+
172
+
173
+ More text after."
174
+ `);
175
+ });
176
+ test('code block at root level unchanged', () => {
177
+ const input = `\`\`\`js
178
+ const x = 1
179
+ \`\`\``;
180
+ const result = unnestCodeBlocksFromLists(input);
181
+ expect(result).toMatchInlineSnapshot(`
182
+ "\`\`\`js
183
+ const x = 1
184
+ \`\`\`"
185
+ `);
186
+ });
187
+ test('handles code block without language', () => {
188
+ const input = `- Item
189
+ \`\`\`
190
+ plain code
191
+ \`\`\``;
192
+ const result = unnestCodeBlocksFromLists(input);
193
+ expect(result).toMatchInlineSnapshot(`
194
+ "- Item
195
+
196
+ \`\`\`
197
+ plain code
198
+ \`\`\`
199
+ "
200
+ `);
201
+ });
202
+ test('handles empty list item with code', () => {
203
+ const input = `- \`\`\`js
204
+ const x = 1
205
+ \`\`\``;
206
+ const result = unnestCodeBlocksFromLists(input);
207
+ expect(result).toMatchInlineSnapshot(`
208
+ "\`\`\`js
209
+ const x = 1
210
+ \`\`\`
211
+ "
212
+ `);
213
+ });
package/dist/utils.js CHANGED
@@ -17,6 +17,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
17
17
  PermissionsBitField.Flags.AttachFiles,
18
18
  PermissionsBitField.Flags.Connect,
19
19
  PermissionsBitField.Flags.Speak,
20
+ PermissionsBitField.Flags.ManageRoles,
20
21
  ], scopes = ['bot'], guildId, disableGuildSelect = false, }) {
21
22
  const permissionsBitField = new PermissionsBitField(permissions);
22
23
  const permissionsValue = permissionsBitField.bitfield.toString();
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.30",
5
+ "version": "0.4.32",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
package/src/cli.ts CHANGED
@@ -529,8 +529,14 @@ async function run({ restart, addChannels }: CliOptions) {
529
529
  }
530
530
 
531
531
  const s = spinner()
532
- s.start('Creating Discord client and connecting...')
533
532
 
533
+ // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
534
+ // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
535
+ const currentDir = process.cwd()
536
+ s.start('Starting OpenCode server...')
537
+ const opencodePromise = initializeOpencodeForDirectory(currentDir)
538
+
539
+ s.message('Connecting to Discord...')
534
540
  const discordClient = await createDiscordClient()
535
541
 
536
542
  const guilds: Guild[] = []
@@ -542,15 +548,56 @@ async function run({ restart, addChannels }: CliOptions) {
542
548
  discordClient.once(Events.ClientReady, async (c) => {
543
549
  guilds.push(...Array.from(c.guilds.cache.values()))
544
550
 
545
- for (const guild of guilds) {
546
- const channels = await getChannelsWithDescriptions(guild)
547
- const kimakiChans = channels.filter(
548
- (ch) =>
549
- ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
550
- )
551
+ // Process all guilds in parallel for faster startup
552
+ const guildResults = await Promise.all(
553
+ guilds.map(async (guild) => {
554
+ // Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
555
+ guild.roles
556
+ .fetch()
557
+ .then(async (roles) => {
558
+ const existingRole = roles.find(
559
+ (role) => role.name.toLowerCase() === 'kimaki',
560
+ )
561
+ if (existingRole) {
562
+ // Move to bottom if not already there
563
+ if (existingRole.position > 1) {
564
+ await existingRole.setPosition(1)
565
+ cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`)
566
+ }
567
+ return
568
+ }
569
+ return guild.roles.create({
570
+ name: 'Kimaki',
571
+ position: 1, // Place at bottom so anyone with Manage Roles can assign it
572
+ reason:
573
+ 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
574
+ })
575
+ })
576
+ .then((role) => {
577
+ if (role) {
578
+ cliLogger.info(`Created "Kimaki" role in ${guild.name}`)
579
+ }
580
+ })
581
+ .catch((error) => {
582
+ cliLogger.warn(
583
+ `Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`,
584
+ )
585
+ })
586
+
587
+ const channels = await getChannelsWithDescriptions(guild)
588
+ const kimakiChans = channels.filter(
589
+ (ch) =>
590
+ ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
591
+ )
592
+
593
+ return { guild, channels: kimakiChans }
594
+ }),
595
+ )
551
596
 
552
- if (kimakiChans.length > 0) {
553
- kimakiChannels.push({ guild, channels: kimakiChans })
597
+ // Collect results
598
+ for (const result of guildResults) {
599
+ if (result.channels.length > 0) {
600
+ kimakiChannels.push(result)
554
601
  }
555
602
  }
556
603
 
@@ -613,32 +660,25 @@ async function run({ restart, addChannels }: CliOptions) {
613
660
  note(channelList, 'Existing Kimaki Channels')
614
661
  }
615
662
 
616
- s.start('Starting OpenCode server...')
617
-
618
- const currentDir = process.cwd()
619
- let getClient = await initializeOpencodeForDirectory(currentDir)
620
- s.stop('OpenCode server started!')
663
+ // Await the OpenCode server that was started in parallel with Discord login
664
+ s.start('Waiting for OpenCode server...')
665
+ const getClient = await opencodePromise
666
+ s.stop('OpenCode server ready!')
621
667
 
622
- s.start('Fetching OpenCode projects...')
668
+ s.start('Fetching OpenCode data...')
623
669
 
624
- let projects: Project[] = []
670
+ // Fetch projects and commands in parallel
671
+ const [projects, allUserCommands] = await Promise.all([
672
+ getClient().project.list({}).then((r) => r.data || []).catch((error) => {
673
+ s.stop('Failed to fetch projects')
674
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
675
+ discordClient.destroy()
676
+ process.exit(EXIT_NO_RESTART)
677
+ }),
678
+ getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
679
+ ])
625
680
 
626
- try {
627
- const projectsResponse = await getClient().project.list({})
628
- if (!projectsResponse.data) {
629
- throw new Error('Failed to fetch projects')
630
- }
631
- projects = projectsResponse.data
632
- s.stop(`Found ${projects.length} OpenCode project(s)`)
633
- } catch (error) {
634
- s.stop('Failed to fetch projects')
635
- cliLogger.error(
636
- 'Error:',
637
- error instanceof Error ? error.message : String(error),
638
- )
639
- discordClient.destroy()
640
- process.exit(EXIT_NO_RESTART)
641
- }
681
+ s.stop(`Found ${projects.length} OpenCode project(s)`)
642
682
 
643
683
  const existingDirs = kimakiChannels.flatMap(({ channels }) =>
644
684
  channels
@@ -746,19 +786,6 @@ async function run({ restart, addChannels }: CliOptions) {
746
786
  }
747
787
  }
748
788
 
749
- // Fetch user-defined commands using the already-running server
750
- const allUserCommands: OpencodeCommand[] = []
751
- try {
752
- const commandsResponse = await getClient().command.list({
753
- query: { directory: currentDir },
754
- })
755
- if (commandsResponse.data) {
756
- allUserCommands.push(...commandsResponse.data)
757
- }
758
- } catch {
759
- // Ignore errors fetching commands
760
- }
761
-
762
789
  // Log available user commands
763
790
  const registrableCommands = allUserCommands.filter(
764
791
  (cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
@@ -839,13 +866,31 @@ cli
839
866
  '--data-dir <path>',
840
867
  'Data directory for config and database (default: ~/.kimaki)',
841
868
  )
842
- .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string }) => {
869
+ .option('--install-url', 'Print the bot install URL and exit')
870
+ .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
843
871
  try {
844
872
  // Set data directory early, before any database access
845
873
  if (options.dataDir) {
846
874
  setDataDir(options.dataDir)
847
875
  cliLogger.log(`Using data directory: ${getDataDir()}`)
848
876
  }
877
+
878
+ if (options.installUrl) {
879
+ const db = getDatabase()
880
+ const existingBot = db
881
+ .prepare(
882
+ 'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
883
+ )
884
+ .get() as { app_id: string } | undefined
885
+
886
+ if (!existingBot) {
887
+ cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
888
+ process.exit(EXIT_NO_RESTART)
889
+ }
890
+
891
+ console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
892
+ process.exit(0)
893
+ }
849
894
 
850
895
  await checkSingleInstance()
851
896
  await startLockServer()
@@ -10,7 +10,7 @@ import {
10
10
  } from 'discord.js'
11
11
  import crypto from 'node:crypto'
12
12
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
13
- import { getOpencodeServerPort } from '../opencode.js'
13
+ import { getOpencodeClientV2 } from '../opencode.js'
14
14
  import { createLogger } from '../logger.js'
15
15
 
16
16
  const logger = createLogger('ASK_QUESTION')
@@ -200,31 +200,20 @@ async function submitQuestionAnswers(
200
200
  context: PendingQuestionContext
201
201
  ): Promise<void> {
202
202
  try {
203
- // Build answers array: each element is an array of selected labels for that question
204
- const answersPayload = context.questions.map((_, i) => {
205
- return context.answers[i] || []
206
- })
207
-
208
- // Reply to the question using direct HTTP call to OpenCode API
209
- // (v1 SDK doesn't have question.reply, so we call it directly)
210
- const port = getOpencodeServerPort(context.directory)
211
- if (!port) {
203
+ const clientV2 = getOpencodeClientV2(context.directory)
204
+ if (!clientV2) {
212
205
  throw new Error('OpenCode server not found for directory')
213
206
  }
214
207
 
215
- const response = await fetch(
216
- `http://127.0.0.1:${port}/question/${context.requestId}/reply`,
217
- {
218
- method: 'POST',
219
- headers: { 'Content-Type': 'application/json' },
220
- body: JSON.stringify({ answers: answersPayload }),
221
- }
222
- )
208
+ // Build answers array: each element is an array of selected labels for that question
209
+ const answers = context.questions.map((_, i) => {
210
+ return context.answers[i] || []
211
+ })
223
212
 
224
- if (!response.ok) {
225
- const text = await response.text()
226
- throw new Error(`Failed to reply to question: ${response.status} ${text}`)
227
- }
213
+ await clientV2.question.reply({
214
+ requestID: context.requestId,
215
+ answers,
216
+ })
228
217
 
229
218
  logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
230
219
  } catch (error) {
@@ -275,3 +264,49 @@ export function parseAskUserQuestionTool(part: {
275
264
 
276
265
  return input
277
266
  }
267
+
268
+ /**
269
+ * Cancel a pending question for a thread (e.g., when user sends a new message).
270
+ * Sends cancellation response to OpenCode so the session can continue.
271
+ */
272
+ export async function cancelPendingQuestion(threadId: string): Promise<boolean> {
273
+ // Find pending question for this thread
274
+ let contextHash: string | undefined
275
+ let context: PendingQuestionContext | undefined
276
+ for (const [hash, ctx] of pendingQuestionContexts) {
277
+ if (ctx.thread.id === threadId) {
278
+ contextHash = hash
279
+ context = ctx
280
+ break
281
+ }
282
+ }
283
+
284
+ if (!contextHash || !context) {
285
+ return false
286
+ }
287
+
288
+ try {
289
+ const clientV2 = getOpencodeClientV2(context.directory)
290
+ if (!clientV2) {
291
+ throw new Error('OpenCode server not found for directory')
292
+ }
293
+
294
+ // Preserve already-answered questions, mark unanswered as cancelled
295
+ const answers = context.questions.map((_, i) => {
296
+ return context.answers[i] || ['(cancelled - user sent new message)']
297
+ })
298
+
299
+ await clientV2.question.reply({
300
+ requestID: context.requestId,
301
+ answers,
302
+ })
303
+
304
+ logger.log(`Cancelled question ${context.requestId} due to new user message`)
305
+ } catch (error) {
306
+ logger.error('Failed to cancel question:', error)
307
+ }
308
+
309
+ // Clean up regardless of whether the API call succeeded
310
+ pendingQuestionContexts.delete(contextHash)
311
+ return true
312
+ }
@@ -180,7 +180,10 @@ export async function startDiscordBot({
180
180
  )
181
181
 
182
182
  if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
183
- await message.react('🔒')
183
+ await message.reply({
184
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
185
+ flags: SILENT_MESSAGE_FLAGS,
186
+ })
184
187
  return
185
188
  }
186
189
  }
@@ -11,6 +11,7 @@ import {
11
11
  import { Lexer } from 'marked'
12
12
  import { extractTagsArrays } from './xml.js'
13
13
  import { formatMarkdownTables } from './format-tables.js'
14
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
14
15
  import { createLogger } from './logger.js'
15
16
 
16
17
  const discordLogger = createLogger('DISCORD')
@@ -125,7 +126,8 @@ export function splitMarkdownForDiscord({
125
126
 
126
127
  // calculate overhead for code block markers
127
128
  const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
128
- const availablePerChunk = maxLength - codeBlockOverhead - 50 // safety margin
129
+ // ensure at least 10 chars available, even if maxLength is very small
130
+ const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50)
129
131
 
130
132
  const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
131
133
 
@@ -198,6 +200,7 @@ export async function sendThreadMessage(
198
200
  const MAX_LENGTH = 2000
199
201
 
200
202
  content = formatMarkdownTables(content)
203
+ content = unnestCodeBlocksFromLists(content)
201
204
  content = escapeBackticksInCodeBlocks(content)
202
205
 
203
206
  // If custom flags provided, send as single message (no chunking)
@@ -376,11 +376,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
376
376
  \`\`\`
377
377
  ",
378
378
  "\`\`\`js
379
- veryverylonglinethatexceedsmaxlength
380
- \`\`\`
379
+ veryverylo\`\`\`
381
380
  ",
382
381
  "\`\`\`js
383
- short
382
+ nglinethat\`\`\`
383
+ ",
384
+ "\`\`\`js
385
+ exceedsmax\`\`\`
386
+ ",
387
+ "\`\`\`js
388
+ length
389
+ \`\`\`
390
+ ",
391
+ "short
384
392
  \`\`\`
385
393
  ",
386
394
  ]
@@ -8,12 +8,12 @@ import type { Message, ThreadChannel } from 'discord.js'
8
8
  import prettyMilliseconds from 'pretty-ms'
9
9
  import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
10
10
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
11
- import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js'
11
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
12
12
  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 } from './commands/ask-question.js'
16
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js'
17
17
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
18
18
 
19
19
  const sessionLogger = createLogger('SESSION')
@@ -239,6 +239,13 @@ export async function handleOpencodeSession({
239
239
  }
240
240
  }
241
241
 
242
+ // Cancel any pending question tool if user sends a new message
243
+ const questionCancelled = await cancelPendingQuestion(thread.id)
244
+ if (questionCancelled) {
245
+ sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
246
+ await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`)
247
+ }
248
+
242
249
  const abortController = new AbortController()
243
250
  abortControllers.set(session.id, abortController)
244
251
 
@@ -399,7 +406,8 @@ export async function handleOpencodeSession({
399
406
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
400
407
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
401
408
  lastDisplayedContextPercentage = thresholdCrossed
402
- await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`)
409
+ const chunk = `⬦ context usage ${currentPercentage}%`
410
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
403
411
  }
404
412
  }
405
413
  }
@@ -429,9 +437,40 @@ export async function handleOpencodeSession({
429
437
  }
430
438
 
431
439
  if (part.type === 'tool' && part.state.status === 'running') {
440
+ // Flush any pending text/reasoning parts before showing the tool
441
+ // This ensures text the LLM generated before the tool call is shown first
442
+ for (const p of currentParts) {
443
+ if (p.type !== 'step-start' && p.type !== 'step-finish' && p.id !== part.id) {
444
+ await sendPartMessage(p)
445
+ }
446
+ }
432
447
  await sendPartMessage(part)
433
448
  }
434
449
 
450
+ // Show token usage for completed tools with large output (>5k tokens)
451
+ if (part.type === 'tool' && part.state.status === 'completed') {
452
+ const output = part.state.output || ''
453
+ const outputTokens = Math.ceil(output.length / 4)
454
+ const LARGE_OUTPUT_THRESHOLD = 3000
455
+ if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
456
+ const formattedTokens = outputTokens >= 1000
457
+ ? `${(outputTokens / 1000).toFixed(1)}k`
458
+ : String(outputTokens)
459
+ const percentageSuffix = (() => {
460
+ if (!modelContextLimit) {
461
+ return ''
462
+ }
463
+ const pct = (outputTokens / modelContextLimit) * 100
464
+ if (pct < 1) {
465
+ return ''
466
+ }
467
+ return ` (${pct.toFixed(1)}%)`
468
+ })()
469
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
470
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
471
+ }
472
+ }
473
+
435
474
  if (part.type === 'reasoning') {
436
475
  await sendPartMessage(part)
437
476
  }
@@ -530,6 +569,14 @@ export async function handleOpencodeSession({
530
569
  `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
531
570
  )
532
571
 
572
+ // Flush any pending text/reasoning parts before showing the dropdown
573
+ // This ensures text the LLM generated before the question tool is shown first
574
+ for (const p of currentParts) {
575
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
576
+ await sendPartMessage(p)
577
+ }
578
+ }
579
+
533
580
  await showAskUserQuestionDropdowns({
534
581
  thread,
535
582
  sessionId: session.id,