kimaki 0.4.78 → 0.4.80
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/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
package/src/commands/model.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { getRuntime } from '../session-handler/thread-session-runtime.js'
|
|
|
31
31
|
import { getThinkingValuesForModel } from '../thinking-utils.js'
|
|
32
32
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
33
33
|
import * as errore from 'errore'
|
|
34
|
+
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
|
|
34
35
|
|
|
35
36
|
const modelLogger = createLogger(LogPrefix.MODEL)
|
|
36
37
|
|
|
@@ -51,6 +52,10 @@ type PendingModelContext = {
|
|
|
51
52
|
selectedModelId?: string
|
|
52
53
|
selectedVariant?: string | null
|
|
53
54
|
availableVariants?: string[]
|
|
55
|
+
providerPage?: number
|
|
56
|
+
modelPage?: number
|
|
57
|
+
/** Header text shown above the provider select (current model info). */
|
|
58
|
+
providerSelectHeader?: string
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
const pendingModelContexts = new Map<string, PendingModelContext>()
|
|
@@ -464,6 +469,7 @@ export async function handleModelCommand({
|
|
|
464
469
|
})()
|
|
465
470
|
|
|
466
471
|
// Store context with a short hash key to avoid customId length limits.
|
|
472
|
+
const providerSelectHeader = `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`
|
|
467
473
|
const context = {
|
|
468
474
|
dir: projectDirectory,
|
|
469
475
|
channelId: targetChannelId,
|
|
@@ -471,13 +477,13 @@ export async function handleModelCommand({
|
|
|
471
477
|
isThread: isThread,
|
|
472
478
|
thread: isThread ? (channel as ThreadChannel) : undefined,
|
|
473
479
|
appId,
|
|
480
|
+
providerSelectHeader,
|
|
474
481
|
}
|
|
475
482
|
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
476
483
|
setModelContext(contextHash, context)
|
|
477
484
|
|
|
478
|
-
const
|
|
485
|
+
const allProviderOptions = [...availableProviders]
|
|
479
486
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
480
|
-
.slice(0, 25)
|
|
481
487
|
.map((provider) => {
|
|
482
488
|
const modelCount = Object.keys(provider.models || {}).length
|
|
483
489
|
return {
|
|
@@ -491,6 +497,11 @@ export async function handleModelCommand({
|
|
|
491
497
|
}
|
|
492
498
|
})
|
|
493
499
|
|
|
500
|
+
const { options } = buildPaginatedOptions({
|
|
501
|
+
allOptions: allProviderOptions,
|
|
502
|
+
page: 0,
|
|
503
|
+
})
|
|
504
|
+
|
|
494
505
|
const selectMenu = new StringSelectMenuBuilder()
|
|
495
506
|
.setCustomId(`model_provider:${contextHash}`)
|
|
496
507
|
.setPlaceholder('Select a provider')
|
|
@@ -500,7 +511,7 @@ export async function handleModelCommand({
|
|
|
500
511
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
501
512
|
|
|
502
513
|
await interaction.editReply({
|
|
503
|
-
content:
|
|
514
|
+
content: providerSelectHeader,
|
|
504
515
|
components: [actionRow],
|
|
505
516
|
})
|
|
506
517
|
} catch (error) {
|
|
@@ -547,6 +558,47 @@ export async function handleProviderSelectMenu(
|
|
|
547
558
|
return
|
|
548
559
|
}
|
|
549
560
|
|
|
561
|
+
// Handle pagination nav — re-render the same provider select with new page
|
|
562
|
+
const providerNavPage = parsePaginationValue(selectedProviderId)
|
|
563
|
+
if (providerNavPage !== undefined) {
|
|
564
|
+
context.providerPage = providerNavPage
|
|
565
|
+
setModelContext(contextHash, context)
|
|
566
|
+
|
|
567
|
+
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
568
|
+
if (getClient instanceof Error) {
|
|
569
|
+
await interaction.editReply({ content: getClient.message, components: [] })
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
const providersResponse = await getClient().provider.list({ directory: context.dir })
|
|
573
|
+
if (!providersResponse.data) {
|
|
574
|
+
await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
const { all: allProviders, connected } = providersResponse.data
|
|
578
|
+
const availableProviders = allProviders.filter((p) => connected.includes(p.id))
|
|
579
|
+
const allProviderOptions = [...availableProviders]
|
|
580
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
581
|
+
.map((p) => {
|
|
582
|
+
const modelCount = Object.keys(p.models || {}).length
|
|
583
|
+
return {
|
|
584
|
+
label: p.name.slice(0, 100),
|
|
585
|
+
value: p.id,
|
|
586
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage })
|
|
590
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
591
|
+
.setCustomId(`model_provider:${contextHash}`)
|
|
592
|
+
.setPlaceholder('Select a provider')
|
|
593
|
+
.addOptions(options)
|
|
594
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
595
|
+
await interaction.editReply({
|
|
596
|
+
content: context.providerSelectHeader || `**Set Model Preference**\nSelect a provider:`,
|
|
597
|
+
components: [actionRow],
|
|
598
|
+
})
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
|
|
550
602
|
try {
|
|
551
603
|
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
552
604
|
if (getClient instanceof Error) {
|
|
@@ -597,15 +649,13 @@ export async function handleProviderSelectMenu(
|
|
|
597
649
|
return
|
|
598
650
|
}
|
|
599
651
|
|
|
600
|
-
// Take first 25 models (most recent since sorted descending)
|
|
601
|
-
const recentModels = models.slice(0, 25)
|
|
602
|
-
|
|
603
652
|
// Update context with provider info and reuse the same hash
|
|
604
653
|
context.providerId = selectedProviderId
|
|
605
654
|
context.providerName = provider.name
|
|
655
|
+
context.modelPage = 0
|
|
606
656
|
setModelContext(contextHash, context)
|
|
607
657
|
|
|
608
|
-
const
|
|
658
|
+
const allModelOptions = models.map((model) => {
|
|
609
659
|
const dateStr = model.releaseDate
|
|
610
660
|
? new Date(model.releaseDate).toLocaleDateString()
|
|
611
661
|
: 'Unknown date'
|
|
@@ -616,6 +666,11 @@ export async function handleProviderSelectMenu(
|
|
|
616
666
|
}
|
|
617
667
|
})
|
|
618
668
|
|
|
669
|
+
const { options } = buildPaginatedOptions({
|
|
670
|
+
allOptions: allModelOptions,
|
|
671
|
+
page: 0,
|
|
672
|
+
})
|
|
673
|
+
|
|
619
674
|
const selectMenu = new StringSelectMenuBuilder()
|
|
620
675
|
.setCustomId(`model_select:${contextHash}`)
|
|
621
676
|
.setPlaceholder('Select a model')
|
|
@@ -673,6 +728,46 @@ export async function handleModelSelectMenu(
|
|
|
673
728
|
return
|
|
674
729
|
}
|
|
675
730
|
|
|
731
|
+
// Handle pagination nav — re-render the same model select with new page
|
|
732
|
+
const modelNavPage = parsePaginationValue(selectedModelId)
|
|
733
|
+
if (modelNavPage !== undefined) {
|
|
734
|
+
context.modelPage = modelNavPage
|
|
735
|
+
setModelContext(contextHash, context)
|
|
736
|
+
|
|
737
|
+
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
738
|
+
if (getClient instanceof Error) {
|
|
739
|
+
await interaction.editReply({ content: getClient.message, components: [] })
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
const providersResponse = await getClient().provider.list({ directory: context.dir })
|
|
743
|
+
const provider = providersResponse.data?.all.find((p) => p.id === context.providerId)
|
|
744
|
+
if (!provider) {
|
|
745
|
+
await interaction.editReply({ content: 'Provider not found', components: [] })
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
const allModelOptions = Object.entries(provider.models || {})
|
|
749
|
+
.map(([modelId, model]) => ({
|
|
750
|
+
label: model.name.slice(0, 100),
|
|
751
|
+
value: modelId,
|
|
752
|
+
description: (model.release_date
|
|
753
|
+
? new Date(model.release_date).toLocaleDateString()
|
|
754
|
+
: 'Unknown date'
|
|
755
|
+
).slice(0, 100),
|
|
756
|
+
}))
|
|
757
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
758
|
+
const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage })
|
|
759
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
760
|
+
.setCustomId(`model_select:${contextHash}`)
|
|
761
|
+
.setPlaceholder('Select a model')
|
|
762
|
+
.addOptions(options)
|
|
763
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
764
|
+
await interaction.editReply({
|
|
765
|
+
content: `**Set Model Preference**\nProvider: **${context.providerName}**\nSelect a model:`,
|
|
766
|
+
components: [actionRow],
|
|
767
|
+
})
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
676
771
|
// Build full model ID: provider_id/model_id
|
|
677
772
|
const fullModelId = `${context.providerId}/${selectedModelId}`
|
|
678
773
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable paginated select menu helpers for Discord StringSelectMenuBuilder.
|
|
3
|
+
* Discord caps select menus at 25 options. This module slices a full options
|
|
4
|
+
* list into pages of PAGE_SIZE real items and appends "← Previous page" /
|
|
5
|
+
* "Next page →" sentinel options so the user can navigate. Handlers detect
|
|
6
|
+
* sentinel values via parsePaginationValue() and re-render the same select
|
|
7
|
+
* with the new page — reusing the same customId, no new interaction handlers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const NAV_PREFIX = '__page_nav:'
|
|
11
|
+
|
|
12
|
+
/** 23 real items per page, leaving room for up to 2 nav sentinels (prev + next). */
|
|
13
|
+
const PAGE_SIZE = 23
|
|
14
|
+
|
|
15
|
+
export type SelectOption = {
|
|
16
|
+
label: string
|
|
17
|
+
value: string
|
|
18
|
+
description?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the options array for a single page, with prev/next nav sentinels.
|
|
23
|
+
* If allOptions fits in 25 items, returns them all with no nav items.
|
|
24
|
+
*/
|
|
25
|
+
export function buildPaginatedOptions({
|
|
26
|
+
allOptions,
|
|
27
|
+
page,
|
|
28
|
+
}: {
|
|
29
|
+
allOptions: SelectOption[]
|
|
30
|
+
page: number
|
|
31
|
+
}): { options: SelectOption[]; totalPages: number } {
|
|
32
|
+
// No pagination needed — everything fits in one Discord select
|
|
33
|
+
if (allOptions.length <= 25) {
|
|
34
|
+
return { options: allOptions, totalPages: 1 }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const totalPages = Math.ceil(allOptions.length / PAGE_SIZE)
|
|
38
|
+
const safePage = Math.max(0, Math.min(page, totalPages - 1))
|
|
39
|
+
const start = safePage * PAGE_SIZE
|
|
40
|
+
const slice = allOptions.slice(start, start + PAGE_SIZE)
|
|
41
|
+
|
|
42
|
+
const result: SelectOption[] = []
|
|
43
|
+
|
|
44
|
+
if (safePage > 0) {
|
|
45
|
+
result.push({
|
|
46
|
+
label: `← Previous page (${safePage}/${totalPages})`,
|
|
47
|
+
value: `${NAV_PREFIX}${safePage - 1}`,
|
|
48
|
+
description: 'Go to previous page',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
result.push(...slice)
|
|
53
|
+
|
|
54
|
+
if (safePage < totalPages - 1) {
|
|
55
|
+
result.push({
|
|
56
|
+
label: `Next page → (${safePage + 2}/${totalPages})`,
|
|
57
|
+
value: `${NAV_PREFIX}${safePage + 1}`,
|
|
58
|
+
description: 'Go to next page',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { options: result, totalPages }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a selected value is a pagination nav sentinel.
|
|
67
|
+
* Returns the target page number if so, undefined otherwise.
|
|
68
|
+
*/
|
|
69
|
+
export function parsePaginationValue(
|
|
70
|
+
value: string,
|
|
71
|
+
): number | undefined {
|
|
72
|
+
if (!value.startsWith(NAV_PREFIX)) {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
const pageStr = value.slice(NAV_PREFIX.length)
|
|
76
|
+
const page = Number(pageStr)
|
|
77
|
+
if (Number.isNaN(page)) {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
return page
|
|
81
|
+
}
|
package/src/commands/resume.ts
CHANGED
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
getAllThreadSessionIds,
|
|
16
16
|
} from '../database.js'
|
|
17
17
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
sendThreadMessage,
|
|
20
|
+
resolveProjectDirectoryFromAutocomplete,
|
|
21
|
+
NOTIFY_MESSAGE_FLAGS,
|
|
22
|
+
} from '../discord-utils.js'
|
|
19
23
|
import { collectLastAssistantParts } from '../message-formatting.js'
|
|
20
24
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
21
25
|
import * as errore from 'errore'
|
|
@@ -153,6 +157,7 @@ export async function handleResumeCommand({
|
|
|
153
157
|
await sendThreadMessage(
|
|
154
158
|
thread,
|
|
155
159
|
`Failed to load message history, but session is connected. You can still send new messages.`,
|
|
160
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
156
161
|
)
|
|
157
162
|
}
|
|
158
163
|
} catch (error) {
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// /tasks command — list all scheduled tasks sorted by next run time.
|
|
2
|
+
// Renders a markdown table that the CV2 pipeline auto-formats for Discord,
|
|
3
|
+
// including HTML-backed action buttons for cancellable tasks.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ButtonInteraction,
|
|
7
|
+
ChatInputCommandInteraction,
|
|
8
|
+
ComponentType,
|
|
9
|
+
MessageFlags,
|
|
10
|
+
type APIMessageTopLevelComponent,
|
|
11
|
+
type APITextDisplayComponent,
|
|
12
|
+
type InteractionEditReplyOptions,
|
|
13
|
+
} from 'discord.js'
|
|
14
|
+
import {
|
|
15
|
+
cancelScheduledTask,
|
|
16
|
+
listScheduledTasks,
|
|
17
|
+
type ScheduledTask,
|
|
18
|
+
type ScheduledTaskStatus,
|
|
19
|
+
} from '../database.js'
|
|
20
|
+
import { splitTablesFromMarkdown } from '../format-tables.js'
|
|
21
|
+
import {
|
|
22
|
+
buildHtmlActionCustomId,
|
|
23
|
+
cancelHtmlActionsForOwner,
|
|
24
|
+
registerHtmlAction,
|
|
25
|
+
} from '../html-actions.js'
|
|
26
|
+
import { formatTimeAgo } from './worktrees.js'
|
|
27
|
+
|
|
28
|
+
function formatTimeUntil(date: Date): string {
|
|
29
|
+
const diffMs = date.getTime() - Date.now()
|
|
30
|
+
if (diffMs <= 0) {
|
|
31
|
+
return 'due now'
|
|
32
|
+
}
|
|
33
|
+
const totalSeconds = Math.floor(diffMs / 1000)
|
|
34
|
+
if (totalSeconds < 60) {
|
|
35
|
+
return `in ${totalSeconds}s`
|
|
36
|
+
}
|
|
37
|
+
const totalMinutes = Math.floor(totalSeconds / 60)
|
|
38
|
+
if (totalMinutes < 60) {
|
|
39
|
+
return `in ${totalMinutes}m`
|
|
40
|
+
}
|
|
41
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
42
|
+
const minutes = totalMinutes % 60
|
|
43
|
+
if (hours < 24) {
|
|
44
|
+
return minutes > 0 ? `in ${hours}h ${minutes}m` : `in ${hours}h`
|
|
45
|
+
}
|
|
46
|
+
const days = Math.floor(hours / 24)
|
|
47
|
+
const remainingHours = hours % 24
|
|
48
|
+
return remainingHours > 0 ? `in ${days}d ${remainingHours}h` : `in ${days}d`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scheduleLabel(task: ScheduledTask): string {
|
|
52
|
+
if (task.schedule_kind === 'cron') {
|
|
53
|
+
return task.cron_expr || 'cron'
|
|
54
|
+
}
|
|
55
|
+
return 'one-time'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function canCancelTask(task: ScheduledTask): boolean {
|
|
59
|
+
return task.status === 'planned' || task.status === 'running'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Escape pipe chars and collapse whitespace so free-text fields don't break
|
|
63
|
+
// GFM table column alignment.
|
|
64
|
+
function sanitizeTableCell(value: string): string {
|
|
65
|
+
return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildCancelButtonHtml({ buttonId }: { buttonId: string }): string {
|
|
69
|
+
return `<button id="${buttonId}" variant="secondary">Delete</button>`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildActionCell(task: ScheduledTask): string {
|
|
73
|
+
if (!canCancelTask(task)) {
|
|
74
|
+
return '-'
|
|
75
|
+
}
|
|
76
|
+
return buildCancelButtonHtml({ buttonId: `cancel-task-${task.id}` })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Cap rows to avoid exceeding Discord's 40-component CV2 limit.
|
|
80
|
+
// Each cancellable row renders as text + action row + button (~4 components),
|
|
81
|
+
// so 10 rows is a safe ceiling.
|
|
82
|
+
const MAX_TASK_ROWS = 10
|
|
83
|
+
|
|
84
|
+
function buildTaskTable({
|
|
85
|
+
tasks,
|
|
86
|
+
}: {
|
|
87
|
+
tasks: ScheduledTask[]
|
|
88
|
+
}): string {
|
|
89
|
+
const header = '| ID | Status | Prompt | Schedule | Next Run | Action |'
|
|
90
|
+
const separator = '|---|---|---|---|---|---|'
|
|
91
|
+
const rows = tasks.map((task) => {
|
|
92
|
+
const id = String(task.id)
|
|
93
|
+
const status = task.status
|
|
94
|
+
const prompt = sanitizeTableCell(
|
|
95
|
+
task.prompt_preview.length > 240
|
|
96
|
+
? task.prompt_preview.slice(0, 237) + '...'
|
|
97
|
+
: task.prompt_preview,
|
|
98
|
+
)
|
|
99
|
+
const schedule = sanitizeTableCell(scheduleLabel(task))
|
|
100
|
+
const nextRun = (() => {
|
|
101
|
+
if (
|
|
102
|
+
task.status === 'completed' ||
|
|
103
|
+
task.status === 'cancelled' ||
|
|
104
|
+
task.status === 'failed'
|
|
105
|
+
) {
|
|
106
|
+
return task.last_run_at ? formatTimeAgo(task.last_run_at) : '-'
|
|
107
|
+
}
|
|
108
|
+
return formatTimeUntil(task.next_run_at)
|
|
109
|
+
})()
|
|
110
|
+
const action = buildActionCell(task)
|
|
111
|
+
return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${action} |`
|
|
112
|
+
})
|
|
113
|
+
return [header, separator, ...rows].join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getTasksActionOwnerKey({
|
|
117
|
+
userId,
|
|
118
|
+
channelId,
|
|
119
|
+
}: {
|
|
120
|
+
userId: string
|
|
121
|
+
channelId: string
|
|
122
|
+
}): string {
|
|
123
|
+
return `tasks:${userId}:${channelId}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type TasksReplyTarget = {
|
|
127
|
+
guildId: string
|
|
128
|
+
userId: string
|
|
129
|
+
channelId: string
|
|
130
|
+
showAll: boolean
|
|
131
|
+
notice?: string
|
|
132
|
+
editReply: (
|
|
133
|
+
options: string | InteractionEditReplyOptions,
|
|
134
|
+
) => Promise<unknown>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function renderTasksReply({
|
|
138
|
+
guildId,
|
|
139
|
+
userId,
|
|
140
|
+
channelId,
|
|
141
|
+
showAll,
|
|
142
|
+
notice,
|
|
143
|
+
editReply,
|
|
144
|
+
}: TasksReplyTarget): Promise<void> {
|
|
145
|
+
const ownerKey = getTasksActionOwnerKey({ userId, channelId })
|
|
146
|
+
cancelHtmlActionsForOwner(ownerKey)
|
|
147
|
+
|
|
148
|
+
const statuses: ScheduledTaskStatus[] | undefined = showAll
|
|
149
|
+
? undefined
|
|
150
|
+
: ['planned', 'running']
|
|
151
|
+
const allTasks = await listScheduledTasks({ statuses })
|
|
152
|
+
if (allTasks.length === 0) {
|
|
153
|
+
const message = notice
|
|
154
|
+
? `${notice}\n\nNo scheduled tasks found.`
|
|
155
|
+
: 'No scheduled tasks found.'
|
|
156
|
+
const textDisplay: APITextDisplayComponent = {
|
|
157
|
+
type: ComponentType.TextDisplay,
|
|
158
|
+
content: message,
|
|
159
|
+
}
|
|
160
|
+
await editReply({
|
|
161
|
+
components: [textDisplay],
|
|
162
|
+
flags: MessageFlags.IsComponentsV2,
|
|
163
|
+
})
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const tasks = allTasks.slice(0, MAX_TASK_ROWS)
|
|
168
|
+
const truncatedNotice =
|
|
169
|
+
allTasks.length > MAX_TASK_ROWS
|
|
170
|
+
? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
|
|
171
|
+
: undefined
|
|
172
|
+
const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n')
|
|
173
|
+
|
|
174
|
+
const cancellableTasksByButtonId = new Map<string, ScheduledTask>()
|
|
175
|
+
tasks.forEach((task) => {
|
|
176
|
+
if (!canCancelTask(task)) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const tableMarkdown = buildTaskTable({ tasks })
|
|
183
|
+
const markdown = combinedNotice
|
|
184
|
+
? `${combinedNotice}\n\n${tableMarkdown}`
|
|
185
|
+
: tableMarkdown
|
|
186
|
+
const segments = splitTablesFromMarkdown(markdown, {
|
|
187
|
+
resolveButtonCustomId: ({ button }) => {
|
|
188
|
+
const task = cancellableTasksByButtonId.get(button.id)
|
|
189
|
+
if (!task) {
|
|
190
|
+
return new Error(`No task registered for button ${button.id}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const actionId = registerHtmlAction({
|
|
194
|
+
ownerKey,
|
|
195
|
+
threadId: String(task.id),
|
|
196
|
+
run: async ({ interaction }) => {
|
|
197
|
+
await handleCancelTaskAction({
|
|
198
|
+
interaction,
|
|
199
|
+
taskId: task.id,
|
|
200
|
+
showAll,
|
|
201
|
+
})
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
return buildHtmlActionCustomId(actionId)
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const components: APIMessageTopLevelComponent[] = segments.flatMap(
|
|
209
|
+
(segment) => {
|
|
210
|
+
if (segment.type === 'components') {
|
|
211
|
+
return segment.components
|
|
212
|
+
}
|
|
213
|
+
const textDisplay: APITextDisplayComponent = {
|
|
214
|
+
type: ComponentType.TextDisplay,
|
|
215
|
+
content: segment.text,
|
|
216
|
+
}
|
|
217
|
+
return [textDisplay]
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
await editReply({
|
|
222
|
+
components,
|
|
223
|
+
flags: MessageFlags.IsComponentsV2,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function handleCancelTaskAction({
|
|
228
|
+
interaction,
|
|
229
|
+
taskId,
|
|
230
|
+
showAll,
|
|
231
|
+
}: {
|
|
232
|
+
interaction: ButtonInteraction
|
|
233
|
+
taskId: number
|
|
234
|
+
showAll: boolean
|
|
235
|
+
}): Promise<void> {
|
|
236
|
+
const guildId = interaction.guildId
|
|
237
|
+
if (!guildId) {
|
|
238
|
+
await interaction.editReply({
|
|
239
|
+
components: [
|
|
240
|
+
{
|
|
241
|
+
type: ComponentType.TextDisplay,
|
|
242
|
+
content: 'This action can only be used in a server.',
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
flags: MessageFlags.IsComponentsV2,
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cancelled = await cancelScheduledTask(taskId)
|
|
251
|
+
const notice = cancelled
|
|
252
|
+
? `Cancelled task #${taskId}.`
|
|
253
|
+
: `Task #${taskId} not found or already finalized.`
|
|
254
|
+
|
|
255
|
+
await renderTasksReply({
|
|
256
|
+
guildId,
|
|
257
|
+
userId: interaction.user.id,
|
|
258
|
+
channelId: interaction.channelId,
|
|
259
|
+
showAll,
|
|
260
|
+
notice,
|
|
261
|
+
editReply: (options) => {
|
|
262
|
+
return interaction.editReply(options)
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function handleTasksCommand({
|
|
268
|
+
command,
|
|
269
|
+
}: {
|
|
270
|
+
command: ChatInputCommandInteraction
|
|
271
|
+
appId: string
|
|
272
|
+
}): Promise<void> {
|
|
273
|
+
const guildId = command.guildId
|
|
274
|
+
if (!guildId) {
|
|
275
|
+
await command.reply({
|
|
276
|
+
content: 'This command can only be used in a server.',
|
|
277
|
+
flags: MessageFlags.Ephemeral,
|
|
278
|
+
})
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const showAll = command.options.getBoolean('all') ?? false
|
|
283
|
+
await command.deferReply({ flags: MessageFlags.Ephemeral })
|
|
284
|
+
await renderTasksReply({
|
|
285
|
+
guildId,
|
|
286
|
+
userId: command.user.id,
|
|
287
|
+
channelId: command.channelId,
|
|
288
|
+
showAll,
|
|
289
|
+
editReply: (options) => {
|
|
290
|
+
return command.editReply(options)
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
}
|