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.
- package/package.json +9 -7
- package/src/ai/__tests__/chat.test.ts +11 -2
- package/src/ai/__tests__/compat.test.ts +46 -0
- package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
- package/src/ai/__tests__/provider-registry.test.ts +61 -0
- package/src/ai/__tests__/provider.test.ts +58 -18
- package/src/ai/__tests__/stream-events.test.ts +152 -0
- package/src/ai/chat.ts +200 -88
- package/src/ai/configure.ts +13 -4
- package/src/ai/models.ts +26 -0
- package/src/ai/provider-registry.ts +150 -0
- package/src/ai/provider.ts +99 -137
- package/src/ai/stream-events.ts +64 -0
- package/src/ai/tools.ts +10 -6
- package/src/ai/types.ts +105 -0
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth.ts +17 -2
- package/src/cli/__tests__/commands.test.ts +6 -2
- package/src/cli/cmd/ai.ts +10 -0
- package/src/cli/cmd/setup.ts +275 -5
- package/src/cli/ui.ts +131 -24
- package/src/config/index.ts +38 -1
- package/src/index.ts +4 -1
- package/src/mcp/__tests__/client.test.ts +2 -2
- package/src/mcp/__tests__/e2e.ts +10 -6
- package/src/mcp/client.ts +33 -63
- package/src/storage/chat.ts +3 -1
- package/src/tui/__tests__/input-intent.test.ts +27 -0
- package/src/tui/__tests__/stream-assembler.test.ts +33 -0
- package/src/tui/ai-stream.ts +28 -0
- package/src/tui/app.tsx +27 -1
- package/src/tui/commands.ts +41 -7
- package/src/tui/context/theme.tsx +2 -1
- package/src/tui/input-intent.ts +26 -0
- package/src/tui/routes/home.tsx +590 -190
- package/src/tui/routes/setup.tsx +20 -8
- package/src/tui/stream-assembler.ts +49 -0
- package/src/util/log.ts +3 -1
- package/tsconfig.json +1 -1
package/src/mcp/__tests__/e2e.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
41
|
-
log.debug("connecting", { path: mcpPath })
|
|
17
|
+
log.info("connecting via InMemoryTransport")
|
|
42
18
|
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
|
31
|
+
await server.connect(serverTransport)
|
|
32
|
+
await c.connect(ct)
|
|
59
33
|
} catch (err) {
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
40
|
+
log.info("connected", {
|
|
66
41
|
server: c.getServerVersion()?.name,
|
|
67
42
|
version: c.getServerVersion()?.version,
|
|
68
43
|
})
|
|
69
44
|
|
|
70
|
-
|
|
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
|
-
|
|
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 (
|
|
134
|
-
await
|
|
135
|
-
|
|
103
|
+
if (clientTransport) {
|
|
104
|
+
await clientTransport.close().catch(() => {})
|
|
105
|
+
clientTransport = null
|
|
136
106
|
}
|
|
137
107
|
client = null
|
|
138
108
|
}
|
package/src/storage/chat.ts
CHANGED
|
@@ -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
|
})
|
package/src/tui/commands.ts
CHANGED
|
@@ -9,14 +9,15 @@ export interface CmdDef {
|
|
|
9
9
|
|
|
10
10
|
export interface CommandDeps {
|
|
11
11
|
showMsg: (text: string, color?: string) => void
|
|
12
|
-
|
|
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: "
|
|
36
|
-
{ name: "/model", description: "
|
|
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: "
|
|
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
|
|
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
|
|
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]
|
|
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
|
+
}
|