oh-my-opencode-dashboard 0.0.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/README.md +100 -0
- package/dashboard-ui.png +0 -0
- package/dist/assets/index-D6OVzN1o.css +1 -0
- package/dist/assets/index-SEmwze_4.js +40 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +51 -0
- package/src/App.tsx +518 -0
- package/src/cli/dev.ts +139 -0
- package/src/cli/ports.test.ts +40 -0
- package/src/cli/ports.ts +43 -0
- package/src/ding-policy.test.ts +48 -0
- package/src/ding-policy.ts +39 -0
- package/src/ingest/background-tasks.test.ts +707 -0
- package/src/ingest/background-tasks.ts +317 -0
- package/src/ingest/boulder.test.ts +77 -0
- package/src/ingest/boulder.ts +71 -0
- package/src/ingest/paths.test.ts +82 -0
- package/src/ingest/paths.ts +76 -0
- package/src/ingest/session.test.ts +220 -0
- package/src/ingest/session.ts +283 -0
- package/src/main.tsx +10 -0
- package/src/server/api.test.ts +62 -0
- package/src/server/api.ts +16 -0
- package/src/server/build.ts +5 -0
- package/src/server/dashboard.test.ts +135 -0
- package/src/server/dashboard.ts +191 -0
- package/src/server/dev.ts +44 -0
- package/src/server/start.ts +93 -0
- package/src/sound.test.ts +55 -0
- package/src/sound.ts +89 -0
- package/src/styles.css +457 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { buildDashboardPayload } from "./dashboard"
|
|
6
|
+
import { getStorageRoots } from "../ingest/session"
|
|
7
|
+
|
|
8
|
+
function mkStorageRoot(): string {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
|
|
10
|
+
fs.mkdirSync(path.join(root, "session"), { recursive: true })
|
|
11
|
+
fs.mkdirSync(path.join(root, "message"), { recursive: true })
|
|
12
|
+
fs.mkdirSync(path.join(root, "part"), { recursive: true })
|
|
13
|
+
return root
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("buildDashboardPayload", () => {
|
|
17
|
+
it("surfaces 'running tool' status when session has in-flight tool", () => {
|
|
18
|
+
const storageRoot = mkStorageRoot()
|
|
19
|
+
const storage = getStorageRoots(storageRoot)
|
|
20
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
21
|
+
const sessionId = "ses_running_tool"
|
|
22
|
+
const messageId = "msg_1"
|
|
23
|
+
const projectID = "proj_1"
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
27
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
id: sessionId,
|
|
32
|
+
projectID,
|
|
33
|
+
directory: projectRoot,
|
|
34
|
+
time: { created: 1000, updated: 1000 },
|
|
35
|
+
}),
|
|
36
|
+
"utf8"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const messageDir = path.join(storage.message, sessionId)
|
|
40
|
+
fs.mkdirSync(messageDir, { recursive: true })
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
path.join(messageDir, `${messageId}.json`),
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
id: messageId,
|
|
45
|
+
sessionID: sessionId,
|
|
46
|
+
role: "assistant",
|
|
47
|
+
agent: "sisyphus",
|
|
48
|
+
time: { created: 1000 },
|
|
49
|
+
}),
|
|
50
|
+
"utf8"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const partDir = path.join(storage.part, messageId)
|
|
54
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(partDir, "part_1.json"),
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
id: "part_1",
|
|
59
|
+
sessionID: sessionId,
|
|
60
|
+
messageID: messageId,
|
|
61
|
+
type: "tool",
|
|
62
|
+
callID: "call_1",
|
|
63
|
+
tool: "delegate_task",
|
|
64
|
+
state: { status: "running", input: {} },
|
|
65
|
+
}),
|
|
66
|
+
"utf8"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const payload = buildDashboardPayload({
|
|
70
|
+
projectRoot,
|
|
71
|
+
storage,
|
|
72
|
+
nowMs: 2000,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(payload.mainSession.statusPill).toBe("running tool")
|
|
76
|
+
expect(payload.mainSession.currentTool).toBe("delegate_task")
|
|
77
|
+
expect(payload.mainSession.agent).toBe("sisyphus")
|
|
78
|
+
|
|
79
|
+
expect(payload.raw).not.toHaveProperty("prompt")
|
|
80
|
+
expect(payload.raw).not.toHaveProperty("input")
|
|
81
|
+
} finally {
|
|
82
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
83
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("surfaces 'thinking' status when latest assistant message is not completed", () => {
|
|
88
|
+
const storageRoot = mkStorageRoot()
|
|
89
|
+
const storage = getStorageRoots(storageRoot)
|
|
90
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
91
|
+
const sessionId = "ses_thinking"
|
|
92
|
+
const messageId = "msg_1"
|
|
93
|
+
const projectID = "proj_1"
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
97
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
id: sessionId,
|
|
102
|
+
projectID,
|
|
103
|
+
directory: projectRoot,
|
|
104
|
+
time: { created: 1000, updated: 1000 },
|
|
105
|
+
}),
|
|
106
|
+
"utf8"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const messageDir = path.join(storage.message, sessionId)
|
|
110
|
+
fs.mkdirSync(messageDir, { recursive: true })
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
path.join(messageDir, `${messageId}.json`),
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
id: messageId,
|
|
115
|
+
sessionID: sessionId,
|
|
116
|
+
role: "assistant",
|
|
117
|
+
agent: "sisyphus",
|
|
118
|
+
time: { created: 1000 },
|
|
119
|
+
}),
|
|
120
|
+
"utf8"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const payload = buildDashboardPayload({
|
|
124
|
+
projectRoot,
|
|
125
|
+
storage,
|
|
126
|
+
nowMs: 50_000,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(payload.mainSession.statusPill).toBe("thinking")
|
|
130
|
+
} finally {
|
|
131
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
132
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import { readBoulderState, readPlanProgress } from "../ingest/boulder"
|
|
4
|
+
import { deriveBackgroundTasks } from "../ingest/background-tasks"
|
|
5
|
+
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
6
|
+
|
|
7
|
+
export type DashboardPayload = {
|
|
8
|
+
mainSession: {
|
|
9
|
+
agent: string
|
|
10
|
+
currentTool: string
|
|
11
|
+
lastUpdatedLabel: string
|
|
12
|
+
session: string
|
|
13
|
+
statusPill: string
|
|
14
|
+
}
|
|
15
|
+
planProgress: {
|
|
16
|
+
name: string
|
|
17
|
+
completed: number
|
|
18
|
+
total: number
|
|
19
|
+
path: string
|
|
20
|
+
statusPill: string
|
|
21
|
+
}
|
|
22
|
+
backgroundTasks: Array<{
|
|
23
|
+
id: string
|
|
24
|
+
description: string
|
|
25
|
+
agent: string
|
|
26
|
+
status: string
|
|
27
|
+
toolCalls: number
|
|
28
|
+
lastTool: string
|
|
29
|
+
timeline: string
|
|
30
|
+
}>
|
|
31
|
+
raw: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DashboardStore = {
|
|
35
|
+
getSnapshot: () => DashboardPayload
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatIso(ts: number | null): string {
|
|
39
|
+
if (!ts) return "never"
|
|
40
|
+
try {
|
|
41
|
+
return new Date(ts).toISOString()
|
|
42
|
+
} catch {
|
|
43
|
+
return "never"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function planStatusPill(progress: { missing: boolean; isComplete: boolean }): string {
|
|
48
|
+
if (progress.missing) return "not started"
|
|
49
|
+
return progress.isComplete ? "complete" : "in progress"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mainStatusPill(status: string): string {
|
|
53
|
+
if (status === "running_tool") return "running tool"
|
|
54
|
+
if (status === "thinking") return "thinking"
|
|
55
|
+
if (status === "busy") return "busy"
|
|
56
|
+
if (status === "idle") return "idle"
|
|
57
|
+
return "unknown"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildDashboardPayload(opts: {
|
|
61
|
+
projectRoot: string
|
|
62
|
+
storage: OpenCodeStorageRoots
|
|
63
|
+
nowMs?: number
|
|
64
|
+
}): DashboardPayload {
|
|
65
|
+
const nowMs = opts.nowMs ?? Date.now()
|
|
66
|
+
|
|
67
|
+
const boulder = readBoulderState(opts.projectRoot)
|
|
68
|
+
const planName = boulder?.plan_name ?? "(no active plan)"
|
|
69
|
+
const planPath = boulder?.active_plan ?? ""
|
|
70
|
+
const plan = boulder ? readPlanProgress(opts.projectRoot, boulder.active_plan) : { total: 0, completed: 0, isComplete: false, missing: true }
|
|
71
|
+
|
|
72
|
+
const sessionId = pickActiveSessionId({
|
|
73
|
+
projectRoot: opts.projectRoot,
|
|
74
|
+
storage: opts.storage,
|
|
75
|
+
boulderSessionIds: boulder?.session_ids,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
let sessionMeta: SessionMetadata | null = null
|
|
79
|
+
if (sessionId) {
|
|
80
|
+
const metas = readMainSessionMetas(opts.storage.session, opts.projectRoot)
|
|
81
|
+
sessionMeta = metas.find((m) => m.id === sessionId) ?? null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const main = sessionId
|
|
85
|
+
? getMainSessionView({
|
|
86
|
+
projectRoot: opts.projectRoot,
|
|
87
|
+
sessionId,
|
|
88
|
+
storage: opts.storage,
|
|
89
|
+
sessionMeta,
|
|
90
|
+
nowMs,
|
|
91
|
+
})
|
|
92
|
+
: { agent: "unknown", currentTool: null, lastUpdated: null, sessionLabel: "(no session)", status: "unknown" as const }
|
|
93
|
+
|
|
94
|
+
const tasks = sessionId ? deriveBackgroundTasks({ storage: opts.storage, mainSessionId: sessionId, nowMs }) : []
|
|
95
|
+
|
|
96
|
+
const payload: DashboardPayload = {
|
|
97
|
+
mainSession: {
|
|
98
|
+
agent: main.agent,
|
|
99
|
+
currentTool: main.currentTool ?? "-",
|
|
100
|
+
lastUpdatedLabel: formatIso(main.lastUpdated),
|
|
101
|
+
session: main.sessionLabel,
|
|
102
|
+
statusPill: mainStatusPill(main.status),
|
|
103
|
+
},
|
|
104
|
+
planProgress: {
|
|
105
|
+
name: planName,
|
|
106
|
+
completed: plan.completed,
|
|
107
|
+
total: plan.total,
|
|
108
|
+
path: planPath,
|
|
109
|
+
statusPill: planStatusPill(plan),
|
|
110
|
+
},
|
|
111
|
+
backgroundTasks: tasks.map((t) => ({
|
|
112
|
+
id: t.id,
|
|
113
|
+
description: t.description,
|
|
114
|
+
agent: t.agent,
|
|
115
|
+
status: t.status,
|
|
116
|
+
toolCalls: t.toolCalls ?? 0,
|
|
117
|
+
lastTool: t.lastTool ?? "-",
|
|
118
|
+
timeline: typeof t.timeline === "string" ? t.timeline : "",
|
|
119
|
+
})),
|
|
120
|
+
raw: null,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
payload.raw = {
|
|
124
|
+
mainSession: payload.mainSession,
|
|
125
|
+
planProgress: payload.planProgress,
|
|
126
|
+
backgroundTasks: payload.backgroundTasks,
|
|
127
|
+
}
|
|
128
|
+
return payload
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function watchIfExists(target: string, onChange: () => void): fs.FSWatcher | null {
|
|
132
|
+
try {
|
|
133
|
+
if (!fs.existsSync(target)) return null
|
|
134
|
+
return fs.watch(target, { persistent: false }, () => onChange())
|
|
135
|
+
} catch {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createDashboardStore(opts: {
|
|
141
|
+
projectRoot: string
|
|
142
|
+
storageRoot: string
|
|
143
|
+
pollIntervalMs?: number
|
|
144
|
+
watch?: boolean
|
|
145
|
+
}): DashboardStore {
|
|
146
|
+
const storage = getStorageRoots(opts.storageRoot)
|
|
147
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 2000
|
|
148
|
+
const watch = opts.watch !== false
|
|
149
|
+
|
|
150
|
+
let lastComputedAt = 0
|
|
151
|
+
let dirty = true
|
|
152
|
+
let cached: DashboardPayload | null = null
|
|
153
|
+
|
|
154
|
+
const watchers: fs.FSWatcher[] = []
|
|
155
|
+
const markDirty = () => {
|
|
156
|
+
dirty = true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (watch) {
|
|
160
|
+
watchers.push(...[
|
|
161
|
+
watchIfExists(path.join(opts.projectRoot, ".sisyphus", "boulder.json"), markDirty),
|
|
162
|
+
watchIfExists(path.join(opts.projectRoot, ".sisyphus", "plans"), markDirty),
|
|
163
|
+
watchIfExists(storage.session, markDirty),
|
|
164
|
+
watchIfExists(storage.message, markDirty),
|
|
165
|
+
watchIfExists(storage.part, markDirty),
|
|
166
|
+
].filter(Boolean) as fs.FSWatcher[])
|
|
167
|
+
|
|
168
|
+
// Best-effort: close watchers on process exit.
|
|
169
|
+
process.on("exit", () => {
|
|
170
|
+
for (const w of watchers) {
|
|
171
|
+
try {
|
|
172
|
+
w.close()
|
|
173
|
+
} catch {
|
|
174
|
+
// ignore
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
getSnapshot() {
|
|
182
|
+
const now = Date.now()
|
|
183
|
+
if (!cached || dirty || now - lastComputedAt > pollIntervalMs) {
|
|
184
|
+
cached = buildDashboardPayload({ projectRoot: opts.projectRoot, storage })
|
|
185
|
+
lastComputedAt = now
|
|
186
|
+
dirty = false
|
|
187
|
+
}
|
|
188
|
+
return cached
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Hono } from "hono"
|
|
3
|
+
import { createApi } from "./api"
|
|
4
|
+
import { createDashboardStore } from "./dashboard"
|
|
5
|
+
import { getOpenCodeStorageDir } from "../ingest/paths"
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2)
|
|
8
|
+
let projectPath: string | undefined;
|
|
9
|
+
let port = 51234;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (arg === '--project' && i + 1 < args.length) {
|
|
14
|
+
projectPath = args[i + 1];
|
|
15
|
+
i++;
|
|
16
|
+
} else if (arg === '--port' && i + 1 < args.length) {
|
|
17
|
+
const portValue = parseInt(args[i + 1], 10);
|
|
18
|
+
if (!isNaN(portValue)) {
|
|
19
|
+
port = portValue;
|
|
20
|
+
}
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const resolvedProjectPath = projectPath ?? process.cwd()
|
|
26
|
+
|
|
27
|
+
const app = new Hono()
|
|
28
|
+
|
|
29
|
+
const store = createDashboardStore({
|
|
30
|
+
projectRoot: resolvedProjectPath,
|
|
31
|
+
storageRoot: getOpenCodeStorageDir(),
|
|
32
|
+
watch: true,
|
|
33
|
+
pollIntervalMs: 2000,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
app.route("/api", createApi(store))
|
|
37
|
+
|
|
38
|
+
Bun.serve({
|
|
39
|
+
fetch: app.fetch,
|
|
40
|
+
hostname: '127.0.0.1',
|
|
41
|
+
port,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
console.log(`Server running at http://127.0.0.1:${port}`)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'util'
|
|
5
|
+
import { createApi } from "./api"
|
|
6
|
+
import { createDashboardStore } from "./dashboard"
|
|
7
|
+
import { getOpenCodeStorageDir } from "../ingest/paths"
|
|
8
|
+
|
|
9
|
+
const { values } = parseArgs({
|
|
10
|
+
args: Bun.argv,
|
|
11
|
+
options: {
|
|
12
|
+
project: { type: 'string' },
|
|
13
|
+
port: { type: 'string' },
|
|
14
|
+
},
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const project = values.project ?? process.cwd()
|
|
19
|
+
|
|
20
|
+
const port = parseInt(values.port || '51234')
|
|
21
|
+
|
|
22
|
+
const app = new Hono()
|
|
23
|
+
|
|
24
|
+
const store = createDashboardStore({
|
|
25
|
+
projectRoot: project,
|
|
26
|
+
storageRoot: getOpenCodeStorageDir(),
|
|
27
|
+
watch: true,
|
|
28
|
+
pollIntervalMs: 2000,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
app.route('/api', createApi(store))
|
|
32
|
+
|
|
33
|
+
const distRoot = join(import.meta.dir, '../../dist')
|
|
34
|
+
|
|
35
|
+
// SPA fallback middleware
|
|
36
|
+
app.use('*', async (c, next) => {
|
|
37
|
+
const path = c.req.path
|
|
38
|
+
|
|
39
|
+
// Skip API routes - let them pass through
|
|
40
|
+
if (path.startsWith('/api/')) {
|
|
41
|
+
return await next()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// For non-API routes without extensions, serve index.html
|
|
45
|
+
if (!path.includes('.')) {
|
|
46
|
+
const indexFile = Bun.file(join(distRoot, 'index.html'))
|
|
47
|
+
if (await indexFile.exists()) {
|
|
48
|
+
return c.html(await indexFile.text())
|
|
49
|
+
}
|
|
50
|
+
return c.notFound()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// For static files with extensions, try to serve them
|
|
54
|
+
const relativePath = path.startsWith('/') ? path.slice(1) : path
|
|
55
|
+
const file = Bun.file(join(distRoot, relativePath))
|
|
56
|
+
if (await file.exists()) {
|
|
57
|
+
const ext = path.split('.').pop() || ''
|
|
58
|
+
const contentType = getContentType(ext)
|
|
59
|
+
return new Response(file, {
|
|
60
|
+
headers: { 'Content-Type': contentType }
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return c.notFound()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function getContentType(ext: string): string {
|
|
68
|
+
const types: Record<string, string> = {
|
|
69
|
+
'html': 'text/html',
|
|
70
|
+
'js': 'application/javascript',
|
|
71
|
+
'css': 'text/css',
|
|
72
|
+
'json': 'application/json',
|
|
73
|
+
'png': 'image/png',
|
|
74
|
+
'jpg': 'image/jpeg',
|
|
75
|
+
'jpeg': 'image/jpeg',
|
|
76
|
+
'gif': 'image/gif',
|
|
77
|
+
'svg': 'image/svg+xml',
|
|
78
|
+
'ico': 'image/x-icon',
|
|
79
|
+
'woff': 'font/woff',
|
|
80
|
+
'woff2': 'font/woff2',
|
|
81
|
+
'ttf': 'font/ttf',
|
|
82
|
+
'eot': 'application/vnd.ms-fontobject',
|
|
83
|
+
}
|
|
84
|
+
return types[ext] || 'text/plain'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Bun.serve({
|
|
88
|
+
fetch: app.fetch,
|
|
89
|
+
hostname: '127.0.0.1',
|
|
90
|
+
port,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
console.log(`Server running on http://127.0.0.1:${port}`)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
describe("playDing", () => {
|
|
4
|
+
const prevWindow = (globalThis as unknown as { window?: unknown }).window
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
;(globalThis as unknown as { window?: unknown }).window = prevWindow
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("plays two tones for waiting", async () => {
|
|
11
|
+
const oscillators: Array<{ start: ReturnType<typeof vi.fn> }> = []
|
|
12
|
+
|
|
13
|
+
class FakeAudioContext {
|
|
14
|
+
public state: AudioContextState = "suspended"
|
|
15
|
+
public currentTime = 1
|
|
16
|
+
public destination = null as unknown as AudioDestinationNode
|
|
17
|
+
|
|
18
|
+
resume = vi.fn(async () => {
|
|
19
|
+
this.state = "running"
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
createOscillator(): OscillatorNode {
|
|
23
|
+
const osc = {
|
|
24
|
+
type: "sine" as OscillatorType,
|
|
25
|
+
frequency: { setValueAtTime: vi.fn() },
|
|
26
|
+
connect: vi.fn(),
|
|
27
|
+
start: vi.fn(),
|
|
28
|
+
stop: vi.fn(),
|
|
29
|
+
}
|
|
30
|
+
oscillators.push({ start: osc.start })
|
|
31
|
+
return osc as unknown as OscillatorNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
createGain(): GainNode {
|
|
35
|
+
const g = {
|
|
36
|
+
gain: {
|
|
37
|
+
setValueAtTime: vi.fn(),
|
|
38
|
+
linearRampToValueAtTime: vi.fn(),
|
|
39
|
+
},
|
|
40
|
+
connect: vi.fn(),
|
|
41
|
+
}
|
|
42
|
+
return g as unknown as GainNode
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
;(globalThis as unknown as { window?: unknown }).window = {
|
|
47
|
+
AudioContext: FakeAudioContext,
|
|
48
|
+
} as unknown as Window & typeof globalThis
|
|
49
|
+
|
|
50
|
+
const { playDing } = await import("./sound")
|
|
51
|
+
await playDing("waiting")
|
|
52
|
+
|
|
53
|
+
expect(oscillators).toHaveLength(2)
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/sound.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type DingKind = "waiting" | "task" | "all"
|
|
2
|
+
|
|
3
|
+
let ctx: AudioContext | null = null
|
|
4
|
+
|
|
5
|
+
function getCtx(): AudioContext | null {
|
|
6
|
+
if (typeof window === "undefined") return null
|
|
7
|
+
const AnyAudioContext = (window.AudioContext ?? (window as any).webkitAudioContext) as
|
|
8
|
+
| (new () => AudioContext)
|
|
9
|
+
| undefined
|
|
10
|
+
if (!AnyAudioContext) return null
|
|
11
|
+
|
|
12
|
+
if (!ctx) {
|
|
13
|
+
ctx = new AnyAudioContext()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return ctx
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function unlockAudio(): Promise<boolean> {
|
|
20
|
+
const c = getCtx()
|
|
21
|
+
if (!c) return false
|
|
22
|
+
try {
|
|
23
|
+
if (c.state !== "running") {
|
|
24
|
+
await c.resume()
|
|
25
|
+
}
|
|
26
|
+
return c.state === "running"
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function playTone(opts: {
|
|
33
|
+
at: number
|
|
34
|
+
freq: number
|
|
35
|
+
dur: number
|
|
36
|
+
gain: number
|
|
37
|
+
}): void {
|
|
38
|
+
const c = getCtx()
|
|
39
|
+
if (!c) return
|
|
40
|
+
if (c.state !== "running") return
|
|
41
|
+
|
|
42
|
+
const osc = c.createOscillator()
|
|
43
|
+
const g = c.createGain()
|
|
44
|
+
|
|
45
|
+
osc.type = "sine"
|
|
46
|
+
osc.frequency.setValueAtTime(opts.freq, opts.at)
|
|
47
|
+
|
|
48
|
+
const attack = Math.min(0.01, opts.dur / 4)
|
|
49
|
+
const release = Math.min(0.08, opts.dur / 2)
|
|
50
|
+
|
|
51
|
+
g.gain.setValueAtTime(0, opts.at)
|
|
52
|
+
g.gain.linearRampToValueAtTime(opts.gain, opts.at + attack)
|
|
53
|
+
g.gain.setValueAtTime(opts.gain, opts.at + Math.max(attack, opts.dur - release))
|
|
54
|
+
g.gain.linearRampToValueAtTime(0, opts.at + opts.dur)
|
|
55
|
+
|
|
56
|
+
osc.connect(g)
|
|
57
|
+
g.connect(c.destination)
|
|
58
|
+
|
|
59
|
+
osc.start(opts.at)
|
|
60
|
+
osc.stop(opts.at + opts.dur + 0.01)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function playDing(kind: DingKind): Promise<void> {
|
|
64
|
+
const ok = await unlockAudio()
|
|
65
|
+
if (!ok) return
|
|
66
|
+
|
|
67
|
+
const c = getCtx()
|
|
68
|
+
if (!c) return
|
|
69
|
+
|
|
70
|
+
const t0 = c.currentTime + 0.01
|
|
71
|
+
const baseGain = 0.06
|
|
72
|
+
|
|
73
|
+
if (kind === "waiting") {
|
|
74
|
+
playTone({ at: t0, freq: 784, dur: 0.08, gain: baseGain })
|
|
75
|
+
playTone({ at: t0 + 0.10, freq: 659, dur: 0.10, gain: baseGain })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (kind === "task") {
|
|
80
|
+
playTone({ at: t0, freq: 659, dur: 0.08, gain: baseGain })
|
|
81
|
+
playTone({ at: t0 + 0.10, freq: 880, dur: 0.10, gain: baseGain })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// all
|
|
86
|
+
playTone({ at: t0, freq: 523.25, dur: 0.10, gain: baseGain })
|
|
87
|
+
playTone({ at: t0 + 0.12, freq: 659.25, dur: 0.10, gain: baseGain })
|
|
88
|
+
playTone({ at: t0 + 0.24, freq: 783.99, dur: 0.14, gain: baseGain })
|
|
89
|
+
}
|