@sebastianandreasson/pi-autonomous-agents 0.7.1 → 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/README.md +3 -0
- package/docs/PI_SUPERVISOR.md +1 -1
- package/package.json +1 -1
- package/src/pi-client.mjs +15 -1
- package/src/pi-sdk-turn.mjs +57 -0
- package/src/pi-supervisor.mjs +4 -0
- package/src/pi-telemetry.mjs +4 -0
- package/src/pi-visualizer-server.mjs +124 -1
package/README.md
CHANGED
|
@@ -277,6 +277,9 @@ Visualizer now includes:
|
|
|
277
277
|
- per-iteration stage graph with retries/rechecks
|
|
278
278
|
- clickable graph nodes and timeline rows that show full event JSON
|
|
279
279
|
- historical run summaries and per-run last output snapshots
|
|
280
|
+
- live worker feed with thinking text, assistant text, tool calls, and tool output
|
|
281
|
+
- feed controls to hide thinking and collapse repetitive deltas
|
|
282
|
+
- pinned latest tool output panel
|
|
280
283
|
|
|
281
284
|
## Visual Review Contract
|
|
282
285
|
|
package/docs/PI_SUPERVISOR.md
CHANGED
|
@@ -57,7 +57,7 @@ The package reads `PI_CONFIG_FILE` if provided. Otherwise it falls back to the b
|
|
|
57
57
|
|
|
58
58
|
`pi-harness visualize` remains available as standalone viewer.
|
|
59
59
|
|
|
60
|
-
Visualizer reads active-run lock, per-run state, per-run iteration summary, per-run last output snapshot, and telemetry to show current stage plus historical runs.
|
|
60
|
+
Visualizer reads active-run lock, per-run state, per-run iteration summary, per-run last output snapshot, live feed JSONL, and telemetry to show current stage plus historical runs.
|
|
61
61
|
|
|
62
62
|
## Config Contract
|
|
63
63
|
|
package/package.json
CHANGED
package/src/pi-client.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
1
3
|
import { randomUUID } from 'node:crypto'
|
|
2
4
|
import {
|
|
3
5
|
appendLog,
|
|
@@ -37,6 +39,14 @@ async function writeAgentOutputSnapshot(config, content) {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
async function appendLiveFeedEvent(config, event) {
|
|
43
|
+
if (!config.runLiveFeedFile) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
|
|
47
|
+
await fs.appendFile(config.runLiveFeedFile, `${JSON.stringify(event)}\n`, 'utf8')
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
|
|
41
51
|
const nextSessionId = sessionId || `mock-${randomUUID()}`
|
|
42
52
|
const nextSessionFile = sessionFile || `${config.piRuntimeDir}/mock-${nextSessionId}.jsonl`
|
|
@@ -78,7 +88,7 @@ async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
|
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason }) {
|
|
91
|
+
async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason, phase, role, kind }) {
|
|
82
92
|
await appendLog(
|
|
83
93
|
config.logFile,
|
|
84
94
|
`Starting SDK turn iteration=${iteration} retry=${retryCount} reason=${reason}`
|
|
@@ -118,6 +128,10 @@ async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prom
|
|
|
118
128
|
retryCount,
|
|
119
129
|
reason,
|
|
120
130
|
},
|
|
131
|
+
phase,
|
|
132
|
+
role,
|
|
133
|
+
kind,
|
|
134
|
+
onLiveEvent: (event) => appendLiveFeedEvent(config, event),
|
|
121
135
|
})
|
|
122
136
|
} catch (error) {
|
|
123
137
|
const notes = error instanceof Error ? error.message : String(error)
|
package/src/pi-sdk-turn.mjs
CHANGED
|
@@ -300,6 +300,22 @@ export async function createSdkSession(pi, request) {
|
|
|
300
300
|
})
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
function emitLiveFeed(request, event) {
|
|
304
|
+
if (typeof request?.onLiveEvent !== 'function') {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
request.onLiveEvent({
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
iteration: Number(request?.metadata?.iteration ?? 0),
|
|
310
|
+
retryCount: Number(request?.metadata?.retryCount ?? 0),
|
|
311
|
+
reason: String(request?.metadata?.reason ?? request?.reason ?? ''),
|
|
312
|
+
phase: String(request?.phase ?? ''),
|
|
313
|
+
role: String(request?.role ?? ''),
|
|
314
|
+
kind: String(request?.kind ?? ''),
|
|
315
|
+
...event,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
303
319
|
async function safeAbort(session) {
|
|
304
320
|
try {
|
|
305
321
|
await session.abort()
|
|
@@ -430,10 +446,25 @@ export async function runSdkTurnWithPi(pi, request) {
|
|
|
430
446
|
|
|
431
447
|
if (event.type === 'agent_start') {
|
|
432
448
|
agentStarted = true
|
|
449
|
+
emitLiveFeed(request, {
|
|
450
|
+
type: 'agent_start',
|
|
451
|
+
text: 'agent started',
|
|
452
|
+
})
|
|
433
453
|
writeLive('[PI] agent started\n')
|
|
434
454
|
}
|
|
435
455
|
|
|
456
|
+
if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'thinking_delta') {
|
|
457
|
+
emitLiveFeed(request, {
|
|
458
|
+
type: 'thinking_delta',
|
|
459
|
+
text: event.assistantMessageEvent.delta,
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
436
463
|
if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
|
|
464
|
+
emitLiveFeed(request, {
|
|
465
|
+
type: 'text_delta',
|
|
466
|
+
text: event.assistantMessageEvent.delta,
|
|
467
|
+
})
|
|
437
468
|
ensureAssistantLine()
|
|
438
469
|
writeLive(event.assistantMessageEvent.delta)
|
|
439
470
|
streamedAssistantText = true
|
|
@@ -488,21 +519,47 @@ export async function runSdkTurnWithPi(pi, request) {
|
|
|
488
519
|
requestAbortForLoop()
|
|
489
520
|
}
|
|
490
521
|
|
|
522
|
+
emitLiveFeed(request, {
|
|
523
|
+
type: 'tool_start',
|
|
524
|
+
toolName: String(event.toolName ?? ''),
|
|
525
|
+
args: event.args,
|
|
526
|
+
text: `${String(event.toolName ?? '')}${suffix}`.trim(),
|
|
527
|
+
})
|
|
491
528
|
writeLive(`[PI tool:start] ${event.toolName}${suffix}\n`)
|
|
492
529
|
if (event.toolName === 'bash' && isLargeShellRead(shellCommand)) {
|
|
493
530
|
writeLive('[PI warning] large bash file read detected; prefer read or a smaller exact window to avoid truncated context.\n')
|
|
494
531
|
}
|
|
495
532
|
}
|
|
496
533
|
|
|
534
|
+
if (event.type === 'tool_execution_update') {
|
|
535
|
+
emitLiveFeed(request, {
|
|
536
|
+
type: 'tool_update',
|
|
537
|
+
toolName: String(event.toolName ?? ''),
|
|
538
|
+
partialResult: event.partialResult,
|
|
539
|
+
text: formatValue(event.partialResult),
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
497
543
|
if (event.type === 'tool_execution_end') {
|
|
498
544
|
closeAssistantLine()
|
|
499
545
|
activeToolName = ''
|
|
500
546
|
activeToolStartedAt = 0
|
|
547
|
+
emitLiveFeed(request, {
|
|
548
|
+
type: 'tool_end',
|
|
549
|
+
toolName: String(event.toolName ?? ''),
|
|
550
|
+
isError: event.isError === true,
|
|
551
|
+
result: event.result,
|
|
552
|
+
text: `${String(event.toolName ?? '')} ${event.isError ? 'error' : 'ok'}`,
|
|
553
|
+
})
|
|
501
554
|
writeLive(`[PI tool:end] ${event.toolName} ${event.isError ? 'error' : 'ok'}\n`)
|
|
502
555
|
}
|
|
503
556
|
|
|
504
557
|
if (event.type === 'agent_end') {
|
|
505
558
|
agentEnded = true
|
|
559
|
+
emitLiveFeed(request, {
|
|
560
|
+
type: 'agent_end',
|
|
561
|
+
text: 'agent finished',
|
|
562
|
+
})
|
|
506
563
|
closeAssistantLine()
|
|
507
564
|
writeLive('[PI] agent finished\n')
|
|
508
565
|
}
|
package/src/pi-supervisor.mjs
CHANGED
|
@@ -368,6 +368,9 @@ async function runAgentInvocation({
|
|
|
368
368
|
iteration,
|
|
369
369
|
retryCount,
|
|
370
370
|
reason,
|
|
371
|
+
phase,
|
|
372
|
+
role,
|
|
373
|
+
kind,
|
|
371
374
|
})
|
|
372
375
|
const afterSnapshot = getRepoSnapshot(config.cwd)
|
|
373
376
|
const changedFiles = listChangedFiles(config.cwd)
|
|
@@ -1734,6 +1737,7 @@ async function main() {
|
|
|
1734
1737
|
config.runStateFile = path.join(runDir, 'state.json')
|
|
1735
1738
|
config.runLastIterationSummaryFile = path.join(runDir, 'last-iteration.json')
|
|
1736
1739
|
config.runLastAgentOutputFile = path.join(runDir, 'last-output.txt')
|
|
1740
|
+
config.runLiveFeedFile = path.join(runDir, 'live-feed.jsonl')
|
|
1737
1741
|
|
|
1738
1742
|
ensureRepo(config.cwd)
|
|
1739
1743
|
await ensureFileExists(config.taskFile, 'task file')
|
package/src/pi-telemetry.mjs
CHANGED
|
@@ -14,6 +14,10 @@ export async function ensureTelemetryFiles(config) {
|
|
|
14
14
|
await fs.mkdir(path.dirname(config.runLastAgentOutputFile), { recursive: true })
|
|
15
15
|
await fs.writeFile(config.runLastAgentOutputFile, '', 'utf8')
|
|
16
16
|
}
|
|
17
|
+
if (config.runLiveFeedFile) {
|
|
18
|
+
await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
|
|
19
|
+
await fs.writeFile(config.runLiveFeedFile, '', 'utf8')
|
|
20
|
+
}
|
|
17
21
|
await fs.writeFile(config.lastVerificationOutputFile, '', 'utf8')
|
|
18
22
|
await fs.writeFile(config.changedFilesFile, '', 'utf8')
|
|
19
23
|
await fs.writeFile(config.lastPromptFile, '', 'utf8')
|
|
@@ -28,6 +28,20 @@ async function readOptionalText(filePath, maxLength = 6000) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
async function readJsonlTail(filePath, maxItems = 200) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fs.readFile(filePath, 'utf8')
|
|
34
|
+
return raw
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.slice(-maxItems)
|
|
39
|
+
.map((line) => JSON.parse(line))
|
|
40
|
+
} catch {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
function getRunDir(config, runId) {
|
|
32
46
|
return path.join(config.piRuntimeDir, 'runs', runId)
|
|
33
47
|
}
|
|
@@ -42,6 +56,7 @@ function getRunScopedConfig(config, runId) {
|
|
|
42
56
|
stateFile: path.join(runDir, 'state.json'),
|
|
43
57
|
lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
|
|
44
58
|
lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
|
|
59
|
+
liveFeedFile: path.join(runDir, 'live-feed.jsonl'),
|
|
45
60
|
logFile: path.join(runDir, 'pi.log'),
|
|
46
61
|
}
|
|
47
62
|
}
|
|
@@ -101,11 +116,12 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
101
116
|
const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
|
|
102
117
|
const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
|
|
103
118
|
|
|
104
|
-
const [state, summary, telemetry, currentOutput] = await Promise.all([
|
|
119
|
+
const [state, summary, telemetry, currentOutput, liveFeed] = await Promise.all([
|
|
105
120
|
readJsonFile(selectedConfig.stateFile, null),
|
|
106
121
|
readJsonFile(selectedConfig.lastIterationSummaryFile, null),
|
|
107
122
|
readTelemetry(selectedConfig),
|
|
108
123
|
readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
|
|
124
|
+
readJsonlTail(selectedConfig.liveFeedFile, 300),
|
|
109
125
|
])
|
|
110
126
|
|
|
111
127
|
const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
|
|
@@ -144,6 +160,7 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
144
160
|
},
|
|
145
161
|
graph,
|
|
146
162
|
lastOutput: currentOutput,
|
|
163
|
+
liveFeed,
|
|
147
164
|
recentTelemetry,
|
|
148
165
|
}
|
|
149
166
|
}
|
|
@@ -217,6 +234,24 @@ export function renderHtml() {
|
|
|
217
234
|
.graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
|
|
218
235
|
.graph-arrow { color: var(--muted); text-align: center; align-self: center; }
|
|
219
236
|
.kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
|
|
237
|
+
.feed-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:12px; margin-bottom:10px; }
|
|
238
|
+
.feed-toggle { display:flex; gap:6px; align-items:center; color: var(--muted); font-size: 12px; }
|
|
239
|
+
.feed { background: #0a1325; border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
240
|
+
.feed-item { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
241
|
+
.feed-item:last-child { border-bottom: 0; }
|
|
242
|
+
.feed-head { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
243
|
+
.feed-type { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:2px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
244
|
+
.feed-type.agent_start, .feed-type.agent_end { color: var(--active); }
|
|
245
|
+
.feed-type.thinking_delta { color: #b392f0; }
|
|
246
|
+
.feed-type.text_delta { color: var(--done); }
|
|
247
|
+
.feed-type.tool_start, .feed-type.tool_update, .feed-type.tool_end { color: var(--skip); }
|
|
248
|
+
.feed-meta { color: var(--muted); font-size: 12px; }
|
|
249
|
+
.feed-text { white-space: pre-wrap; word-break: break-word; margin-top: 6px; }
|
|
250
|
+
.feed-count { color: var(--muted); font-size: 11px; }
|
|
251
|
+
.pinned-tool { background:#0a1325; border: 1px solid var(--line); border-radius:12px; padding:12px; }
|
|
252
|
+
.pinned-tool-name { font-weight:700; }
|
|
253
|
+
.pinned-tool-meta { color: var(--muted); font-size:12px; margin-top:4px; }
|
|
254
|
+
.pinned-tool-text { white-space: pre-wrap; word-break: break-word; margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
220
255
|
.kv div:nth-child(odd) { color: var(--muted); }
|
|
221
256
|
pre {
|
|
222
257
|
margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
|
|
@@ -281,6 +316,15 @@ export function renderHtml() {
|
|
|
281
316
|
|
|
282
317
|
<div class="grid">
|
|
283
318
|
<div class="card"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
|
|
319
|
+
<div class="card"><div class="label">Latest tool output</div><div class="pinned-tool" id="pinned-tool">No tool activity yet.</div></div>
|
|
320
|
+
<div class="card">
|
|
321
|
+
<div class="label">Live worker feed</div>
|
|
322
|
+
<div class="feed-toolbar">
|
|
323
|
+
<label class="feed-toggle"><input type="checkbox" id="feed-show-thinking" checked /> <span>Show thinking</span></label>
|
|
324
|
+
<label class="feed-toggle"><input type="checkbox" id="feed-collapse-deltas" checked /> <span>Collapse deltas</span></label>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="feed" id="feed">No live feed yet.</div>
|
|
327
|
+
</div>
|
|
284
328
|
<div class="card"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
|
|
285
329
|
<div class="card"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
|
|
286
330
|
<div class="card"><div class="label">Last agent output</div><pre id="output">—</pre></div>
|
|
@@ -319,6 +363,56 @@ export function renderHtml() {
|
|
|
319
363
|
let selectedEventId = ''
|
|
320
364
|
let eventSource = null
|
|
321
365
|
|
|
366
|
+
function normalizeFeedEntry(entry) {
|
|
367
|
+
return {
|
|
368
|
+
...entry,
|
|
369
|
+
type: String(entry?.type || 'event'),
|
|
370
|
+
text: String(entry?.text || ''),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function collapseFeedEntries(entries) {
|
|
375
|
+
const collapsed = []
|
|
376
|
+
for (const raw of entries) {
|
|
377
|
+
const entry = normalizeFeedEntry(raw)
|
|
378
|
+
const prev = collapsed[collapsed.length - 1]
|
|
379
|
+
const canMerge = prev
|
|
380
|
+
&& (entry.type === 'text_delta' || entry.type === 'thinking_delta')
|
|
381
|
+
&& prev.type === entry.type
|
|
382
|
+
&& prev.role === entry.role
|
|
383
|
+
&& prev.kind === entry.kind
|
|
384
|
+
if (canMerge) {
|
|
385
|
+
prev.text += entry.text
|
|
386
|
+
prev.count = (prev.count || 1) + 1
|
|
387
|
+
prev.timestamp = entry.timestamp
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
collapsed.push({ ...entry, count: 1 })
|
|
391
|
+
}
|
|
392
|
+
return collapsed
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function getVisibleFeedEntries(snapshot) {
|
|
396
|
+
const showThinking = document.getElementById('feed-show-thinking')?.checked !== false
|
|
397
|
+
const collapseDeltas = document.getElementById('feed-collapse-deltas')?.checked !== false
|
|
398
|
+
const source = Array.isArray(snapshot?.liveFeed) ? snapshot.liveFeed : []
|
|
399
|
+
const filtered = source.filter((entry) => showThinking || entry.type !== 'thinking_delta')
|
|
400
|
+
return collapseDeltas ? collapseFeedEntries(filtered) : filtered.map((entry) => ({ ...normalizeFeedEntry(entry), count: 1 }))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function renderPinnedTool(snapshot) {
|
|
404
|
+
const target = document.getElementById('pinned-tool')
|
|
405
|
+
const source = Array.isArray(snapshot?.liveFeed) ? [...snapshot.liveFeed].reverse() : []
|
|
406
|
+
const latest = source.find((entry) => entry.type === 'tool_update' || entry.type === 'tool_end' || entry.type === 'tool_start')
|
|
407
|
+
if (!latest) {
|
|
408
|
+
target.textContent = 'No tool activity yet.'
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
target.innerHTML = '<div class="pinned-tool-name">' + esc(latest.toolName || 'tool') + '</div>' +
|
|
412
|
+
'<div class="pinned-tool-meta">' + esc(latest.type) + ' · ' + esc(new Date(latest.timestamp).toLocaleTimeString()) + '</div>' +
|
|
413
|
+
'<div class="pinned-tool-text">' + esc(latest.text || '') + '</div>'
|
|
414
|
+
}
|
|
415
|
+
|
|
322
416
|
function findEventById(snapshot, eventId) {
|
|
323
417
|
if (!snapshot || !eventId) return null
|
|
324
418
|
return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
|
|
@@ -340,6 +434,17 @@ export function renderHtml() {
|
|
|
340
434
|
renderSelectedEvent()
|
|
341
435
|
})
|
|
342
436
|
})
|
|
437
|
+
;['feed-show-thinking', 'feed-collapse-deltas'].forEach((id) => {
|
|
438
|
+
const input = document.getElementById(id)
|
|
439
|
+
if (input && !input.dataset.bound) {
|
|
440
|
+
input.addEventListener('change', () => {
|
|
441
|
+
if (latestSnapshot) {
|
|
442
|
+
renderSnapshot(latestSnapshot)
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
input.dataset.bound = '1'
|
|
446
|
+
}
|
|
447
|
+
})
|
|
343
448
|
}
|
|
344
449
|
|
|
345
450
|
function renderSnapshot(data) {
|
|
@@ -404,6 +509,24 @@ export function renderHtml() {
|
|
|
404
509
|
document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
|
|
405
510
|
document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
|
|
406
511
|
|
|
512
|
+
renderPinnedTool(data)
|
|
513
|
+
const visibleFeed = getVisibleFeedEntries(data)
|
|
514
|
+
const feedEl = document.getElementById('feed')
|
|
515
|
+
feedEl.innerHTML = visibleFeed.length > 0
|
|
516
|
+
? visibleFeed.map((entry) => {
|
|
517
|
+
const meta = [entry.role, entry.kind, entry.toolName].filter(Boolean).join(' · ')
|
|
518
|
+
return '<div class="feed-item">' +
|
|
519
|
+
'<div class="feed-head">' +
|
|
520
|
+
'<div class="feed-type ' + esc(entry.type) + '">' + esc(entry.type || 'event') + '</div>' +
|
|
521
|
+
(entry.count > 1 ? '<div class="feed-count">x' + esc(entry.count) + '</div>' : '') +
|
|
522
|
+
'</div>' +
|
|
523
|
+
'<div class="feed-meta">' + esc(new Date(entry.timestamp).toLocaleTimeString()) + (meta ? ' · ' + esc(meta) : '') + '</div>' +
|
|
524
|
+
'<div class="feed-text">' + esc(entry.text || '') + '</div>' +
|
|
525
|
+
'</div>'
|
|
526
|
+
}).join('')
|
|
527
|
+
: '<div class="muted">No live feed yet.</div>'
|
|
528
|
+
feedEl.scrollTop = feedEl.scrollHeight
|
|
529
|
+
|
|
407
530
|
const timelineEvents = [...data.recentTelemetry].reverse()
|
|
408
531
|
const timeline = timelineEvents.map((event) => {
|
|
409
532
|
const status = eventStatus(event.status)
|