@defrex/autobuild 0.8.0
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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** Pure Unicode-aware text geometry for the dashboard's editable field. */
|
|
2
|
+
import {
|
|
3
|
+
cellWidth,
|
|
4
|
+
displayGraphemes,
|
|
5
|
+
displayText,
|
|
6
|
+
graphemeBoundary,
|
|
7
|
+
graphemes,
|
|
8
|
+
splitCells,
|
|
9
|
+
} from './cells'
|
|
10
|
+
|
|
11
|
+
export { displayText } from './cells'
|
|
12
|
+
|
|
13
|
+
/** Wrap prose in terminal cells without splitting a grapheme cluster. */
|
|
14
|
+
export function wrapDisplay(value: string, width: number, indent = ''): string[] {
|
|
15
|
+
if (width <= 0) return []
|
|
16
|
+
const pad = cellWidth(indent) < width ? indent : ''
|
|
17
|
+
const budget = width - cellWidth(pad)
|
|
18
|
+
const rows: string[] = []
|
|
19
|
+
|
|
20
|
+
for (const paragraph of value.split(/\r?\n/)) {
|
|
21
|
+
const paragraphStart = rows.length
|
|
22
|
+
const tokens = displayText(paragraph)
|
|
23
|
+
.split(/\s+/u)
|
|
24
|
+
.filter((token) => token !== '')
|
|
25
|
+
if (tokens.length === 0) {
|
|
26
|
+
rows.push(pad)
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let line = ''
|
|
31
|
+
let used = 0
|
|
32
|
+
for (const token of tokens) {
|
|
33
|
+
const tokenWidth = cellWidth(token)
|
|
34
|
+
if (line !== '' && used + 1 + tokenWidth <= budget) {
|
|
35
|
+
line += ` ${token}`
|
|
36
|
+
used += 1 + tokenWidth
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (line !== '') {
|
|
40
|
+
rows.push(`${pad}${line}`)
|
|
41
|
+
line = ''
|
|
42
|
+
used = 0
|
|
43
|
+
}
|
|
44
|
+
if (tokenWidth <= budget) {
|
|
45
|
+
line = token
|
|
46
|
+
used = tokenWidth
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const pieces = splitCells(token, budget)
|
|
50
|
+
for (const piece of pieces.slice(0, -1)) rows.push(`${pad}${piece}`)
|
|
51
|
+
line = pieces.at(-1) ?? ''
|
|
52
|
+
used = cellWidth(line)
|
|
53
|
+
}
|
|
54
|
+
if (line !== '') rows.push(`${pad}${line}`)
|
|
55
|
+
else if (rows.length === paragraphStart) rows.push(pad)
|
|
56
|
+
}
|
|
57
|
+
return rows
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pack labels atomically, dropping a label that cannot fit one row. */
|
|
61
|
+
export function packAtomic(tokens: string[], width: number, separator = ' '): string[] {
|
|
62
|
+
if (width <= 0) return []
|
|
63
|
+
const rows: string[] = []
|
|
64
|
+
let line = ''
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
if (cellWidth(token) > width) continue
|
|
67
|
+
if (line === '') {
|
|
68
|
+
line = token
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const candidate = `${line}${separator}${token}`
|
|
72
|
+
if (cellWidth(candidate) <= width) line = candidate
|
|
73
|
+
else {
|
|
74
|
+
rows.push(line)
|
|
75
|
+
line = token
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (line !== '') rows.push(line)
|
|
79
|
+
return rows
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ComposerLayout {
|
|
83
|
+
rows: string[]
|
|
84
|
+
cursorRow: number
|
|
85
|
+
/** Caret column in terminal display cells. */
|
|
86
|
+
cursorColumn: number
|
|
87
|
+
/** UTF-16 insertion offset within cursorRow. */
|
|
88
|
+
cursorOffset: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function composerBudget(width: number): number {
|
|
92
|
+
return Math.max(1, width - 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Wrap the original value and map a UTF-16 grapheme boundary to its caret. */
|
|
96
|
+
export function layoutComposer(value: string, cursor: number, width: number): ComposerLayout {
|
|
97
|
+
const budget = composerBudget(width)
|
|
98
|
+
const rows: string[] = []
|
|
99
|
+
const positions = new Map<number, { row: number; column: number; offset: number }>()
|
|
100
|
+
let current = ''
|
|
101
|
+
let used = 0
|
|
102
|
+
positions.set(0, { row: 0, column: 0, offset: 0 })
|
|
103
|
+
|
|
104
|
+
for (const source of graphemes(value)) {
|
|
105
|
+
positions.set(source.start, { row: rows.length, column: used, offset: current.length })
|
|
106
|
+
if (source.text === '\n') {
|
|
107
|
+
rows.push(current)
|
|
108
|
+
current = ''
|
|
109
|
+
used = 0
|
|
110
|
+
positions.set(source.end, { row: rows.length, column: 0, offset: 0 })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const displayed of displayGraphemes(source.text)) {
|
|
115
|
+
if (displayed.width > budget) continue
|
|
116
|
+
if (displayed.width > 0 && used + displayed.width > budget) {
|
|
117
|
+
rows.push(current)
|
|
118
|
+
current = ''
|
|
119
|
+
used = 0
|
|
120
|
+
}
|
|
121
|
+
current += displayed.text
|
|
122
|
+
used += displayed.width
|
|
123
|
+
}
|
|
124
|
+
positions.set(source.end, {
|
|
125
|
+
row: rows.length,
|
|
126
|
+
column: used,
|
|
127
|
+
offset: current.length,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
rows.push(current)
|
|
131
|
+
const normalized = clampCursor(value, cursor)
|
|
132
|
+
const at = positions.get(normalized) ?? {
|
|
133
|
+
row: rows.length - 1,
|
|
134
|
+
column: used,
|
|
135
|
+
offset: current.length,
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
rows,
|
|
139
|
+
cursorRow: at.row,
|
|
140
|
+
cursorColumn: at.column,
|
|
141
|
+
cursorOffset: at.offset,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Clamp to a UTF-16 offset that is also a whole-grapheme boundary. */
|
|
146
|
+
export function clampCursor(value: string, cursor: number): number {
|
|
147
|
+
return graphemeBoundary(value, cursor)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function insertText(
|
|
151
|
+
value: string,
|
|
152
|
+
cursor: number,
|
|
153
|
+
text: string,
|
|
154
|
+
): { value: string; cursor: number } {
|
|
155
|
+
const at = clampCursor(value, cursor)
|
|
156
|
+
const next = `${value.slice(0, at)}${text}${value.slice(at)}`
|
|
157
|
+
const wanted = at + text.length
|
|
158
|
+
// Insertion can combine with a following mark and erase the exact boundary
|
|
159
|
+
// that used to follow the inserted bytes. In that case advance over the
|
|
160
|
+
// newly formed cluster rather than jumping the caret back before it.
|
|
161
|
+
const combined = graphemes(next).find((cluster) => cluster.start < wanted && wanted < cluster.end)
|
|
162
|
+
return { value: next, cursor: combined?.end ?? graphemeBoundary(next, wanted) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function deleteBefore(value: string, cursor: number): { value: string; cursor: number } {
|
|
166
|
+
const at = clampCursor(value, cursor)
|
|
167
|
+
if (at === 0) return { value, cursor: 0 }
|
|
168
|
+
const previous = graphemes(value).findLast((cluster) => cluster.end <= at)
|
|
169
|
+
if (previous === undefined) return { value, cursor: 0 }
|
|
170
|
+
return {
|
|
171
|
+
value: `${value.slice(0, previous.start)}${value.slice(at)}`,
|
|
172
|
+
cursor: previous.start,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type ComposerMotion = 'left' | 'right' | 'up' | 'down' | 'home' | 'end'
|
|
177
|
+
|
|
178
|
+
interface LogicalLine {
|
|
179
|
+
start: number
|
|
180
|
+
end: number
|
|
181
|
+
/** UTF-16 boundaries by grapheme column, including both line ends. */
|
|
182
|
+
boundaries: number[]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function logicalLines(value: string): LogicalLine[] {
|
|
186
|
+
const lines: LogicalLine[] = []
|
|
187
|
+
let start = 0
|
|
188
|
+
let boundaries = [0]
|
|
189
|
+
for (const cluster of graphemes(value)) {
|
|
190
|
+
if (cluster.text === '\n') {
|
|
191
|
+
lines.push({ start, end: cluster.start, boundaries })
|
|
192
|
+
start = cluster.end
|
|
193
|
+
boundaries = [start]
|
|
194
|
+
} else {
|
|
195
|
+
boundaries.push(cluster.end)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push({ start, end: value.length, boundaries })
|
|
199
|
+
return lines
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function moveCursor(value: string, cursor: number, motion: ComposerMotion): number {
|
|
203
|
+
const clusters = graphemes(value)
|
|
204
|
+
const at = clampCursor(value, cursor)
|
|
205
|
+
const lines = logicalLines(value)
|
|
206
|
+
const lineIndex = lines.findLastIndex((line) => line.start <= at)
|
|
207
|
+
const line = lines[lineIndex]!
|
|
208
|
+
const column = Math.max(0, line.boundaries.indexOf(at))
|
|
209
|
+
|
|
210
|
+
switch (motion) {
|
|
211
|
+
case 'left':
|
|
212
|
+
return clusters.findLast((cluster) => cluster.end <= at)?.start ?? 0
|
|
213
|
+
case 'right':
|
|
214
|
+
return clusters.find((cluster) => cluster.start >= at)?.end ?? value.length
|
|
215
|
+
case 'home':
|
|
216
|
+
return line.start
|
|
217
|
+
case 'end':
|
|
218
|
+
return line.end
|
|
219
|
+
case 'up': {
|
|
220
|
+
if (lineIndex === 0) return 0
|
|
221
|
+
const target = lines[lineIndex - 1]!
|
|
222
|
+
return target.boundaries[Math.min(column, target.boundaries.length - 1)]!
|
|
223
|
+
}
|
|
224
|
+
case 'down': {
|
|
225
|
+
if (lineIndex === lines.length - 1) return value.length
|
|
226
|
+
const target = lines[lineIndex + 1]!
|
|
227
|
+
return target.boundaries[Math.min(column, target.boundaries.length - 1)]!
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { AbEvent } from '../../events/catalog'
|
|
2
|
+
import type { StreamRecord } from '../../store/types'
|
|
3
|
+
|
|
4
|
+
export interface SessionUsage {
|
|
5
|
+
inputTokens: number
|
|
6
|
+
outputTokens: number
|
|
7
|
+
turns: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DashboardSession {
|
|
11
|
+
id: string
|
|
12
|
+
role: string
|
|
13
|
+
phase: string
|
|
14
|
+
round?: number
|
|
15
|
+
runtime: string
|
|
16
|
+
model?: string
|
|
17
|
+
startedSeq: number
|
|
18
|
+
status: 'open' | 'ended' | 'reclaimed'
|
|
19
|
+
usage?: SessionUsage
|
|
20
|
+
transcript?: { kind: string; rev: number }
|
|
21
|
+
reclaimedBy?: { instance: string; resumedFromSeq: number }
|
|
22
|
+
/** Live-view stream id (SPEC §9); absent on sessions that never streamed. */
|
|
23
|
+
stream?: string
|
|
24
|
+
/** Open until the session's `session.ended` lands; the store's records
|
|
25
|
+
* refine this to the stream's authoritative status when available. */
|
|
26
|
+
streamStatus: 'open' | 'closed'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Display-only chronological session history. The lifecycle reducer retains
|
|
31
|
+
* only open sessions; the dashboard needs both halves, so it pairs the raw
|
|
32
|
+
* append-only facts without introducing transition state. `streams` is the
|
|
33
|
+
* optional authoritative enrichment from `listStreams` on the build scope:
|
|
34
|
+
* records whose label matches `session:<id>` supply each session's stream id
|
|
35
|
+
* and open/closed status.
|
|
36
|
+
*/
|
|
37
|
+
export function projectSessions(
|
|
38
|
+
events: readonly AbEvent[],
|
|
39
|
+
streams?: readonly StreamRecord[],
|
|
40
|
+
): DashboardSession[] {
|
|
41
|
+
const streamsByLabel = new Map<string, StreamRecord>()
|
|
42
|
+
if (streams !== undefined) {
|
|
43
|
+
for (const record of streams) {
|
|
44
|
+
if (record.scope.kind === 'build') streamsByLabel.set(record.label, record)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sessions: DashboardSession[] = []
|
|
49
|
+
const open = new Map<string, DashboardSession>()
|
|
50
|
+
|
|
51
|
+
for (const event of events) {
|
|
52
|
+
if (event.type === 'session.started') {
|
|
53
|
+
const record = streamsByLabel.get(`session:${event.payload.session}`)
|
|
54
|
+
const session: DashboardSession = {
|
|
55
|
+
id: event.payload.session,
|
|
56
|
+
role: event.payload.role,
|
|
57
|
+
phase: event.payload.phase,
|
|
58
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
59
|
+
runtime: event.payload.runner,
|
|
60
|
+
...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
|
|
61
|
+
startedSeq: event.seq,
|
|
62
|
+
status: 'open',
|
|
63
|
+
// The store's records are authoritative when available; without them
|
|
64
|
+
// the pairing degrades gracefully (open until `session.ended`).
|
|
65
|
+
...(record !== undefined
|
|
66
|
+
? { stream: record.id, streamStatus: record.status }
|
|
67
|
+
: {
|
|
68
|
+
...(event.payload.stream !== undefined ? { stream: event.payload.stream } : {}),
|
|
69
|
+
streamStatus: event.payload.stream !== undefined ? 'open' : 'closed',
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
sessions.push(session)
|
|
73
|
+
open.set(session.id, session)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (event.type !== 'session.ended') continue
|
|
77
|
+
const started = open.get(event.payload.session)
|
|
78
|
+
if (started === undefined) continue
|
|
79
|
+
if ('transcript' in event.payload) {
|
|
80
|
+
started.status = 'ended'
|
|
81
|
+
started.usage = { ...event.payload.usage }
|
|
82
|
+
started.transcript = { ...event.payload.transcript }
|
|
83
|
+
} else {
|
|
84
|
+
started.status = 'reclaimed'
|
|
85
|
+
started.reclaimedBy = { ...event.payload.reclaimedBy }
|
|
86
|
+
}
|
|
87
|
+
// A session bracket has ended: without an authoritative record its
|
|
88
|
+
// stream reads closed.
|
|
89
|
+
const record = streamsByLabel.get(`session:${started.id}`)
|
|
90
|
+
if (record !== undefined) {
|
|
91
|
+
started.stream = record.id
|
|
92
|
+
started.streamStatus = record.status
|
|
93
|
+
} else {
|
|
94
|
+
started.streamStatus = 'closed'
|
|
95
|
+
}
|
|
96
|
+
open.delete(started.id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return sessions
|
|
100
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
5
|
+
import { distributionPath } from '../../distribution'
|
|
6
|
+
import { graphemes } from './cells'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Deterministic dashboard-frame rendering.
|
|
10
|
+
*
|
|
11
|
+
* The dashboard deliberately emits a tiny terminal vocabulary: SGR reset,
|
|
12
|
+
* bold/dim and six named foreground colours, plus OSC 8 hyperlinks. This
|
|
13
|
+
* adapter accepts exactly that vocabulary. Unknown control traffic is an
|
|
14
|
+
* error rather than evidence that merely looks plausible after bytes were
|
|
15
|
+
* dropped.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ESC = '\x1b'
|
|
19
|
+
const BEL = '\x07'
|
|
20
|
+
const FONT_FAMILY = 'DejaVu Sans Mono'
|
|
21
|
+
const FONT_FAMILIES = [
|
|
22
|
+
FONT_FAMILY,
|
|
23
|
+
'Dashboard CJK 113',
|
|
24
|
+
'Dashboard CJK 117',
|
|
25
|
+
'Dashboard CJK 118',
|
|
26
|
+
'Dashboard Emoji Flags',
|
|
27
|
+
'Dashboard Emoji Food',
|
|
28
|
+
'Dashboard Emoji People',
|
|
29
|
+
].join(', ')
|
|
30
|
+
const FONT_SIZE = 16
|
|
31
|
+
const CELL_WIDTH = 10
|
|
32
|
+
const LINE_HEIGHT = 20
|
|
33
|
+
const PADDING_X = 12
|
|
34
|
+
const PADDING_Y = 10
|
|
35
|
+
const BASELINE = 16
|
|
36
|
+
const EMOJI_CLUSTER = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u
|
|
37
|
+
|
|
38
|
+
const PALETTE = {
|
|
39
|
+
background: '#0d1117',
|
|
40
|
+
foreground: '#c9d1d9',
|
|
41
|
+
red: '#ff7b72',
|
|
42
|
+
green: '#3fb950',
|
|
43
|
+
yellow: '#d29922',
|
|
44
|
+
blue: '#58a6ff',
|
|
45
|
+
cyan: '#39c5cf',
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
type Foreground = Exclude<keyof typeof PALETTE, 'background'>
|
|
49
|
+
|
|
50
|
+
interface Style {
|
|
51
|
+
foreground: Foreground
|
|
52
|
+
bold: boolean
|
|
53
|
+
dim: boolean
|
|
54
|
+
href?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface TextRun {
|
|
58
|
+
column: number
|
|
59
|
+
cells: number
|
|
60
|
+
text: string
|
|
61
|
+
style: Style
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ParsedLine {
|
|
65
|
+
text: string
|
|
66
|
+
cells: number
|
|
67
|
+
runs: TextRun[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FrameImageOptions {
|
|
71
|
+
/** Declared terminal width. Every parsed line must fit this grid. */
|
|
72
|
+
columns: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RenderedFrameImage {
|
|
76
|
+
/** ANSI/OSC-free text from the exact parsed cells, with a final newline. */
|
|
77
|
+
text: string
|
|
78
|
+
svg: string
|
|
79
|
+
png: Uint8Array
|
|
80
|
+
width: number
|
|
81
|
+
height: number
|
|
82
|
+
rows: number
|
|
83
|
+
columns: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cloneStyle(style: Style): Style {
|
|
87
|
+
return {
|
|
88
|
+
foreground: style.foreground,
|
|
89
|
+
bold: style.bold,
|
|
90
|
+
dim: style.dim,
|
|
91
|
+
...(style.href !== undefined ? { href: style.href } : {}),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sameStyle(left: Style, right: Style): boolean {
|
|
96
|
+
return (
|
|
97
|
+
left.foreground === right.foreground &&
|
|
98
|
+
left.bold === right.bold &&
|
|
99
|
+
left.dim === right.dim &&
|
|
100
|
+
left.href === right.href
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applySgr(style: Style, raw: string, line: number): void {
|
|
105
|
+
const codes = raw === '' ? [0] : raw.split(';').map((part) => Number(part))
|
|
106
|
+
if (codes.some((code) => !Number.isInteger(code))) {
|
|
107
|
+
throw new Error(`dashboard frame line ${line}: malformed SGR sequence ESC[${raw}m`)
|
|
108
|
+
}
|
|
109
|
+
for (const code of codes) {
|
|
110
|
+
switch (code) {
|
|
111
|
+
case 0:
|
|
112
|
+
style.foreground = 'foreground'
|
|
113
|
+
style.bold = false
|
|
114
|
+
style.dim = false
|
|
115
|
+
break
|
|
116
|
+
case 1:
|
|
117
|
+
style.bold = true
|
|
118
|
+
break
|
|
119
|
+
case 2:
|
|
120
|
+
style.dim = true
|
|
121
|
+
break
|
|
122
|
+
case 22:
|
|
123
|
+
style.bold = false
|
|
124
|
+
style.dim = false
|
|
125
|
+
break
|
|
126
|
+
case 31:
|
|
127
|
+
style.foreground = 'red'
|
|
128
|
+
break
|
|
129
|
+
case 32:
|
|
130
|
+
style.foreground = 'green'
|
|
131
|
+
break
|
|
132
|
+
case 33:
|
|
133
|
+
style.foreground = 'yellow'
|
|
134
|
+
break
|
|
135
|
+
case 34:
|
|
136
|
+
style.foreground = 'blue'
|
|
137
|
+
break
|
|
138
|
+
case 36:
|
|
139
|
+
style.foreground = 'cyan'
|
|
140
|
+
break
|
|
141
|
+
case 39:
|
|
142
|
+
style.foreground = 'foreground'
|
|
143
|
+
break
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`dashboard frame line ${line}: unsupported SGR code ${code} in ESC[${raw}m`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseLine(value: string, lineNumber: number): ParsedLine {
|
|
151
|
+
const style: Style = {
|
|
152
|
+
foreground: 'foreground',
|
|
153
|
+
bold: false,
|
|
154
|
+
dim: false,
|
|
155
|
+
}
|
|
156
|
+
const runs: TextRun[] = []
|
|
157
|
+
let text = ''
|
|
158
|
+
let cells = 0
|
|
159
|
+
|
|
160
|
+
const append = (cluster: string, width: number): void => {
|
|
161
|
+
const previous = runs.at(-1)
|
|
162
|
+
// ASCII runs may coalesce. Unicode clusters stay independently pinned to
|
|
163
|
+
// their terminal cell columns so font fallback/advance cannot shift the
|
|
164
|
+
// run that follows a wide glyph.
|
|
165
|
+
if (
|
|
166
|
+
previous !== undefined &&
|
|
167
|
+
previous.column + previous.cells === cells &&
|
|
168
|
+
/^[\x20-\x7e]*$/.test(previous.text) &&
|
|
169
|
+
/^[\x20-\x7e]$/.test(cluster) &&
|
|
170
|
+
sameStyle(previous.style, style)
|
|
171
|
+
) {
|
|
172
|
+
previous.text += cluster
|
|
173
|
+
previous.cells += width
|
|
174
|
+
} else {
|
|
175
|
+
runs.push({ column: cells, cells: width, text: cluster, style: cloneStyle(style) })
|
|
176
|
+
}
|
|
177
|
+
text += cluster
|
|
178
|
+
cells += width
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (let index = 0; index < value.length; ) {
|
|
182
|
+
if (value[index] === ESC) {
|
|
183
|
+
const family = value[index + 1]
|
|
184
|
+
if (family === '[') {
|
|
185
|
+
const rest = value.slice(index + 2)
|
|
186
|
+
const match = /^([0-9;]*)m/.exec(rest)
|
|
187
|
+
if (match === null) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`dashboard frame line ${lineNumber}: unsupported or unterminated CSI sequence`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
applySgr(style, match[1]!, lineNumber)
|
|
193
|
+
index += 2 + match[0].length
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (family === ']') {
|
|
197
|
+
const end = value.indexOf(BEL, index + 2)
|
|
198
|
+
if (end === -1) {
|
|
199
|
+
throw new Error(`dashboard frame line ${lineNumber}: unterminated OSC sequence`)
|
|
200
|
+
}
|
|
201
|
+
const payload = value.slice(index + 2, end)
|
|
202
|
+
const match = /^8;;(.*)$/.exec(payload)
|
|
203
|
+
if (match === null) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`dashboard frame line ${lineNumber}: unsupported OSC sequence ${JSON.stringify(payload)}`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
const href = match[1]!
|
|
209
|
+
style.href = href === '' ? undefined : href
|
|
210
|
+
index = end + 1
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
throw new Error(
|
|
214
|
+
`dashboard frame line ${lineNumber}: unsupported escape family ${JSON.stringify(family ?? '')}`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const nextEscape = value.indexOf(ESC, index)
|
|
219
|
+
const stop = nextEscape === -1 ? value.length : nextEscape
|
|
220
|
+
const plain = value.slice(index, stop)
|
|
221
|
+
for (const character of plain) {
|
|
222
|
+
const code = character.codePointAt(0)!
|
|
223
|
+
if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`dashboard frame line ${lineNumber}: unsupported control U+${code
|
|
226
|
+
.toString(16)
|
|
227
|
+
.toUpperCase()
|
|
228
|
+
.padStart(4, '0')}`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const cluster of graphemes(plain)) append(cluster.text, cluster.width)
|
|
233
|
+
index = stop
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (style.href !== undefined) {
|
|
237
|
+
throw new Error(`dashboard frame line ${lineNumber}: OSC 8 hyperlink was not closed`)
|
|
238
|
+
}
|
|
239
|
+
if (style.foreground !== 'foreground' || style.bold || style.dim) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`dashboard frame line ${lineNumber}: SGR style was not reset before end of line`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return { text, cells, runs }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function xml(value: string): string {
|
|
248
|
+
return value
|
|
249
|
+
.replaceAll('&', '&')
|
|
250
|
+
.replaceAll('<', '<')
|
|
251
|
+
.replaceAll('>', '>')
|
|
252
|
+
.replaceAll('"', '"')
|
|
253
|
+
.replaceAll("'", ''')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function svgFor(
|
|
257
|
+
parsed: ParsedLine[],
|
|
258
|
+
columns: number,
|
|
259
|
+
): {
|
|
260
|
+
svg: string
|
|
261
|
+
width: number
|
|
262
|
+
height: number
|
|
263
|
+
} {
|
|
264
|
+
const width = PADDING_X * 2 + columns * CELL_WIDTH
|
|
265
|
+
const height = PADDING_Y * 2 + Math.max(1, parsed.length) * LINE_HEIGHT
|
|
266
|
+
const content: string[] = []
|
|
267
|
+
|
|
268
|
+
for (const [row, line] of parsed.entries()) {
|
|
269
|
+
for (const run of line.runs) {
|
|
270
|
+
const x = PADDING_X + run.column * CELL_WIDTH
|
|
271
|
+
const y = PADDING_Y + BASELINE + row * LINE_HEIGHT
|
|
272
|
+
const opacity = run.style.dim ? ' fill-opacity="0.58"' : ''
|
|
273
|
+
const weight = run.style.bold ? ' font-weight="700"' : ' font-weight="400"'
|
|
274
|
+
// DejaVu's native advance is slightly narrower than the 10 px evidence
|
|
275
|
+
// grid. Fit coalesced ASCII runs to their exact terminal cells so the
|
|
276
|
+
// error cannot accumulate into a visible gap before a pinned Unicode
|
|
277
|
+
// cluster. Emoji get the same exact fit at a slightly larger size: the
|
|
278
|
+
// monochrome fallback remains readable without crossing its cell range.
|
|
279
|
+
const emoji = EMOJI_CLUSTER.test(run.text)
|
|
280
|
+
const geometry =
|
|
281
|
+
/^[\x20-\x7e]+$/.test(run.text) || emoji
|
|
282
|
+
? ` textLength="${run.cells * CELL_WIDTH}" lengthAdjust="spacingAndGlyphs"`
|
|
283
|
+
: ''
|
|
284
|
+
const size = emoji ? ' font-size="18"' : ''
|
|
285
|
+
const node =
|
|
286
|
+
`<text x="${x}" y="${y}" fill="${PALETTE[run.style.foreground]}"` +
|
|
287
|
+
`${weight}${opacity}${geometry}${size}>${xml(run.text)}</text>`
|
|
288
|
+
content.push(
|
|
289
|
+
run.style.href === undefined ? node : `<a href="${xml(run.style.href)}">${node}</a>`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
width,
|
|
296
|
+
height,
|
|
297
|
+
svg: [
|
|
298
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
|
|
299
|
+
`<rect width="${width}" height="${height}" fill="${PALETTE.background}"/>`,
|
|
300
|
+
`<g font-family="${FONT_FAMILIES}" font-size="${FONT_SIZE}" xml:space="preserve" text-rendering="geometricPrecision">`,
|
|
301
|
+
...content,
|
|
302
|
+
'</g>',
|
|
303
|
+
'</svg>',
|
|
304
|
+
].join(''),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fontFiles(): string[] {
|
|
309
|
+
const require = createRequire(import.meta.url)
|
|
310
|
+
const root = dirname(require.resolve('dejavu-fonts-ttf/package.json'))
|
|
311
|
+
const fallbacks = distributionPath('tools', 'fonts')
|
|
312
|
+
return [
|
|
313
|
+
join(root, 'ttf', 'DejaVuSansMono.ttf'),
|
|
314
|
+
join(root, 'ttf', 'DejaVuSansMono-Bold.ttf'),
|
|
315
|
+
...readdirSync(fallbacks)
|
|
316
|
+
.filter((name) => name.endsWith('.ttf'))
|
|
317
|
+
.sort()
|
|
318
|
+
.map((name) => join(fallbacks, name)),
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Parse once, then derive both evidence forms from the same exact cells. */
|
|
323
|
+
export function renderDashboardFrameImage(
|
|
324
|
+
lines: readonly string[],
|
|
325
|
+
options: FrameImageOptions,
|
|
326
|
+
): RenderedFrameImage {
|
|
327
|
+
if (!Number.isInteger(options.columns) || options.columns <= 0) {
|
|
328
|
+
throw new Error(`dashboard frame columns must be a positive integer, got ${options.columns}`)
|
|
329
|
+
}
|
|
330
|
+
if (lines.length === 0) {
|
|
331
|
+
throw new Error('dashboard frame is empty')
|
|
332
|
+
}
|
|
333
|
+
const parsed = lines.map((line, index) => parseLine(line, index + 1))
|
|
334
|
+
for (const [index, line] of parsed.entries()) {
|
|
335
|
+
if (line.cells > options.columns) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`dashboard frame line ${index + 1} is ${line.cells} cells wide, exceeding declared terminal width ${options.columns}`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { svg, width, height } = svgFor(parsed, options.columns)
|
|
343
|
+
const rendered = new Resvg(svg, {
|
|
344
|
+
fitTo: { mode: 'original' },
|
|
345
|
+
background: PALETTE.background,
|
|
346
|
+
font: {
|
|
347
|
+
fontFiles: fontFiles(),
|
|
348
|
+
loadSystemFonts: false,
|
|
349
|
+
defaultFontFamily: FONT_FAMILY,
|
|
350
|
+
monospaceFamily: FONT_FAMILY,
|
|
351
|
+
defaultFontSize: FONT_SIZE,
|
|
352
|
+
},
|
|
353
|
+
shapeRendering: 2,
|
|
354
|
+
textRendering: 2,
|
|
355
|
+
imageRendering: 0,
|
|
356
|
+
logLevel: 'off',
|
|
357
|
+
}).render()
|
|
358
|
+
if (rendered.width !== width || rendered.height !== height) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`dashboard PNG dimensions ${rendered.width}x${rendered.height} did not match SVG ${width}x${height}`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
text: `${parsed.map((line) => line.text).join('\n')}\n`,
|
|
366
|
+
svg,
|
|
367
|
+
png: new Uint8Array(rendered.asPng()),
|
|
368
|
+
width,
|
|
369
|
+
height,
|
|
370
|
+
rows: lines.length,
|
|
371
|
+
columns: options.columns,
|
|
372
|
+
}
|
|
373
|
+
}
|