codeblog-app 2.2.6 → 2.3.1

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.
Files changed (39) hide show
  1. package/package.json +9 -7
  2. package/src/ai/__tests__/chat.test.ts +11 -2
  3. package/src/ai/__tests__/compat.test.ts +46 -0
  4. package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
  5. package/src/ai/__tests__/provider-registry.test.ts +61 -0
  6. package/src/ai/__tests__/provider.test.ts +58 -18
  7. package/src/ai/__tests__/stream-events.test.ts +152 -0
  8. package/src/ai/chat.ts +200 -88
  9. package/src/ai/configure.ts +13 -4
  10. package/src/ai/models.ts +26 -0
  11. package/src/ai/provider-registry.ts +150 -0
  12. package/src/ai/provider.ts +99 -137
  13. package/src/ai/stream-events.ts +64 -0
  14. package/src/ai/tools.ts +10 -6
  15. package/src/ai/types.ts +105 -0
  16. package/src/auth/index.ts +3 -1
  17. package/src/auth/oauth.ts +17 -2
  18. package/src/cli/__tests__/commands.test.ts +6 -2
  19. package/src/cli/cmd/ai.ts +10 -0
  20. package/src/cli/cmd/setup.ts +275 -5
  21. package/src/cli/ui.ts +131 -24
  22. package/src/config/index.ts +38 -1
  23. package/src/index.ts +4 -1
  24. package/src/mcp/__tests__/client.test.ts +2 -2
  25. package/src/mcp/__tests__/e2e.ts +10 -6
  26. package/src/mcp/client.ts +33 -63
  27. package/src/storage/chat.ts +3 -1
  28. package/src/tui/__tests__/input-intent.test.ts +27 -0
  29. package/src/tui/__tests__/stream-assembler.test.ts +33 -0
  30. package/src/tui/ai-stream.ts +28 -0
  31. package/src/tui/app.tsx +27 -1
  32. package/src/tui/commands.ts +41 -7
  33. package/src/tui/context/theme.tsx +2 -1
  34. package/src/tui/input-intent.ts +26 -0
  35. package/src/tui/routes/home.tsx +590 -190
  36. package/src/tui/routes/setup.tsx +20 -8
  37. package/src/tui/stream-assembler.ts +49 -0
  38. package/src/util/log.ts +3 -1
  39. package/tsconfig.json +1 -1
@@ -54,6 +54,10 @@ function assert(condition: boolean, msg: string) {
54
54
  if (!condition) throw new Error(`Assertion failed: ${msg}`)
55
55
  }
56
56
 
57
+ function firstLine(text: string): string {
58
+ return text.split("\n")[0] ?? ""
59
+ }
60
+
57
61
  async function main() {
58
62
  console.log("=== CodeBlog E2E Test — Full User Journey ===\n")
59
63
 
@@ -62,7 +66,7 @@ async function main() {
62
66
  const result = await McpBridge.callTool("codeblog_status")
63
67
  assert(result.includes("CodeBlog MCP Server"), "should include server info")
64
68
  assert(result.includes("Agent:"), "should include agent info (authenticated)")
65
- console.log(` → ${result.split("\n")[0]}`)
69
+ console.log(` → ${firstLine(result)}`)
66
70
  })
67
71
 
68
72
  // 2. Scan IDE sessions
@@ -172,7 +176,7 @@ async function main() {
172
176
  const idMatch = raw.match(/Comment ID:\s*([a-z0-9]+)/)
173
177
  testCommentId = idMatch?.[1] || ""
174
178
  }
175
- console.log(` → ${raw.split("\n")[0].slice(0, 80)}`)
179
+ console.log(` → ${firstLine(raw).slice(0, 80)}`)
176
180
  })
177
181
 
178
182
  // 11. Edit the post
@@ -219,14 +223,14 @@ async function main() {
219
223
  await test("16. trending_topics", async () => {
220
224
  const raw = await McpBridge.callTool("trending_topics")
221
225
  assert(raw.includes("Trending"), "should include trending info")
222
- console.log(` → ${raw.split("\n")[0]}`)
226
+ console.log(` → ${firstLine(raw)}`)
223
227
  })
224
228
 
225
229
  // 17. Explore and engage
226
230
  await test("17. explore_and_engage (browse)", async () => {
227
231
  const raw = await McpBridge.callTool("explore_and_engage", { action: "browse", limit: 3 })
228
232
  assert(raw.length > 0, "should return content")
229
- console.log(` → ${raw.split("\n")[0]}`)
233
+ console.log(` → ${firstLine(raw)}`)
230
234
  })
231
235
 
232
236
  // 18. My posts
@@ -272,14 +276,14 @@ async function main() {
272
276
  await test("24. weekly_digest (dry_run)", async () => {
273
277
  const raw = await McpBridge.callTool("weekly_digest", { dry_run: true })
274
278
  assert(raw.length > 0, "should return digest preview")
275
- console.log(` → ${raw.split("\n")[0]}`)
279
+ console.log(` → ${firstLine(raw)}`)
276
280
  })
277
281
 
278
282
  // 25. Auto post (dry run)
279
283
  await test("25. auto_post (dry_run)", async () => {
280
284
  const raw = await McpBridge.callTool("auto_post", { dry_run: true })
281
285
  assert(raw.length > 0, "should return post preview")
282
- console.log(` → ${raw.split("\n")[0].slice(0, 100)}`)
286
+ console.log(` → ${firstLine(raw).slice(0, 100)}`)
283
287
  })
284
288
 
285
289
  // 26. Remove vote
package/src/mcp/client.ts CHANGED
@@ -1,74 +1,48 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js"
2
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
3
- import { resolve, dirname } from "path"
2
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
3
+ import { createServer } from "codeblog-mcp"
4
4
  import { Log } from "../util/log"
5
5
 
6
6
  const log = Log.create({ service: "mcp" })
7
7
 
8
- const CONNECTION_TIMEOUT_MS = 30_000
9
-
10
8
  let client: Client | null = null
11
- let transport: StdioClientTransport | null = null
9
+ let clientTransport: InstanceType<typeof InMemoryTransport> | null = null
12
10
  let connecting: Promise<Client> | null = null
13
11
 
14
- function getMcpBinaryPath(): string {
15
- try {
16
- const resolved = require.resolve("codeblog-mcp/dist/index.js")
17
- return resolved
18
- } catch {
19
- return resolve(dirname(new URL(import.meta.url).pathname), "../../node_modules/codeblog-mcp/dist/index.js")
20
- }
21
- }
22
-
23
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
24
- return new Promise<T>((resolve, reject) => {
25
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
26
- promise.then(
27
- (v) => { clearTimeout(timer); resolve(v) },
28
- (e) => { clearTimeout(timer); reject(e) },
29
- )
30
- })
31
- }
32
-
33
12
  async function connect(): Promise<Client> {
34
13
  if (client) return client
35
-
36
- // If another caller is already connecting, reuse that promise
37
14
  if (connecting) return connecting
38
15
 
39
16
  connecting = (async (): Promise<Client> => {
40
- const mcpPath = getMcpBinaryPath()
41
- log.debug("connecting", { path: mcpPath })
17
+ log.info("connecting via InMemoryTransport")
42
18
 
43
- const env: Record<string, string> = {}
44
- for (const [k, v] of Object.entries(process.env)) {
45
- if (v !== undefined) env[k] = v
46
- }
47
-
48
- const t = new StdioClientTransport({
49
- command: "node",
50
- args: [mcpPath],
51
- env,
52
- stderr: "pipe",
53
- })
19
+ const server = createServer()
20
+ const [ct, serverTransport] = InMemoryTransport.createLinkedPair()
54
21
 
55
22
  const c = new Client({ name: "codeblog-cli", version: "2.0.0" })
56
23
 
24
+ c.onclose = () => {
25
+ log.warn("mcp-connection-closed")
26
+ client = null
27
+ clientTransport = null
28
+ }
29
+
57
30
  try {
58
- await withTimeout(c.connect(t), CONNECTION_TIMEOUT_MS, "MCP connection")
31
+ await server.connect(serverTransport)
32
+ await c.connect(ct)
59
33
  } catch (err) {
60
- // Clean up on failure so next call can retry
61
- await t.close().catch(() => {})
34
+ const errMsg = err instanceof Error ? err.message : String(err)
35
+ log.error("mcp-connect-failed", { error: errMsg })
36
+ await ct.close().catch(() => {})
62
37
  throw err
63
38
  }
64
39
 
65
- log.debug("connected", {
40
+ log.info("connected", {
66
41
  server: c.getServerVersion()?.name,
67
42
  version: c.getServerVersion()?.version,
68
43
  })
69
44
 
70
- // Only assign to module-level vars after successful connection
71
- transport = t
45
+ clientTransport = ct
72
46
  client = c
73
47
  return c
74
48
  })()
@@ -76,35 +50,36 @@ async function connect(): Promise<Client> {
76
50
  try {
77
51
  return await connecting
78
52
  } catch (err) {
79
- // Reset connecting so next call can retry
80
53
  connecting = null
81
54
  throw err
82
55
  }
83
56
  }
84
57
 
85
58
  export namespace McpBridge {
86
- /**
87
- * Call an MCP tool by name with arguments.
88
- * Returns the text content from the tool result.
89
- */
90
59
  export async function callTool(
91
60
  name: string,
92
61
  args: Record<string, unknown> = {},
93
62
  ): Promise<string> {
94
63
  const c = await connect()
95
- const result = await c.callTool({ name, arguments: args })
64
+ let result
65
+ try {
66
+ result = await c.callTool({ name, arguments: args })
67
+ } catch (err) {
68
+ const errMsg = err instanceof Error ? err.message : String(err)
69
+ const errCode = (err as any)?.code
70
+ log.error("mcp-tool-call-failed", { tool: name, error: errMsg, code: errCode })
71
+ throw err
72
+ }
96
73
 
97
74
  if (result.isError) {
98
75
  const text = extractText(result)
76
+ log.error("mcp-tool-returned-error", { tool: name, error: text })
99
77
  throw new Error(text || `MCP tool "${name}" returned an error`)
100
78
  }
101
79
 
102
80
  return extractText(result)
103
81
  }
104
82
 
105
- /**
106
- * Call an MCP tool and parse the result as JSON.
107
- */
108
83
  export async function callToolJSON<T = unknown>(
109
84
  name: string,
110
85
  args: Record<string, unknown> = {},
@@ -117,22 +92,17 @@ export namespace McpBridge {
117
92
  }
118
93
  }
119
94
 
120
- /**
121
- * List all available MCP tools.
122
- */
123
95
  export async function listTools() {
124
96
  const c = await connect()
125
97
  return c.listTools()
126
98
  }
127
99
 
128
- /**
129
- * Disconnect the MCP client and kill the subprocess.
130
- */
131
100
  export async function disconnect(): Promise<void> {
101
+ log.info("disconnecting", { hadClient: !!client })
132
102
  connecting = null
133
- if (transport) {
134
- await transport.close().catch(() => {})
135
- transport = null
103
+ if (clientTransport) {
104
+ await clientTransport.close().catch(() => {})
105
+ clientTransport = null
136
106
  }
137
107
  client = null
138
108
  }
@@ -1,8 +1,10 @@
1
1
  import { Database } from "./db"
2
2
 
3
3
  export interface ChatMsg {
4
- role: "user" | "assistant" | "tool"
4
+ role: "user" | "assistant" | "tool" | "system"
5
5
  content: string
6
+ modelContent?: string
7
+ tone?: "info" | "success" | "warning" | "error"
6
8
  toolName?: string
7
9
  toolStatus?: "running" | "done" | "error"
8
10
  }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { isShiftEnterSequence } from "../input-intent"
3
+
4
+ describe("input intent", () => {
5
+ test("detects kitty csi-u shift+enter sequences", () => {
6
+ expect(isShiftEnterSequence("\x1b[13;2u")).toBe(true)
7
+ expect(isShiftEnterSequence("\x1b[57345;2u")).toBe(true)
8
+ expect(isShiftEnterSequence("\x1b[13;2:1u")).toBe(true)
9
+ })
10
+
11
+ test("detects modifyOtherKeys-style shift+enter sequences", () => {
12
+ expect(isShiftEnterSequence("\x1b[27;2;13~")).toBe(true)
13
+ expect(isShiftEnterSequence("\x1b[13;2~")).toBe(true)
14
+ })
15
+
16
+ test("detects shift+enter sequences with trailing newline bytes", () => {
17
+ expect(isShiftEnterSequence("\x1b[13;2u\r")).toBe(true)
18
+ expect(isShiftEnterSequence("\x1b[27;2;13~\n")).toBe(true)
19
+ })
20
+
21
+ test("does not match plain enter sequences", () => {
22
+ expect(isShiftEnterSequence("\r")).toBe(false)
23
+ expect(isShiftEnterSequence("\n")).toBe(false)
24
+ expect(isShiftEnterSequence("\x1b[13u")).toBe(false)
25
+ expect(isShiftEnterSequence("")).toBe(false)
26
+ })
27
+ })
@@ -0,0 +1,33 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { TuiStreamAssembler } from "../stream-assembler"
3
+
4
+ describe("TuiStreamAssembler", () => {
5
+ test("delta -> final does not lose text", () => {
6
+ const a = new TuiStreamAssembler()
7
+ a.pushDelta("Hello ")
8
+ a.pushDelta("World")
9
+ const final = a.pushFinal("Hello World!")
10
+ expect(final).toBe("Hello World!")
11
+ })
12
+
13
+ test("empty final keeps streamed text", () => {
14
+ const a = new TuiStreamAssembler()
15
+ a.pushDelta("Streaming content")
16
+ const final = a.pushFinal("")
17
+ expect(final).toBe("Streaming content")
18
+ })
19
+
20
+ test("out-of-order delta is ignored", () => {
21
+ const a = new TuiStreamAssembler()
22
+ a.pushDelta("abc", 2)
23
+ a.pushDelta("x", 1)
24
+ expect(a.getText()).toBe("abc")
25
+ })
26
+
27
+ test("repeated delta text is preserved", () => {
28
+ const a = new TuiStreamAssembler()
29
+ a.pushDelta("ha", 1)
30
+ a.pushDelta("ha", 2)
31
+ expect(a.getText()).toBe("haha")
32
+ })
33
+ })
@@ -0,0 +1,28 @@
1
+ export interface ToolResultItem {
2
+ name: string
3
+ result: string
4
+ }
5
+
6
+ export function formatToolResultSummary(results: ToolResultItem[]): string {
7
+ return `Tool execution completed:\n${results.map((t) => `- ${t.name}: ${t.result}`).join("\n")}`
8
+ }
9
+
10
+ export function resolveAssistantContent(args: {
11
+ finalText: string
12
+ aborted: boolean
13
+ abortByUser: boolean
14
+ hasToolCalls: boolean
15
+ toolResults: ToolResultItem[]
16
+ }): string | undefined {
17
+ if (args.finalText) {
18
+ if (args.aborted && args.abortByUser) return `${args.finalText}\n\n(interrupted)`
19
+ return args.finalText
20
+ }
21
+ if (args.hasToolCalls && args.toolResults.length > 0) {
22
+ return formatToolResultSummary(args.toolResults)
23
+ }
24
+ if (args.aborted && args.abortByUser) {
25
+ return "(interrupted)"
26
+ }
27
+ return undefined
28
+ }
package/src/tui/app.tsx CHANGED
@@ -10,15 +10,27 @@ import { Post } from "./routes/post"
10
10
  import { Search } from "./routes/search"
11
11
  import { Trending } from "./routes/trending"
12
12
  import { Notifications } from "./routes/notifications"
13
+ import { emitInputIntent, isShiftEnterSequence } from "./input-intent"
13
14
 
14
15
  import pkg from "../../package.json"
15
16
  const VERSION = pkg.version
16
17
 
18
+ function enableModifyOtherKeys() {
19
+ if (!process.stdout.isTTY) return () => {}
20
+ // Ask xterm-compatible terminals to include modifier info for keys like Enter.
21
+ process.stdout.write("\x1b[>4;2m")
22
+ return () => {
23
+ // Disable modifyOtherKeys on exit.
24
+ process.stdout.write("\x1b[>4m")
25
+ }
26
+ }
27
+
17
28
  export function tui(input: { onExit?: () => Promise<void> }) {
18
29
  return new Promise<void>(async (resolve) => {
30
+ const restoreModifiers = enableModifyOtherKeys()
19
31
  render(
20
32
  () => (
21
- <ExitProvider onExit={async () => { await input.onExit?.(); resolve() }}>
33
+ <ExitProvider onExit={async () => { await input.onExit?.(); restoreModifiers(); resolve() }}>
22
34
  <ThemeProvider>
23
35
  <RouteProvider>
24
36
  <App />
@@ -31,6 +43,20 @@ export function tui(input: { onExit?: () => Promise<void> }) {
31
43
  exitOnCtrlC: false,
32
44
  autoFocus: false,
33
45
  openConsoleOnError: false,
46
+ useKittyKeyboard: {
47
+ disambiguate: true,
48
+ alternateKeys: true,
49
+ events: true,
50
+ allKeysAsEscapes: true,
51
+ reportText: true,
52
+ },
53
+ prependInputHandlers: [
54
+ (sequence) => {
55
+ if (!isShiftEnterSequence(sequence)) return false
56
+ emitInputIntent("newline")
57
+ return true
58
+ },
59
+ ],
34
60
  },
35
61
  )
36
62
  })
@@ -9,14 +9,15 @@ export interface CmdDef {
9
9
 
10
10
  export interface CommandDeps {
11
11
  showMsg: (text: string, color?: string) => void
12
- navigate: (route: any) => void
12
+ openModelPicker: () => Promise<void>
13
13
  exit: () => void
14
14
  onLogin: () => Promise<void>
15
15
  onLogout: () => void
16
+ onAIConfigured: () => void
16
17
  clearChat: () => void
17
18
  startAIConfig: () => void
18
19
  setMode: (mode: "dark" | "light") => void
19
- send: (prompt: string) => void
20
+ send: (prompt: string, options?: { display?: string }) => void
20
21
  resume: (id?: string) => void
21
22
  listSessions: () => Array<{ id: string; title: string | null; time: number; count: number }>
22
23
  hasAI: boolean
@@ -32,8 +33,33 @@ export interface CommandDeps {
32
33
  export function createCommands(deps: CommandDeps): CmdDef[] {
33
34
  return [
34
35
  // === Configuration & Setup ===
35
- { name: "/ai", description: "Configure AI provider (paste URL + key)", action: () => deps.startAIConfig() },
36
- { name: "/model", description: "Choose AI model", action: () => deps.navigate({ type: "model" }) },
36
+ { name: "/ai", description: "Quick AI setup (URL + key)", action: () => deps.startAIConfig() },
37
+ { name: "/model", description: "Switch model (picker or /model <id>)", action: async (parts) => {
38
+ const query = parts.slice(1).join(" ").trim()
39
+ if (!query) {
40
+ await deps.openModelPicker()
41
+ return
42
+ }
43
+ const { AIProvider } = await import("../ai/provider")
44
+ const { Config } = await import("../config")
45
+ const list = await AIProvider.available()
46
+ const all = list.filter((m) => m.hasKey).map((m) => m.model)
47
+
48
+ const picked = all.find((m) =>
49
+ m.id === query ||
50
+ `${m.providerID}/${m.id}` === query ||
51
+ (m.providerID === "openai-compatible" && `openai-compatible/${m.id}` === query)
52
+ )
53
+ if (!picked) {
54
+ deps.showMsg(`Model not found: ${query}. Run /model to list available models.`, deps.colors.warning)
55
+ return
56
+ }
57
+
58
+ const saveId = picked.providerID === "openai-compatible" ? `openai-compatible/${picked.id}` : picked.id
59
+ await Config.save({ model: saveId })
60
+ deps.onAIConfigured()
61
+ deps.showMsg(`Model switched to ${saveId}`, deps.colors.success)
62
+ }},
37
63
  { name: "/login", description: "Sign in to CodeBlog", action: async () => {
38
64
  deps.showMsg("Opening browser for login...", deps.colors.primary)
39
65
  await deps.onLogin()
@@ -134,7 +160,15 @@ export function createCommands(deps: CommandDeps): CmdDef[] {
134
160
  // === UI & Navigation ===
135
161
  { name: "/clear", description: "Clear conversation", action: () => deps.clearChat() },
136
162
  { name: "/new", description: "New conversation", action: () => deps.clearChat() },
137
- { name: "/theme", description: "Change color theme", action: () => deps.navigate({ type: "theme" }) },
163
+ { name: "/theme", description: "Theme mode: /theme [light|dark]", action: (parts) => {
164
+ const mode = (parts[1] || "").toLowerCase()
165
+ if (mode === "dark" || mode === "light") {
166
+ deps.setMode(mode)
167
+ deps.showMsg(`Theme switched to ${mode}`, deps.colors.success)
168
+ return
169
+ }
170
+ deps.showMsg("Use /theme dark or /theme light (or /dark /light)", deps.colors.text)
171
+ }},
138
172
  { name: "/dark", description: "Switch to dark mode", action: () => { deps.setMode("dark"); deps.showMsg("Dark mode", deps.colors.text) } },
139
173
  { name: "/light", description: "Switch to light mode", action: () => { deps.setMode("light"); deps.showMsg("Light mode", deps.colors.text) } },
140
174
  { name: "/resume", description: "Resume last chat session", action: (parts) => deps.resume(parts[1]) },
@@ -155,7 +189,7 @@ export function createCommands(deps: CommandDeps): CmdDef[] {
155
189
  }
156
190
 
157
191
  export const TIPS = [
158
- "Type /ai to configure your AI provider with a URL and API key",
192
+ "Type /ai for quick setup, or run `codeblog ai setup` for full onboarding",
159
193
  "Type /model to switch between available AI models",
160
194
  "Use /scan to discover IDE coding sessions from Cursor, Windsurf, etc.",
161
195
  "Use /publish to share your coding sessions as blog posts",
@@ -169,7 +203,7 @@ export const TIPS = [
169
203
  ]
170
204
 
171
205
  export const TIPS_NO_AI = [
172
- "Type /ai to configure your AI provider unlock AI chat and smart commands",
206
+ "Type /ai for quick setup, or run `codeblog ai setup` for full onboarding",
173
207
  "Commands in grey require AI. Type /ai to set up your provider first",
174
208
  "Type / to see all available commands with autocomplete",
175
209
  "Configure AI with /ai — then chat naturally to browse, post, and interact",
@@ -417,6 +417,7 @@ export const THEMES: Record<string, ThemeDef> = {
417
417
  }
418
418
 
419
419
  export const THEME_NAMES = Object.keys(THEMES)
420
+ const DEFAULT_THEME: ThemeDef = codeblog
420
421
 
421
422
  const configPath = path.join(Global.Path.config, "theme.json")
422
423
 
@@ -446,7 +447,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
446
447
 
447
448
  return {
448
449
  get colors(): ThemeColors {
449
- const def = THEMES[store.name] || THEMES.codeblog
450
+ const def = THEMES[store.name] ?? DEFAULT_THEME
450
451
  return def[store.mode]
451
452
  },
452
453
  get name() { return store.name },
@@ -0,0 +1,26 @@
1
+ export type InputIntent = "newline"
2
+
3
+ const listeners = new Set<(intent: InputIntent) => void>()
4
+
5
+ // Common Shift+Enter escape sequences observed across kitty/xterm-style terminals.
6
+ const SHIFT_ENTER_PATTERNS = [
7
+ /^\x1b\[(?:13|57345);2(?::\d+)?u$/,
8
+ /^\x1b\[27;2;13~$/,
9
+ /^\x1b\[13;2~$/,
10
+ ]
11
+
12
+ export function isShiftEnterSequence(sequence: string): boolean {
13
+ if (!sequence) return false
14
+ const normalized = sequence.replace(/[\r\n]+$/g, "")
15
+ if (!normalized) return false
16
+ return SHIFT_ENTER_PATTERNS.some((pattern) => pattern.test(normalized))
17
+ }
18
+
19
+ export function emitInputIntent(intent: InputIntent) {
20
+ for (const listener of listeners) listener(intent)
21
+ }
22
+
23
+ export function onInputIntent(listener: (intent: InputIntent) => void) {
24
+ listeners.add(listener)
25
+ return () => listeners.delete(listener)
26
+ }