@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,1568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's other pure half: model → `string[]`. No terminal, no state,
|
|
3
|
+
* no I/O — `live.ts` owns the writer, this owns the pixels.
|
|
4
|
+
*
|
|
5
|
+
* Two constraints shape every choice here:
|
|
6
|
+
*
|
|
7
|
+
* **Never color-only for pipeline and row state.** Color is additive emphasis:
|
|
8
|
+
* every step also carries a glyph (`[x]` `[>]` `[~]` `[ ]`) and every status
|
|
9
|
+
* its literal word (`RUNNING` / `PAUSED` / `BLOCKED`). Auto-merge is the one
|
|
10
|
+
* deliberate presentation exception: token presence carries on/off intent,
|
|
11
|
+
* while color distinguishes requested, enabled, and cancelling. Strip the
|
|
12
|
+
* escapes — as `--plain` and every pipe do — and the actionable intent remains.
|
|
13
|
+
*
|
|
14
|
+
* **Cell-honest Unicode.** Printable text is segmented into whole grapheme
|
|
15
|
+
* clusters and measured in terminal display cells. Control bytes remain
|
|
16
|
+
* visible escapes, so dynamic text cannot inject terminal traffic.
|
|
17
|
+
*
|
|
18
|
+
* The ~40 lines of ANSI are hand-rolled for the same reason: this repo has
|
|
19
|
+
* four runtime deps and none is terminal-related; `chalk`/`ink` would be a
|
|
20
|
+
* posture change to win a color map.
|
|
21
|
+
*/
|
|
22
|
+
import { basename } from 'node:path'
|
|
23
|
+
import type {
|
|
24
|
+
DashboardBuild,
|
|
25
|
+
DashboardHarvest,
|
|
26
|
+
DashboardModel,
|
|
27
|
+
DashboardSelection,
|
|
28
|
+
PipelineStep,
|
|
29
|
+
} from './model'
|
|
30
|
+
import { dashboardBuildControl } from './actions'
|
|
31
|
+
import type { DashboardSession } from './detail'
|
|
32
|
+
import type { TranscriptPresentation, TranscriptTurn } from './transcript'
|
|
33
|
+
import { projectSessionDocument, projectSessionParts } from './session-view'
|
|
34
|
+
import type { SessionDashboardView } from './model'
|
|
35
|
+
import { dashboardSelections, sameSelection } from './selection'
|
|
36
|
+
import { displayText, layoutComposer, packAtomic, wrapDisplay } from './composer'
|
|
37
|
+
import { cellWidth, graphemes, padEndCells } from './cells'
|
|
38
|
+
|
|
39
|
+
export interface RenderOpts {
|
|
40
|
+
/** ANSI on. False ⇒ not a single `\x1b` in the output (the `--plain` AC). */
|
|
41
|
+
color: boolean
|
|
42
|
+
/** Hard cap per line; one rendered line must be one physical row. */
|
|
43
|
+
width: number
|
|
44
|
+
/**
|
|
45
|
+
* The render-time clock (epoch ms). A running step's elapsed is
|
|
46
|
+
* `timing.accumulatedMs + (now - timing.runningSince)`, so painting the same
|
|
47
|
+
* cached model against a moving `now` is what makes the elapsed tick without
|
|
48
|
+
* recomputing the model (AC 8). Steps with no open interval ignore it.
|
|
49
|
+
*/
|
|
50
|
+
now: number
|
|
51
|
+
/**
|
|
52
|
+
* Hard cap on the NUMBER of lines — the screen's paintable rows.
|
|
53
|
+
*
|
|
54
|
+
* The live region clears its alternate display and anchors this frame at
|
|
55
|
+
* terminal row 1 on every effective paint. The cap still matters: every
|
|
56
|
+
* rendered line has a trailing newline, so a frame as tall as the screen
|
|
57
|
+
* scrolls its own top away even on a freshly cleared display.
|
|
58
|
+
*
|
|
59
|
+
* This is a cap on LINES, and it is NOT the screen's row count: the region
|
|
60
|
+
* needs a row for the cursor to rest on, so a caller painting a terminal
|
|
61
|
+
* must pass `paintableRows(rows)`, not `rows`. This module cannot enforce
|
|
62
|
+
* that — it counts lines and knows nothing of the region's trailing newline.
|
|
63
|
+
*
|
|
64
|
+
* Absent ⇒ unbounded, for callers that are not painting a screen.
|
|
65
|
+
*/
|
|
66
|
+
height?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A dashboard presentation implementation. Kept as a named dependency so the
|
|
70
|
+
* repo-local dev entry can replace presentation without replacing the live
|
|
71
|
+
* dispatch loop that owns runners, input, timers, and leases. */
|
|
72
|
+
export type DashboardRenderer = (model: DashboardModel, opts: RenderOpts) => string[]
|
|
73
|
+
|
|
74
|
+
/** Resolve at paint time rather than capturing one renderer at loop startup. */
|
|
75
|
+
export type DashboardRendererResolver = () => DashboardRenderer
|
|
76
|
+
|
|
77
|
+
// ── ANSI ─────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const ESC = '\x1b['
|
|
80
|
+
const RESET = `${ESC}0m`
|
|
81
|
+
/** Closes an OSC 8 hyperlink. `RESET` does NOT: it is an SGR reset, and the
|
|
82
|
+
* two families are independent — only this ends the link. */
|
|
83
|
+
const LINK_OFF = '\x1b]8;;\x07'
|
|
84
|
+
const CODES = {
|
|
85
|
+
dim: 2,
|
|
86
|
+
red: 31,
|
|
87
|
+
green: 32,
|
|
88
|
+
yellow: 33,
|
|
89
|
+
blue: 34,
|
|
90
|
+
cyan: 36,
|
|
91
|
+
bold: 1,
|
|
92
|
+
} as const
|
|
93
|
+
type ColorName = keyof typeof CODES
|
|
94
|
+
|
|
95
|
+
function paint(text: string, color: ColorName, on: boolean): string {
|
|
96
|
+
return on ? `${ESC}${CODES[color]}m${text}${RESET}` : text
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** OSC 8: the escape common terminals turn into a real hyperlink. In plain
|
|
100
|
+
* mode we emit the bare URL, which those same terminals linkify on sight —
|
|
101
|
+
* so "PR URLs are recognized as a link" holds on both paths. */
|
|
102
|
+
function link(url: string, text: string, on: boolean): string {
|
|
103
|
+
if (!/^https?:\/\//.test(url)) return text === url ? url : `${text} ${url}`
|
|
104
|
+
return on ? `\x1b]8;;${url}\x07${text}${LINK_OFF}` : url
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Visible length: what the operator's eye counts, not what `.length` does. */
|
|
108
|
+
function visibleLength(text: string): number {
|
|
109
|
+
return cellWidth(stripAnsi(text))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function stripAnsi(text: string): string {
|
|
113
|
+
// CSI (colors) and OSC 8 (hyperlinks) — the only two families we emit.
|
|
114
|
+
return text.replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b\[[0-9;]*m/g, '')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Truncate to `width` VISIBLE columns without ever splitting an escape, and
|
|
119
|
+
* without leaving any mode it cut through switched ON.
|
|
120
|
+
*
|
|
121
|
+
* Walking the string escape-aware (rather than slicing and hoping) is the
|
|
122
|
+
* whole point: a cut mid-sequence leaks raw escape bytes onto the screen, and
|
|
123
|
+
* a cut that drops a closer leaves the mode running down the rest of the
|
|
124
|
+
* frame. Escapes are copied through and cost zero columns.
|
|
125
|
+
*
|
|
126
|
+
* BOTH families this file emits are stateful, and both must be closed — the
|
|
127
|
+
* SGR colors with `RESET`, and an OSC 8 hyperlink with `LINK_OFF`. `RESET` does
|
|
128
|
+
* not close a hyperlink. Getting this wrong is not cosmetic: `renderBuild` puts
|
|
129
|
+
* the PR link LAST, so it is the first thing truncation eats, and an unclosed
|
|
130
|
+
* OSC 8 makes every line painted afterwards — the progress row, the blockers,
|
|
131
|
+
* every later build — clickable to that one PR. `finish()` deliberately leaves
|
|
132
|
+
* the final frame up, so the state would outlive the process and land on the
|
|
133
|
+
* operator's shell prompt.
|
|
134
|
+
*/
|
|
135
|
+
function truncate(text: string, width: number): string {
|
|
136
|
+
if (width <= 0) return ''
|
|
137
|
+
if (visibleLength(text) <= width) return text
|
|
138
|
+
let out = ''
|
|
139
|
+
let visible = 0
|
|
140
|
+
let sawSgr = false
|
|
141
|
+
let linkOpen = false
|
|
142
|
+
const budget = Math.max(0, width - 1)
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < text.length; ) {
|
|
145
|
+
if (text[i] === '\x1b') {
|
|
146
|
+
const osc = text[i + 1] === ']'
|
|
147
|
+
const end = osc ? text.indexOf('\x07', i) : text.slice(i).search(/[A-Za-z]/) + i
|
|
148
|
+
const stop = end === -1 || end < i ? text.length - 1 : end
|
|
149
|
+
const seq = text.slice(i, stop + 1)
|
|
150
|
+
out += seq
|
|
151
|
+
i = stop + 1
|
|
152
|
+
if (osc) linkOpen = seq !== LINK_OFF
|
|
153
|
+
else sawSgr = true
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const nextEscape = text.indexOf('\x1b', i)
|
|
158
|
+
const stop = nextEscape === -1 ? text.length : nextEscape
|
|
159
|
+
let cut = false
|
|
160
|
+
for (const cluster of graphemes(text.slice(i, stop))) {
|
|
161
|
+
if (cluster.width > budget || visible + cluster.width > budget) {
|
|
162
|
+
cut = true
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
out += cluster.text
|
|
166
|
+
visible += cluster.width
|
|
167
|
+
}
|
|
168
|
+
if (cut) break
|
|
169
|
+
i = stop
|
|
170
|
+
}
|
|
171
|
+
out += '~'
|
|
172
|
+
// Close what we cut through — the link first, so the `~` stays inside it.
|
|
173
|
+
if (linkOpen) out += LINK_OFF
|
|
174
|
+
return sawSgr ? `${out}${RESET}` : out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Greedily pack pre-rendered tokens into `indent`-prefixed lines of at most
|
|
179
|
+
* `width` VISIBLE columns.
|
|
180
|
+
*
|
|
181
|
+
* Why wrap rather than truncate: truncation is mandatory for redraw
|
|
182
|
+
* correctness (one rendered line must be one physical row, or the painted-line
|
|
183
|
+
* count under-counts and the redraw leaves fragments) — but a truncated
|
|
184
|
+
* progress row loses `finalize` and `merge waiting` off the right edge, i.e.
|
|
185
|
+
* exactly the steps the ACs require and the ones the operator is waiting on.
|
|
186
|
+
* Both hold at once because WE do the wrapping: every line we emit is within
|
|
187
|
+
* the width, so the row count stays honest AND nothing is dropped.
|
|
188
|
+
*
|
|
189
|
+
* A single token wider than the line is truncated — it cannot be helped, and
|
|
190
|
+
* it keeps the width guarantee absolute.
|
|
191
|
+
*/
|
|
192
|
+
function packLines(tokens: string[], width: number, indent: string): string[] {
|
|
193
|
+
const lines: string[] = []
|
|
194
|
+
let line = ''
|
|
195
|
+
for (const token of tokens) {
|
|
196
|
+
const candidate = line === '' ? `${indent}${token}` : `${line} ${token}`
|
|
197
|
+
if (line !== '' && visibleLength(candidate) > width) {
|
|
198
|
+
lines.push(line)
|
|
199
|
+
line = `${indent}${token}`
|
|
200
|
+
} else {
|
|
201
|
+
line = candidate
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (line !== '') lines.push(line)
|
|
205
|
+
return lines.map((l) => truncate(l, width))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Steps ────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const GLYPH: Record<PipelineStep['state'], string> = {
|
|
211
|
+
done: '[x]',
|
|
212
|
+
current: '[>]',
|
|
213
|
+
provisional: '[~]',
|
|
214
|
+
pending: '[ ]',
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const STEP_COLOR: Record<PipelineStep['state'], ColorName> = {
|
|
218
|
+
done: 'green',
|
|
219
|
+
current: 'cyan',
|
|
220
|
+
provisional: 'yellow',
|
|
221
|
+
pending: 'dim',
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Elapsed duration, ASCII only: `Xs` under a minute, `MmSSs` under an hour,
|
|
226
|
+
* `HhMMm` above — seconds/minutes zero-padded when a larger unit precedes, so
|
|
227
|
+
* the field width is stable as it ticks (`38s`, `4m12s`, `1h04m`).
|
|
228
|
+
*/
|
|
229
|
+
export function formatDuration(ms: number): string {
|
|
230
|
+
const totalSec = Math.max(0, Math.floor(ms / 1000))
|
|
231
|
+
if (totalSec < 60) return `${totalSec}s`
|
|
232
|
+
const totalMin = Math.floor(totalSec / 60)
|
|
233
|
+
if (totalMin < 60) return `${totalMin}m${String(totalSec % 60).padStart(2, '0')}s`
|
|
234
|
+
return `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')}m`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderStep(step: PipelineStep, color: boolean, now: number): string {
|
|
238
|
+
const parts: string[] = []
|
|
239
|
+
if (step.qualifier !== undefined) parts.push(step.qualifier)
|
|
240
|
+
// The elapsed segment can only be composed here — it depends on the
|
|
241
|
+
// render-time clock. A running step's open interval grows with `now`; a
|
|
242
|
+
// frozen or done step has none, so its elapsed is stable across repaints.
|
|
243
|
+
// `count` rides the time as `/n` (AC 7); with no time in scope it is not
|
|
244
|
+
// shown (a fresh/restarted step should not carry a stale round).
|
|
245
|
+
if (step.timing !== undefined) {
|
|
246
|
+
const { accumulatedMs, runningSince } = step.timing
|
|
247
|
+
const elapsedMs =
|
|
248
|
+
accumulatedMs + (runningSince !== undefined ? Math.max(0, now - runningSince) : 0)
|
|
249
|
+
const count = step.count !== undefined && step.count > 1 ? `/${step.count}` : ''
|
|
250
|
+
parts.push(`${formatDuration(elapsedMs)}${count}`)
|
|
251
|
+
}
|
|
252
|
+
const note = parts.length > 0 ? `(${parts.join(', ')})` : ''
|
|
253
|
+
const text = `${GLYPH[step.state]} ${step.label}${note}`
|
|
254
|
+
const painted = paint(text, STEP_COLOR[step.state], color)
|
|
255
|
+
return step.state === 'current' ? paint(painted, 'bold', color) : painted
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Builds ───────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const STATUS_COLOR: Record<DashboardBuild['status'] | DashboardHarvest['status'], ColorName> = {
|
|
261
|
+
queued: 'cyan',
|
|
262
|
+
running: 'green',
|
|
263
|
+
pausing: 'yellow',
|
|
264
|
+
paused: 'yellow',
|
|
265
|
+
resuming: 'cyan',
|
|
266
|
+
blocked: 'red',
|
|
267
|
+
aborting: 'red',
|
|
268
|
+
cleaning: 'yellow',
|
|
269
|
+
escalated: 'yellow',
|
|
270
|
+
failed: 'red',
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The marker is one shared visual grammar for every selectable row. */
|
|
274
|
+
function selectionMarker(selected: boolean, selecting: boolean, color: boolean): string {
|
|
275
|
+
// The marker owns a fixed two-column lane even when keyboard selection is
|
|
276
|
+
// unavailable. No identity or detail text may drift into those columns.
|
|
277
|
+
return selecting && selected ? paint('> ', 'cyan', color) : ' '
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Pin a right cluster to the frame edge while keeping exactly one flexible,
|
|
281
|
+
* truncatable segment on the left. */
|
|
282
|
+
function rightPinnedLine(prefix: string, flexible: string, right: string, width: number): string {
|
|
283
|
+
const budget = width - visibleLength(prefix) - visibleLength(right) - 2
|
|
284
|
+
const left = truncate(flexible, Math.max(0, budget))
|
|
285
|
+
const used = visibleLength(prefix) + visibleLength(left)
|
|
286
|
+
const gap = ' '.repeat(Math.max(1, width - used - visibleLength(right)))
|
|
287
|
+
return truncate(`${prefix}${left}${gap}${right}`, width)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function reviewRoundCeilingText(build: DashboardBuild): string | undefined {
|
|
291
|
+
if (build.reviewRoundCeilings === undefined) return undefined
|
|
292
|
+
const values = [
|
|
293
|
+
...(build.reviewRoundCeilings.plan !== undefined
|
|
294
|
+
? [`plan ${build.reviewRoundCeilings.plan}`]
|
|
295
|
+
: []),
|
|
296
|
+
...(build.reviewRoundCeilings.code !== undefined
|
|
297
|
+
? [`code ${build.reviewRoundCeilings.code}`]
|
|
298
|
+
: []),
|
|
299
|
+
]
|
|
300
|
+
return values.length === 0 ? undefined : `review round ceiling: ${values.join(', ')}`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function renderBuild(
|
|
304
|
+
build: DashboardBuild,
|
|
305
|
+
repositoryPaused: boolean,
|
|
306
|
+
opts: RenderOpts,
|
|
307
|
+
widths: Widths,
|
|
308
|
+
selected: boolean,
|
|
309
|
+
selecting: boolean,
|
|
310
|
+
): string[] {
|
|
311
|
+
const { color, width, now } = opts
|
|
312
|
+
|
|
313
|
+
// ── The slug line: TICKET-ID slug … <right cluster> ──────────────────────
|
|
314
|
+
//
|
|
315
|
+
// Explicit left / flexible / right layout, NOT "join then truncate the whole
|
|
316
|
+
// line" — the latter eats the rightmost token (the status, the PR link) first,
|
|
317
|
+
// exactly what this change moves away from. Ticket id is the left column,
|
|
318
|
+
// the slug is the flexible/truncatable identity segment, and the right
|
|
319
|
+
// cluster hugs `width` so status ends at the same column on every row.
|
|
320
|
+
|
|
321
|
+
// Left column: ticket id, padded frame-wide so slugs align even for a build
|
|
322
|
+
// with no ticket (AC 2). No column at all when the frame has zero ticket ids.
|
|
323
|
+
const marker = selectionMarker(selected, selecting, color)
|
|
324
|
+
const ticketCol =
|
|
325
|
+
widths.ticket > 0 ? paint(padEndCells(build.ticketId ?? '', widths.ticket), 'blue', color) : ''
|
|
326
|
+
const leftPrefix = `${marker}${ticketCol === '' ? '' : `${ticketCol} `}`
|
|
327
|
+
|
|
328
|
+
// Right cluster, rightmost last: [PR] [(paused)] STATUS. `padStart`
|
|
329
|
+
// right-justifies the status word so it is never truncated and always ends at
|
|
330
|
+
// the frame's right edge (AC 3, AC 5).
|
|
331
|
+
const rightTokens: string[] = []
|
|
332
|
+
if (build.autoMerge !== 'off') {
|
|
333
|
+
const autoColor: ColorName =
|
|
334
|
+
build.autoMerge === 'enabled' ? 'green' : build.autoMerge === 'requested' ? 'cyan' : 'yellow'
|
|
335
|
+
rightTokens.push(paint('auto merge', autoColor, color))
|
|
336
|
+
}
|
|
337
|
+
if (build.pr !== undefined) rightTokens.push(link(build.pr.url, `PR ${build.pr.state}`, color))
|
|
338
|
+
// A repository hold changes no build lifecycle state. Keep QUEUED literal
|
|
339
|
+
// and annotate only the rows the hold prevents from launching.
|
|
340
|
+
if (repositoryPaused && build.status === 'queued') {
|
|
341
|
+
rightTokens.push(paint('(held)', 'yellow', color))
|
|
342
|
+
}
|
|
343
|
+
// Blocked overrides paused visually, but the pause is still a fact the
|
|
344
|
+
// operator needs — so it rides along rather than being overwritten.
|
|
345
|
+
if (build.alsoPaused) rightTokens.push(paint('(paused)', 'yellow', color))
|
|
346
|
+
rightTokens.push(
|
|
347
|
+
paint(build.status.toUpperCase().padStart(widths.status), STATUS_COLOR[build.status], color),
|
|
348
|
+
)
|
|
349
|
+
const rightStr = rightTokens.join(' ')
|
|
350
|
+
|
|
351
|
+
// The slug is the sole flexible segment; narrow frames truncate it before
|
|
352
|
+
// the right-pinned lifecycle status.
|
|
353
|
+
const lines = [rightPinnedLine(leftPrefix, paint(build.slug, 'bold', color), rightStr, width)]
|
|
354
|
+
const ceiling = reviewRoundCeilingText(build)
|
|
355
|
+
if (ceiling !== undefined) lines.push(truncate(` ${ceiling}`, width))
|
|
356
|
+
|
|
357
|
+
if (build.abortProgress !== undefined) {
|
|
358
|
+
for (const line of wrappedText(build.abortProgress, width - 4, '')) {
|
|
359
|
+
lines.push(truncate(paint(` ! ${line}`, STATUS_COLOR[build.status], color), width))
|
|
360
|
+
}
|
|
361
|
+
} else if (build.dispatch !== undefined) {
|
|
362
|
+
for (const line of wrappedText(build.dispatch, width - 4, '')) {
|
|
363
|
+
lines.push(truncate(paint(` ! ${line}`, STATUS_COLOR[build.status], color), width))
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
// The progress row wraps rather than truncating: the tail is `finalize` and
|
|
367
|
+
// `merge waiting`, which the ACs require and the operator is waiting on.
|
|
368
|
+
lines.push(
|
|
369
|
+
...packLines(
|
|
370
|
+
build.steps.map((s) => renderStep(s, color, now)),
|
|
371
|
+
width,
|
|
372
|
+
' ',
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
// Setup failures and blocker questions are dense-list previews. Their model
|
|
377
|
+
// strings remain untouched; newline-aware wrapping and capping happen only
|
|
378
|
+
// at this presentation seam.
|
|
379
|
+
if (build.setupError !== undefined) {
|
|
380
|
+
lines.push(...prefixedMessagePreview(build.setupError, width, 'red', color, true))
|
|
381
|
+
}
|
|
382
|
+
for (const blocker of build.blockers) {
|
|
383
|
+
lines.push(...prefixedMessagePreview(blocker, width, 'red', color, true))
|
|
384
|
+
}
|
|
385
|
+
return lines
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function renderHarvest(
|
|
389
|
+
harvest: DashboardHarvest,
|
|
390
|
+
opts: RenderOpts,
|
|
391
|
+
widths: Widths,
|
|
392
|
+
selected: boolean,
|
|
393
|
+
selecting: boolean,
|
|
394
|
+
): string[] {
|
|
395
|
+
const { color, width, now } = opts
|
|
396
|
+
const marker = selectionMarker(selected, selecting, color)
|
|
397
|
+
// Harvest has no ticket id, so its title takes the ticket column itself —
|
|
398
|
+
// aligned with the ids, not the slugs — and the observation count sits in
|
|
399
|
+
// the flexible slug slot, keeping one row grammar across mixed frames.
|
|
400
|
+
const title = widths.ticket > 0 ? padEndCells(HARVEST_TITLE, widths.ticket) : HARVEST_TITLE
|
|
401
|
+
const leftPrefix = `${marker}${paint(title, 'bold', color)} `
|
|
402
|
+
const identity = paint(`${harvest.observations} observations`, 'dim', color)
|
|
403
|
+
const statusColor = STATUS_COLOR[harvest.status]
|
|
404
|
+
const status = paint(harvest.status.toUpperCase().padStart(widths.status), statusColor, color)
|
|
405
|
+
const lines = [rightPinnedLine(leftPrefix, identity, status, width)]
|
|
406
|
+
lines.push(
|
|
407
|
+
...packLines(
|
|
408
|
+
harvest.steps.map((item) => renderStep(item, color, now)),
|
|
409
|
+
width,
|
|
410
|
+
' ',
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
if (harvest.detail !== undefined) {
|
|
414
|
+
lines.push(...prefixedMessagePreview(harvest.detail, width, statusColor, color, false))
|
|
415
|
+
}
|
|
416
|
+
return lines
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
interface Widths {
|
|
420
|
+
ticket: number
|
|
421
|
+
status: number
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const HARVEST_TITLE = 'Harvest'
|
|
425
|
+
|
|
426
|
+
/** Pad ticket id and status to the widest in the FRAME, so columns line up down
|
|
427
|
+
* the whole dashboard rather than per-row. `ticket` is 0 when no build in the
|
|
428
|
+
* frame has a ticket id — then there is no ticket column at all. The Harvest
|
|
429
|
+
* title lives in the ticket column, so when that column exists it must also fit
|
|
430
|
+
* the title; Harvest joins the status-width calculation even in a mixed frame. */
|
|
431
|
+
function frameWidths(builds: DashboardBuild[], harvest: DashboardHarvest | undefined): Widths {
|
|
432
|
+
const ticketIds = builds.map((b) => cellWidth(b.ticketId ?? ''))
|
|
433
|
+
const hasTicketColumn = ticketIds.some((length) => length > 0)
|
|
434
|
+
return {
|
|
435
|
+
ticket: Math.max(
|
|
436
|
+
0,
|
|
437
|
+
...ticketIds,
|
|
438
|
+
...(harvest !== undefined && hasTicketColumn ? [cellWidth(HARVEST_TITLE)] : []),
|
|
439
|
+
),
|
|
440
|
+
status: Math.max(
|
|
441
|
+
0,
|
|
442
|
+
...builds.map((b) => cellWidth(b.status)),
|
|
443
|
+
...(harvest !== undefined ? [cellWidth(harvest.status)] : []),
|
|
444
|
+
),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── The frame ────────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
export const DASHBOARD_GLOBAL_LEGEND =
|
|
451
|
+
'Keys: Up/Down select h harvest m auto-merge i intake p pause all r resume all Ctrl-C quit'
|
|
452
|
+
export const DASHBOARD_HARVEST_LEGEND = 'Keys: Up/Down select Ctrl-C quit'
|
|
453
|
+
export const DASHBOARD_HARVEST_RESUME_LEGEND = 'Keys: Up/Down select p resume Ctrl-C quit'
|
|
454
|
+
export const DASHBOARD_HARVEST_ACKNOWLEDGE_LEGEND =
|
|
455
|
+
'Keys: Up/Down select p acknowledge Ctrl-C quit'
|
|
456
|
+
export const DASHBOARD_BUILD_LEGEND =
|
|
457
|
+
'Keys: Up/Down select Enter details m auto-merge p pause a abort Ctrl-C quit'
|
|
458
|
+
export const DASHBOARD_QUEUED_BUILD_LEGEND =
|
|
459
|
+
'Keys: Up/Down select Enter details d discard a abort Ctrl-C quit'
|
|
460
|
+
/** The session view offers ONLY scrolling, back, and quit — it is read-only,
|
|
461
|
+
* and the legend says so by naming nothing else. */
|
|
462
|
+
export const DASHBOARD_SESSION_LIVE_LEGEND =
|
|
463
|
+
'Keys: Up/Down scroll (following tail) Esc back Ctrl-C quit'
|
|
464
|
+
export const DASHBOARD_SESSION_PAUSED_LEGEND =
|
|
465
|
+
'Keys: Up/Down scroll (paused) Esc back Ctrl-C quit'
|
|
466
|
+
|
|
467
|
+
function buildLegend(build: DashboardBuild, detail: boolean): string {
|
|
468
|
+
if (build.status === 'aborting' || build.status === 'cleaning') {
|
|
469
|
+
return detail
|
|
470
|
+
? 'Keys: Up/Down select session Enter transcript Esc back Ctrl-C quit'
|
|
471
|
+
: 'Keys: Up/Down select Enter details Ctrl-C quit'
|
|
472
|
+
}
|
|
473
|
+
if (build.status === 'queued') {
|
|
474
|
+
return detail
|
|
475
|
+
? 'Keys: d discard a abort Esc back Ctrl-C quit'
|
|
476
|
+
: DASHBOARD_QUEUED_BUILD_LEGEND
|
|
477
|
+
}
|
|
478
|
+
const control = dashboardBuildControl(build.status)
|
|
479
|
+
const action = control === undefined ? '' : ` ${control.key} ${control.label}`
|
|
480
|
+
return detail
|
|
481
|
+
? `Keys: Up/Down scroll Left/Right select session Enter transcript m auto-merge${action} a abort Esc back Ctrl-C quit`
|
|
482
|
+
: `Keys: Up/Down select Enter details m auto-merge${action} a abort Ctrl-C quit`
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const MESSAGE_PREVIEW_ROWS = 3
|
|
486
|
+
|
|
487
|
+
/** Preserve authored line structure while limiting display-only blank runs to
|
|
488
|
+
* one row. Two newline separators make one paragraph row; longer runs do not
|
|
489
|
+
* consume more dense-list space. */
|
|
490
|
+
function messageDisplayRows(value: string, width: number, collapseBlankRuns: boolean): string[] {
|
|
491
|
+
if (width <= 0) return []
|
|
492
|
+
const logical = value.split(/\r?\n/)
|
|
493
|
+
const normalized: string[] = []
|
|
494
|
+
let previousBlank = false
|
|
495
|
+
for (const line of logical) {
|
|
496
|
+
const blank = line.trim().length === 0
|
|
497
|
+
if (!collapseBlankRuns || !blank || !previousBlank) normalized.push(line)
|
|
498
|
+
previousBlank = blank
|
|
499
|
+
}
|
|
500
|
+
return normalized.flatMap((line) => wrapDisplay(line, width))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function previewMessageRows(value: string, width: number, expandable: boolean): string[] {
|
|
504
|
+
const rows = messageDisplayRows(value, width, true)
|
|
505
|
+
if (rows.length <= MESSAGE_PREVIEW_ROWS) return rows
|
|
506
|
+
const remaining = rows.length - MESSAGE_PREVIEW_ROWS
|
|
507
|
+
const noun = remaining === 1 ? 'row' : 'rows'
|
|
508
|
+
const hint = expandable ? ' - Enter details' : ''
|
|
509
|
+
return [
|
|
510
|
+
...rows.slice(0, MESSAGE_PREVIEW_ROWS),
|
|
511
|
+
...wrapDisplay(`... ${remaining} more ${noun}${hint}`, width),
|
|
512
|
+
]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function prefixedMessagePreview(
|
|
516
|
+
value: string,
|
|
517
|
+
width: number,
|
|
518
|
+
colorName: ColorName,
|
|
519
|
+
color: boolean,
|
|
520
|
+
expandable: boolean,
|
|
521
|
+
): string[] {
|
|
522
|
+
const contentWidth = Math.max(0, width - 4)
|
|
523
|
+
return previewMessageRows(value, contentWidth, expandable).map((line, index) =>
|
|
524
|
+
truncate(paint(`${index === 0 ? ' ! ' : ' '}${line}`, colorName, color), width),
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Each process warning is independently capped. Unlike build-owned messages,
|
|
529
|
+
* the warning slot has no selectable row, so its notice promises no expansion
|
|
530
|
+
* action. */
|
|
531
|
+
function warningRows(lines: readonly string[], width: number): string[] {
|
|
532
|
+
const contentWidth = Math.max(0, width - 2)
|
|
533
|
+
return lines.flatMap((line) =>
|
|
534
|
+
previewMessageRows(line, contentWidth, false).map((row) => truncate(` ${row}`, width)),
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Wrap prose for the legacy frame elements — build rows, harvest detail,
|
|
540
|
+
* detail view, transcripts.
|
|
541
|
+
*
|
|
542
|
+
* Deliberately unchanged, and deliberately NOT the composer's `wrapDisplay`:
|
|
543
|
+
* this truncates a token wider than the line with a `~`, which every existing
|
|
544
|
+
* frame's byte-stable output depends on. Operator-facing prose that must
|
|
545
|
+
* survive in full — the resume panel's questions and hint — goes through
|
|
546
|
+
* `wrapDisplay` instead, and short labels that must stay whole go through
|
|
547
|
+
* `packAtomic`.
|
|
548
|
+
*/
|
|
549
|
+
function wrappedText(value: string, width: number, indent = ''): string[] {
|
|
550
|
+
if (width <= 0) return []
|
|
551
|
+
const paragraphs = value.split(/\r?\n/)
|
|
552
|
+
const lines: string[] = []
|
|
553
|
+
for (const paragraph of paragraphs) {
|
|
554
|
+
const safe = displayText(paragraph)
|
|
555
|
+
if (safe.length === 0) {
|
|
556
|
+
lines.push(indent)
|
|
557
|
+
continue
|
|
558
|
+
}
|
|
559
|
+
const wrapped = packLines(safe.split(/\s+/), width, indent)
|
|
560
|
+
lines.push(...(wrapped.length > 0 ? wrapped : [indent]))
|
|
561
|
+
}
|
|
562
|
+
return lines
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function detailStep(step: PipelineStep, opts: RenderOpts): string {
|
|
566
|
+
const { color, now } = opts
|
|
567
|
+
const notes: string[] = []
|
|
568
|
+
if (step.qualifier !== undefined) notes.push(step.qualifier)
|
|
569
|
+
if (step.count !== undefined) {
|
|
570
|
+
const kind =
|
|
571
|
+
step.label.startsWith('verify:') || step.label === 'reconcile' ? 'attempt' : 'round'
|
|
572
|
+
notes.push(`${kind} ${step.count}`)
|
|
573
|
+
}
|
|
574
|
+
if (step.timing !== undefined) {
|
|
575
|
+
const elapsed =
|
|
576
|
+
step.timing.accumulatedMs +
|
|
577
|
+
(step.timing.runningSince !== undefined ? Math.max(0, now - step.timing.runningSince) : 0)
|
|
578
|
+
notes.push(formatDuration(elapsed))
|
|
579
|
+
}
|
|
580
|
+
const note = notes.length > 0 ? ` (${notes.join(', ')})` : ''
|
|
581
|
+
return paint(`${GLYPH[step.state]} ${step.label}${note}`, STEP_COLOR[step.state], color)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function sessionLines(session: DashboardSession, selected: boolean, opts: RenderOpts): string[] {
|
|
585
|
+
const usage = session.usage
|
|
586
|
+
const tokens = [
|
|
587
|
+
session.role,
|
|
588
|
+
`phase ${session.phase}`,
|
|
589
|
+
...(session.round !== undefined ? [`round ${session.round}`] : []),
|
|
590
|
+
`runtime ${session.runtime}`,
|
|
591
|
+
...(session.model !== undefined ? [`model ${session.model}`] : []),
|
|
592
|
+
session.status,
|
|
593
|
+
...(session.stream !== undefined ? [`stream ${session.stream} (${session.streamStatus})`] : []),
|
|
594
|
+
...(session.reclaimedBy !== undefined
|
|
595
|
+
? [
|
|
596
|
+
`by ${session.reclaimedBy.instance} at resume boundary ${session.reclaimedBy.resumedFromSeq}`,
|
|
597
|
+
'transcript unavailable',
|
|
598
|
+
]
|
|
599
|
+
: []),
|
|
600
|
+
...(usage !== undefined
|
|
601
|
+
? [`tokens ${usage.inputTokens} in/${usage.outputTokens} out, ${usage.turns} turns`]
|
|
602
|
+
: []),
|
|
603
|
+
]
|
|
604
|
+
const wrapped = packLines(tokens.map(displayText), Math.max(1, opts.width - 4), '')
|
|
605
|
+
return wrapped.map((line, index) =>
|
|
606
|
+
truncate(
|
|
607
|
+
`${index === 0 ? selectionMarker(selected, true, opts.color) : ' '} ${line}`,
|
|
608
|
+
opts.width,
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function viewport(lines: string[], capacity: number, focus: number, scroll = 0): string[] {
|
|
614
|
+
if (capacity <= 0) return []
|
|
615
|
+
const maxStart = Math.max(0, lines.length - capacity)
|
|
616
|
+
const wanted = scroll > 0 ? scroll : Math.max(0, focus - Math.floor(capacity / 2))
|
|
617
|
+
const start = Math.min(maxStart, wanted)
|
|
618
|
+
return lines.slice(start, start + capacity)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
interface DetailBodyLayout {
|
|
622
|
+
body: string[]
|
|
623
|
+
sessionFocus: number | undefined
|
|
624
|
+
messageFocus: number | undefined
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export type DetailFocusTarget = 'session' | 'message'
|
|
628
|
+
|
|
629
|
+
function detailBody(
|
|
630
|
+
build: DashboardBuild,
|
|
631
|
+
view: Extract<NonNullable<DashboardModel['view']>, { kind: 'detail' }>,
|
|
632
|
+
opts: RenderOpts,
|
|
633
|
+
): DetailBodyLayout {
|
|
634
|
+
const { color, width } = opts
|
|
635
|
+
const body: string[] = []
|
|
636
|
+
if (build.ticketId !== undefined) body.push(` ticket ${displayText(build.ticketId)}`)
|
|
637
|
+
body.push(` auto merge ${build.autoMerge}`)
|
|
638
|
+
const ceiling = reviewRoundCeilingText(build)
|
|
639
|
+
if (ceiling !== undefined) body.push(` ${ceiling}`)
|
|
640
|
+
if (build.pr !== undefined)
|
|
641
|
+
body.push(` PR ${build.pr.state} ${link(build.pr.url, build.pr.url, color)}`)
|
|
642
|
+
if (build.abortProgress !== undefined) {
|
|
643
|
+
body.push('', paint('Abort progress', 'bold', color))
|
|
644
|
+
body.push(...wrappedText(build.abortProgress, width, ' '))
|
|
645
|
+
} else if (build.dispatch !== undefined) {
|
|
646
|
+
body.push('', paint('Dispatch', 'bold', color), ...wrappedText(build.dispatch, width, ' '))
|
|
647
|
+
} else {
|
|
648
|
+
body.push('', paint('Pipeline', 'bold', color))
|
|
649
|
+
body.push(...build.steps.map((item) => ` ${detailStep(item, opts)}`))
|
|
650
|
+
}
|
|
651
|
+
if (build.setupError !== undefined) {
|
|
652
|
+
body.push('', paint('Setup failure', 'bold', color))
|
|
653
|
+
body.push(
|
|
654
|
+
...wrapDisplay(build.setupError, width, ' ').map((line) => paint(line, 'red', color)),
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
if (build.blockers.length > 0) {
|
|
658
|
+
body.push('', paint('Unresolved blockers', 'bold', color))
|
|
659
|
+
for (const blocker of build.blockers) {
|
|
660
|
+
body.push(
|
|
661
|
+
...wrapDisplay(`! ${blocker}`, width, ' ').map((line) => paint(line, 'red', color)),
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
for (const note of [
|
|
665
|
+
'The r field records guidance, or a bare retry when empty.',
|
|
666
|
+
`CLI only: ab answer ${build.slug} --dismiss`,
|
|
667
|
+
`CLI only: ab answer ${build.slug} --revise-spec <file>`,
|
|
668
|
+
]) {
|
|
669
|
+
body.push(...wrappedText(note, width, ' ').map((line) => paint(line, 'dim', color)))
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
body.push('', paint('Sessions', 'bold', color))
|
|
673
|
+
const sessions = build.sessions ?? []
|
|
674
|
+
let sessionFocus: number | undefined
|
|
675
|
+
if (sessions.length === 0) body.push(' no agent sessions recorded')
|
|
676
|
+
for (const session of sessions) {
|
|
677
|
+
if (session.id === view.sessionId) sessionFocus = body.length
|
|
678
|
+
body.push(...sessionLines(session, session.id === view.sessionId, opts))
|
|
679
|
+
}
|
|
680
|
+
let messageFocus: number | undefined
|
|
681
|
+
if (view.message !== undefined) {
|
|
682
|
+
const messageLines = wrappedText(view.message, width, ' ').map((line) =>
|
|
683
|
+
paint(line, 'yellow', color),
|
|
684
|
+
)
|
|
685
|
+
body.push('')
|
|
686
|
+
if (messageLines.length > 0) messageFocus = body.length
|
|
687
|
+
body.push(...messageLines)
|
|
688
|
+
}
|
|
689
|
+
return { body, sessionFocus, messageFocus }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
interface DetailGeometry extends DetailBodyLayout {
|
|
693
|
+
capacity: number
|
|
694
|
+
limit: number
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function upgradeNoticeLine(
|
|
698
|
+
model: DashboardModel,
|
|
699
|
+
color: boolean,
|
|
700
|
+
width: number,
|
|
701
|
+
): string | undefined {
|
|
702
|
+
return model.availableUpgrade === undefined
|
|
703
|
+
? undefined
|
|
704
|
+
: truncate(
|
|
705
|
+
paint(
|
|
706
|
+
` Autobuild v${displayText(model.availableUpgrade)} is available — run ab upgrade`,
|
|
707
|
+
'cyan',
|
|
708
|
+
color,
|
|
709
|
+
),
|
|
710
|
+
width,
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function detailGeometry(
|
|
715
|
+
model: DashboardModel,
|
|
716
|
+
width: number,
|
|
717
|
+
height: number,
|
|
718
|
+
): DetailGeometry | undefined {
|
|
719
|
+
const view = model.view
|
|
720
|
+
if (view?.kind !== 'detail') return undefined
|
|
721
|
+
const build = model.builds.find((candidate) => candidate.slug === view.slug)
|
|
722
|
+
if (build === undefined) return undefined
|
|
723
|
+
const layout = detailBody(build, view, { color: false, width, height, now: 0 })
|
|
724
|
+
const topLength = 2 + (model.availableUpgrade === undefined ? 0 : 1)
|
|
725
|
+
const controls =
|
|
726
|
+
model.resumeInput !== undefined || model.abortConfirmation !== undefined
|
|
727
|
+
? dashboardControls(model, false, width, controlsCapacity(height, topLength))
|
|
728
|
+
: [buildLegend(build, true)]
|
|
729
|
+
const capacity = Math.max(0, height - topLength - 2 - controls.length)
|
|
730
|
+
return { ...layout, capacity, limit: Math.max(0, layout.body.length - capacity) }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function detailScrollLimit(model: DashboardModel, width: number, height: number): number {
|
|
734
|
+
return detailGeometry(model, width, height)?.limit ?? 0
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function moveDetailScroll(
|
|
738
|
+
model: DashboardModel,
|
|
739
|
+
width: number,
|
|
740
|
+
height: number,
|
|
741
|
+
current: number,
|
|
742
|
+
delta: number,
|
|
743
|
+
): number {
|
|
744
|
+
const limit = detailScrollLimit(model, width, height)
|
|
745
|
+
return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Reveal one action-selected detail target without making retained display
|
|
749
|
+
* content a standing focus. Incoming and resulting offsets are always clamped. */
|
|
750
|
+
export function revealDetailFocus(
|
|
751
|
+
model: DashboardModel,
|
|
752
|
+
width: number,
|
|
753
|
+
height: number,
|
|
754
|
+
target: DetailFocusTarget,
|
|
755
|
+
current: number,
|
|
756
|
+
): number {
|
|
757
|
+
const geometry = detailGeometry(model, width, height)
|
|
758
|
+
if (geometry === undefined) return 0
|
|
759
|
+
const scroll = Math.max(0, Math.min(geometry.limit, current))
|
|
760
|
+
const focus = target === 'session' ? geometry.sessionFocus : geometry.messageFocus
|
|
761
|
+
if (focus === undefined || geometry.capacity <= 0) return scroll
|
|
762
|
+
if (focus < scroll) return focus
|
|
763
|
+
if (focus >= scroll + geometry.capacity) {
|
|
764
|
+
return Math.min(geometry.limit, focus - Math.max(0, geometry.capacity - 1))
|
|
765
|
+
}
|
|
766
|
+
return scroll
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function renderDetail(model: DashboardModel, opts: RenderOpts): string[] {
|
|
770
|
+
const view = model.view
|
|
771
|
+
if (view?.kind !== 'detail') return []
|
|
772
|
+
const build = model.builds.find((candidate) => candidate.slug === view.slug)
|
|
773
|
+
if (build === undefined) return []
|
|
774
|
+
const { color, width, height } = opts
|
|
775
|
+
const paused = build.alsoPaused ? ' (also paused)' : ''
|
|
776
|
+
const notice = upgradeNoticeLine(model, color, width)
|
|
777
|
+
const top = [
|
|
778
|
+
truncate(`${paint('Build', 'bold', color)} ${displayText(build.slug)}`, width),
|
|
779
|
+
truncate(
|
|
780
|
+
` status ${paint(build.status.toUpperCase(), STATUS_COLOR[build.status], color)}${paused}`,
|
|
781
|
+
width,
|
|
782
|
+
),
|
|
783
|
+
...(notice === undefined ? [] : [notice]),
|
|
784
|
+
]
|
|
785
|
+
const { body } = detailBody(build, view, opts)
|
|
786
|
+
const controls =
|
|
787
|
+
model.resumeInput !== undefined || model.abortConfirmation !== undefined
|
|
788
|
+
? dashboardControls(model, color, width, controlsCapacity(height, top.length))
|
|
789
|
+
: [truncate(paint(buildLegend(build, true), 'dim', color), width)]
|
|
790
|
+
if (height !== undefined && height <= 0) return []
|
|
791
|
+
if (height !== undefined && height <= top.length) return top.slice(0, height)
|
|
792
|
+
if (height !== undefined && height === top.length + 1) return [...top, ...controls]
|
|
793
|
+
if (height !== undefined && height === top.length + 2) return [...top, '', ...controls]
|
|
794
|
+
const capacity =
|
|
795
|
+
height === undefined ? body.length : Math.max(0, height - top.length - 2 - controls.length)
|
|
796
|
+
const limit = Math.max(0, body.length - capacity)
|
|
797
|
+
const start = Math.max(0, Math.min(limit, view.scroll))
|
|
798
|
+
return [
|
|
799
|
+
...top,
|
|
800
|
+
'',
|
|
801
|
+
...body.slice(start, start + capacity).map((line) => truncate(line, width)),
|
|
802
|
+
'',
|
|
803
|
+
...controls,
|
|
804
|
+
].slice(0, height === undefined ? Number.POSITIVE_INFINITY : height)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function transcriptContent(presentation: TranscriptPresentation, width: number): string[] {
|
|
808
|
+
if (presentation.kind === 'raw') {
|
|
809
|
+
return [
|
|
810
|
+
'Unrecognized transcript format - raw content:',
|
|
811
|
+
'',
|
|
812
|
+
...presentation.text.split(/\r?\n/).flatMap((line) => wrappedText(line, width, ' ')),
|
|
813
|
+
]
|
|
814
|
+
}
|
|
815
|
+
const lines: string[] = []
|
|
816
|
+
if (presentation.kind === 'producer-boundary') {
|
|
817
|
+
lines.push(...wrappedText(presentation.notice, width, ''), '')
|
|
818
|
+
}
|
|
819
|
+
for (const [index, turn] of presentation.turns.entries()) {
|
|
820
|
+
lines.push(`Turn ${index + 1}`)
|
|
821
|
+
lines.push(...turnContent(turn, width), '')
|
|
822
|
+
}
|
|
823
|
+
return lines
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Convert a terminal's width to the dashboard's inset composition width. */
|
|
827
|
+
export function dashboardContentWidth(terminalWidth: number): number {
|
|
828
|
+
return Math.max(0, Math.max(0, terminalWidth) - 2)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Maximum process-local transcript offset for the current wrapped viewport. */
|
|
832
|
+
export function transcriptScrollLimit(
|
|
833
|
+
presentation: TranscriptPresentation,
|
|
834
|
+
terminalWidth: number,
|
|
835
|
+
height: number,
|
|
836
|
+
hasUpgradeNotice = false,
|
|
837
|
+
): number {
|
|
838
|
+
const contentLines = transcriptContent(presentation, dashboardContentWidth(terminalWidth)).length
|
|
839
|
+
// Transcript chrome is two header rows, the optional upgrade row, two
|
|
840
|
+
// separators, and controls. Keep this in lockstep with renderTranscript.
|
|
841
|
+
const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
|
|
842
|
+
return Math.max(0, contentLines - capacity)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
export function moveTranscriptScroll(
|
|
846
|
+
presentation: TranscriptPresentation,
|
|
847
|
+
terminalWidth: number,
|
|
848
|
+
height: number,
|
|
849
|
+
current: number,
|
|
850
|
+
delta: number,
|
|
851
|
+
hasUpgradeNotice = false,
|
|
852
|
+
): number {
|
|
853
|
+
const limit = transcriptScrollLimit(presentation, terminalWidth, height, hasUpgradeNotice)
|
|
854
|
+
// Clamp current first because a resize, or state from an older controller,
|
|
855
|
+
// may leave it beyond the freshly wrapped viewport's end.
|
|
856
|
+
return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function turnContent(turn: TranscriptTurn, width: number): string[] {
|
|
860
|
+
const lines = [
|
|
861
|
+
...wrappedText(`Prompt: ${turn.prompt}`, width, ' '),
|
|
862
|
+
...wrappedText(`Agent: ${turn.text}`, width, ' '),
|
|
863
|
+
]
|
|
864
|
+
if (turn.usage !== undefined) {
|
|
865
|
+
lines.push(
|
|
866
|
+
` Usage: ${turn.usage.inputTokens} input, ${turn.usage.outputTokens} output${
|
|
867
|
+
turn.usage.turns !== undefined ? `, ${turn.usage.turns} turns` : ''
|
|
868
|
+
}`,
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
if (turn.failure !== undefined)
|
|
872
|
+
lines.push(...wrappedText(`Failure: ${turn.failure}`, width, ' '))
|
|
873
|
+
return lines
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function renderTranscript(model: DashboardModel, opts: RenderOpts): string[] {
|
|
877
|
+
const view = model.view
|
|
878
|
+
if (view?.kind !== 'transcript') return []
|
|
879
|
+
const { color, width, height } = opts
|
|
880
|
+
const notice = upgradeNoticeLine(model, color, width)
|
|
881
|
+
const top = [
|
|
882
|
+
truncate(`${paint('Transcript', 'bold', color)} ${displayText(view.slug)}`, width),
|
|
883
|
+
truncate(` session ${displayText(view.sessionId)}`, width),
|
|
884
|
+
...(notice === undefined ? [] : [notice]),
|
|
885
|
+
]
|
|
886
|
+
const content = transcriptContent(view.transcript, width).map((line) => truncate(line, width))
|
|
887
|
+
const controls = truncate(
|
|
888
|
+
paint('Keys: Up/Down scroll Esc back Ctrl-C quit', 'dim', color),
|
|
889
|
+
width,
|
|
890
|
+
)
|
|
891
|
+
if (height !== undefined && height <= 0) return []
|
|
892
|
+
if (height !== undefined && height <= top.length) return top.slice(0, height)
|
|
893
|
+
const capacity = height === undefined ? content.length : Math.max(0, height - top.length - 3)
|
|
894
|
+
return [...top, '', ...viewport(content, capacity, 0, view.scroll), '', controls].slice(
|
|
895
|
+
0,
|
|
896
|
+
height === undefined ? Number.POSITIVE_INFINITY : height,
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** The session view's full scrollable content: the shared projection's lines,
|
|
901
|
+
* or the error message in place of them, plus the closed stream's outcome
|
|
902
|
+
* line. ONE shared derivation for the renderer and the scroll limit so the
|
|
903
|
+
* tail math cannot drift from the paint. */
|
|
904
|
+
export function sessionContentLines(view: SessionDashboardView, width: number): string[] {
|
|
905
|
+
if (view.error !== undefined) return wrappedText(view.error, width, '')
|
|
906
|
+
const lines =
|
|
907
|
+
view.source.kind === 'parts'
|
|
908
|
+
? projectSessionParts(view.source.parts, width)
|
|
909
|
+
: projectSessionDocument(view.source.document, width)
|
|
910
|
+
if (view.status === 'closed') {
|
|
911
|
+
lines.push(`Stream closed: ${view.outcome ?? 'completed'}`)
|
|
912
|
+
}
|
|
913
|
+
return lines
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Maximum process-local session-view offset for the current wrapped viewport.
|
|
917
|
+
* Mirrors the transcript pair; the session view's chrome is identical. */
|
|
918
|
+
export function sessionScrollLimit(
|
|
919
|
+
view: SessionDashboardView,
|
|
920
|
+
terminalWidth: number,
|
|
921
|
+
height: number,
|
|
922
|
+
hasUpgradeNotice = false,
|
|
923
|
+
): number {
|
|
924
|
+
const contentLines = sessionContentLines(view, dashboardContentWidth(terminalWidth)).length
|
|
925
|
+
// Session chrome is two header rows, the optional upgrade row, two
|
|
926
|
+
// separators, and controls — the transcript's exact arithmetic.
|
|
927
|
+
const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
|
|
928
|
+
return Math.max(0, contentLines - capacity)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export function moveSessionScroll(
|
|
932
|
+
view: SessionDashboardView,
|
|
933
|
+
terminalWidth: number,
|
|
934
|
+
height: number,
|
|
935
|
+
current: number,
|
|
936
|
+
delta: number,
|
|
937
|
+
hasUpgradeNotice = false,
|
|
938
|
+
): number {
|
|
939
|
+
const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice)
|
|
940
|
+
// Clamp current first: a resize, a feed apply, or state from an older
|
|
941
|
+
// controller may leave it beyond the freshly wrapped viewport's end.
|
|
942
|
+
return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function renderSessionView(model: DashboardModel, opts: RenderOpts): string[] {
|
|
946
|
+
const view = model.view
|
|
947
|
+
if (view?.kind !== 'session') return []
|
|
948
|
+
const { color, width, height } = opts
|
|
949
|
+
const notice = upgradeNoticeLine(model, color, width)
|
|
950
|
+
const top = [
|
|
951
|
+
truncate(`${paint('Session', 'bold', color)} ${displayText(view.slug)}`, width),
|
|
952
|
+
truncate(
|
|
953
|
+
` session ${displayText(view.sessionId)} · ${displayText(view.stream)} · ${
|
|
954
|
+
view.status === 'open' ? 'live' : 'closed'
|
|
955
|
+
}`,
|
|
956
|
+
width,
|
|
957
|
+
),
|
|
958
|
+
...(notice === undefined ? [] : [notice]),
|
|
959
|
+
]
|
|
960
|
+
const content = sessionContentLines(view, width).map((line) => truncate(line, width))
|
|
961
|
+
const controls = truncate(
|
|
962
|
+
paint(
|
|
963
|
+
view.follow ? DASHBOARD_SESSION_LIVE_LEGEND : DASHBOARD_SESSION_PAUSED_LEGEND,
|
|
964
|
+
'dim',
|
|
965
|
+
color,
|
|
966
|
+
),
|
|
967
|
+
width,
|
|
968
|
+
)
|
|
969
|
+
if (height !== undefined && height <= 0) return []
|
|
970
|
+
if (height !== undefined && height <= top.length) return top.slice(0, height)
|
|
971
|
+
const capacity = height === undefined ? content.length : Math.max(0, height - top.length - 3)
|
|
972
|
+
// While following, the view pins to the limit so the tail is tracked as new
|
|
973
|
+
// content lands; otherwise the stored process-local offset rules.
|
|
974
|
+
const limit = Math.max(0, content.length - capacity)
|
|
975
|
+
const scroll = view.follow ? limit : Math.max(0, Math.min(limit, view.scroll))
|
|
976
|
+
return [...top, '', ...viewport(content, capacity, 0, scroll), '', controls].slice(
|
|
977
|
+
0,
|
|
978
|
+
height === undefined ? Number.POSITIVE_INFINITY : height,
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── The blocked-build resume panel ───────────────────────────────────────────
|
|
983
|
+
//
|
|
984
|
+
// Answering a blocked build is the ONLY thing that can happen while the prompt
|
|
985
|
+
// is open, so the controls region has nothing else to say and the panel takes
|
|
986
|
+
// it over whole. It is a block of rows, not a line: a title, a hint, the
|
|
987
|
+
// blocker being answered, a real multi-line field with a caret, and its own key
|
|
988
|
+
// bindings.
|
|
989
|
+
//
|
|
990
|
+
// The panel is bounded from OUTSIDE (a capacity the frame grants) and from
|
|
991
|
+
// INSIDE (its own per-block row caps), which is what makes graceful degradation
|
|
992
|
+
// a property of the allocation rather than a special case per element.
|
|
993
|
+
|
|
994
|
+
const FIELD_MAX_ROWS = 6
|
|
995
|
+
const KEYS_MAX_ROWS = 6
|
|
996
|
+
const HINT_MAX_ROWS = 4
|
|
997
|
+
const QUESTION_MAX_ROWS = 4
|
|
998
|
+
/** The caret is an inserted ASCII glyph, not reverse video: `frame-image.ts`
|
|
999
|
+
* rejects any SGR outside the dashboard's palette, and this file's opening rule
|
|
1000
|
+
* is that stripping escapes must leave the actionable state intact — which a
|
|
1001
|
+
* colour-only cursor does not. */
|
|
1002
|
+
const RESUME_CARET = '|'
|
|
1003
|
+
|
|
1004
|
+
interface ResumeBinding {
|
|
1005
|
+
key: string
|
|
1006
|
+
/** A shorter key form, used only when it saves a ROW. Dropping the alias
|
|
1007
|
+
* costs a convenience; dropping an action word would cost the panel its
|
|
1008
|
+
* meaning. */
|
|
1009
|
+
keyShort?: string
|
|
1010
|
+
action: string
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* The panel's key bindings, held as data rather than baked into format strings
|
|
1015
|
+
* so the invariant is visible at the definition site:
|
|
1016
|
+
*
|
|
1017
|
+
* >>> A rendered binding ALWAYS reads `<key> <action>`, action spelled out.
|
|
1018
|
+
*
|
|
1019
|
+
* Narrow widths are paid for in ROWS — by wrapping a binding across two — never
|
|
1020
|
+
* in words. `Ctrl-J nl` fits where `Ctrl-J newline` does not, and stops naming
|
|
1021
|
+
* the action, which was the entire point of the row.
|
|
1022
|
+
*/
|
|
1023
|
+
const RESUME_BINDINGS: readonly ResumeBinding[] = [
|
|
1024
|
+
{ key: 'Enter', action: 'submit' },
|
|
1025
|
+
{ key: 'Ctrl-J or Shift+Enter', keyShort: 'Ctrl-J', action: 'newline' },
|
|
1026
|
+
{ key: 'Esc', action: 'cancel' },
|
|
1027
|
+
]
|
|
1028
|
+
|
|
1029
|
+
/** Lay one set of key forms out at `width`, or `undefined` when some binding
|
|
1030
|
+
* cannot be named at all — its key or its action alone overflows the row. */
|
|
1031
|
+
function layoutBindings(
|
|
1032
|
+
bindings: ReadonlyArray<{ key: string; action: string }>,
|
|
1033
|
+
width: number,
|
|
1034
|
+
): string[] | undefined {
|
|
1035
|
+
const blocks: string[][] = []
|
|
1036
|
+
let allSingleRow = true
|
|
1037
|
+
for (const binding of bindings) {
|
|
1038
|
+
const single = `${binding.key} ${binding.action}`
|
|
1039
|
+
if (single.length <= width) {
|
|
1040
|
+
blocks.push([single])
|
|
1041
|
+
continue
|
|
1042
|
+
}
|
|
1043
|
+
if (binding.key.length > width || binding.action.length > width) return undefined
|
|
1044
|
+
allSingleRow = false
|
|
1045
|
+
// The continuation indent is the first thing to go; the action word is not.
|
|
1046
|
+
const indent = 2 + binding.action.length <= width ? ' ' : ''
|
|
1047
|
+
blocks.push([binding.key, `${indent}${binding.action}`])
|
|
1048
|
+
}
|
|
1049
|
+
if (!allSingleRow) return blocks.flat()
|
|
1050
|
+
const tokens = blocks.map((block) => block[0]!)
|
|
1051
|
+
// The `Keys: ` prefix earns its columns only when all three still share one
|
|
1052
|
+
// row with it.
|
|
1053
|
+
const prefixed = `Keys: ${tokens.join(' ')}`
|
|
1054
|
+
if (prefixed.length <= width) return [prefixed]
|
|
1055
|
+
return packAtomic(tokens, width)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The keys block: 0…`KEYS_MAX_ROWS` rows naming submit, newline, and cancel.
|
|
1060
|
+
*
|
|
1061
|
+
* Below the width where `newline` fits a row at all the block is dropped
|
|
1062
|
+
* WHOLE. Rendering the other two while silently omitting newline would
|
|
1063
|
+
* misrepresent what the prompt accepts, which is worse than saying nothing.
|
|
1064
|
+
*/
|
|
1065
|
+
export function resumeKeysRows(width: number): string[] {
|
|
1066
|
+
if (width <= 0) return []
|
|
1067
|
+
const long = layoutBindings(
|
|
1068
|
+
RESUME_BINDINGS.map((b) => ({ key: b.key, action: b.action })),
|
|
1069
|
+
width,
|
|
1070
|
+
)
|
|
1071
|
+
const short = layoutBindings(
|
|
1072
|
+
RESUME_BINDINGS.map((b) => ({ key: b.keyShort ?? b.key, action: b.action })),
|
|
1073
|
+
width,
|
|
1074
|
+
)
|
|
1075
|
+
// Fewer rows wins; a tie goes to the long form, so `Shift+Enter` is named
|
|
1076
|
+
// whenever it is free.
|
|
1077
|
+
const chosen =
|
|
1078
|
+
long === undefined ? short : short !== undefined && short.length < long.length ? short : long
|
|
1079
|
+
return (chosen ?? []).slice(0, KEYS_MAX_ROWS)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* The hint's rungs, widest first.
|
|
1084
|
+
*
|
|
1085
|
+
* >>> EVERY rung states both facts: guidance is optional, and submitting an
|
|
1086
|
+
* >>> empty field resumes the build without it.
|
|
1087
|
+
*
|
|
1088
|
+
* The rungs shorten the connective tissue, never the content. A future edit
|
|
1089
|
+
* that shortens one past both facts is visible right here.
|
|
1090
|
+
*/
|
|
1091
|
+
const RESUME_HINTS: readonly string[] = [
|
|
1092
|
+
'Guidance is optional -- Enter on an empty field resumes without it',
|
|
1093
|
+
'Guidance is optional -- Enter alone resumes without it',
|
|
1094
|
+
'Guidance optional; Enter alone resumes without it',
|
|
1095
|
+
'Guidance optional; Enter alone resumes',
|
|
1096
|
+
]
|
|
1097
|
+
|
|
1098
|
+
/** The widest rung that fits one row, else the narrowest rung WRAPPED — because
|
|
1099
|
+
* wrapping costs rows while shortening cost meaning. Past the row cap the block
|
|
1100
|
+
* is dropped whole: half a sentence states neither fact. */
|
|
1101
|
+
export function resumeHintRows(width: number): string[] {
|
|
1102
|
+
if (width <= 0) return []
|
|
1103
|
+
for (const rung of RESUME_HINTS) if (rung.length <= width) return [rung]
|
|
1104
|
+
const wrapped = wrapDisplay(RESUME_HINTS.at(-1)!, width)
|
|
1105
|
+
return wrapped.length <= HINT_MAX_ROWS ? wrapped : []
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Rows the controls region may claim.
|
|
1110
|
+
*
|
|
1111
|
+
* Exactly `1` at every height where today's frame has room for only the
|
|
1112
|
+
* legend — so a one-line legend spends exactly what it always did and every
|
|
1113
|
+
* non-prompt frame stays byte-identical — and otherwise at least one body row
|
|
1114
|
+
* is reserved for the build list.
|
|
1115
|
+
*/
|
|
1116
|
+
export function controlsCapacity(
|
|
1117
|
+
height: number | undefined,
|
|
1118
|
+
topLength: number,
|
|
1119
|
+
separatorCount = 2,
|
|
1120
|
+
): number {
|
|
1121
|
+
if (height === undefined) return Number.POSITIVE_INFINITY
|
|
1122
|
+
const below = height - topLength - separatorCount
|
|
1123
|
+
return Math.max(1, Math.min(below - 1, Math.max(4, Math.floor(below / 2))))
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** One field row with the caret inserted at `caret`, painted but never wider
|
|
1127
|
+
* than `width`. The composer reserves a column for the glyph, so the clip is a
|
|
1128
|
+
* no-op for every `width >= 2`; at width 1 it keeps the caret visible. */
|
|
1129
|
+
function renderFieldRow(
|
|
1130
|
+
row: string,
|
|
1131
|
+
caret: { offset: number; column: number } | undefined,
|
|
1132
|
+
width: number,
|
|
1133
|
+
color: boolean,
|
|
1134
|
+
): string {
|
|
1135
|
+
if (caret === undefined) return truncate(row, width)
|
|
1136
|
+
if (width <= 1) return paint(RESUME_CARET, 'cyan', color)
|
|
1137
|
+
const before = row.slice(0, caret.offset)
|
|
1138
|
+
const after = row.slice(caret.offset)
|
|
1139
|
+
// layoutComposer reserves the caret's one cell. Keep insertion in UTF-16
|
|
1140
|
+
// space and geometry in cell space so a wide cluster is never sliced at a
|
|
1141
|
+
// display column.
|
|
1142
|
+
return truncate(`${before}${paint(RESUME_CARET, 'cyan', color)}${after}`, width)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The panel, assembled and allocated.
|
|
1147
|
+
*
|
|
1148
|
+
* Against a granted capacity, rows go out in this flat order while capacity
|
|
1149
|
+
* remains — the field and its bindings are the LAST things to go and the
|
|
1150
|
+
* blocker text is the first:
|
|
1151
|
+
*
|
|
1152
|
+
* 1. the first field row,
|
|
1153
|
+
* 2. every keys row, in order,
|
|
1154
|
+
* 3. the title row,
|
|
1155
|
+
* 4. every hint row, in order,
|
|
1156
|
+
* 5. question rows, up to `min(desired, QUESTION_MAX_ROWS, ceil(rest / 2))`,
|
|
1157
|
+
* 6. further field rows, up to `FIELD_MAX_ROWS`,
|
|
1158
|
+
* 7. any capacity still unspent, back to questions.
|
|
1159
|
+
*
|
|
1160
|
+
* >>> That order is a PREFIX under WIDTH exactly as it is under capacity. <<<
|
|
1161
|
+
*
|
|
1162
|
+
* A block the granted width cannot represent at all — the keys below content
|
|
1163
|
+
* width 7, the hint below 11 — HALTS the order rather than yielding its rows to
|
|
1164
|
+
* whatever comes next. Without that, a six-column panel printed a truncated
|
|
1165
|
+
* title and a wrapped blocker while naming none of its keys, which is the
|
|
1166
|
+
* degradation order run exactly backwards. Only the field is exempt, because it
|
|
1167
|
+
* outranks every block that can halt: leftover rows still make it taller.
|
|
1168
|
+
*/
|
|
1169
|
+
export function resumePanel(
|
|
1170
|
+
model: DashboardModel,
|
|
1171
|
+
color: boolean,
|
|
1172
|
+
width: number,
|
|
1173
|
+
capacity: number,
|
|
1174
|
+
): string[] {
|
|
1175
|
+
const prompt = model.resumeInput!
|
|
1176
|
+
// A frame that granted NO content columns can render nothing at all. This is
|
|
1177
|
+
// not the same as running out of capacity: `renderDashboard` reserves its
|
|
1178
|
+
// one-column gutter BEFORE calling in, so a one-column terminal arrives here
|
|
1179
|
+
// as width 0, and a field that clamped itself up to one column would emit a
|
|
1180
|
+
// physical row two columns wide. The panel never manufactures a column the
|
|
1181
|
+
// frame did not grant — which is what makes every `fieldWidth` below at
|
|
1182
|
+
// least 1 without a clamp.
|
|
1183
|
+
if (width <= 0) return []
|
|
1184
|
+
const questions = model.builds.find((candidate) => candidate.slug === prompt.slug)?.blockers ?? []
|
|
1185
|
+
|
|
1186
|
+
const title = truncate(
|
|
1187
|
+
paint(paint(`Resume ${displayText(prompt.slug)}`, 'cyan', color), 'bold', color),
|
|
1188
|
+
width,
|
|
1189
|
+
)
|
|
1190
|
+
const hintRows = resumeHintRows(width)
|
|
1191
|
+
const keysRows = resumeKeysRows(width)
|
|
1192
|
+
// Questions go through the NON-truncating wrap: an 80-character URL in a
|
|
1193
|
+
// blocker occupies three rows rather than losing its tail to a `~`. Content
|
|
1194
|
+
// leaves the panel only in whole-row units, which is what the omission
|
|
1195
|
+
// notice accounts for.
|
|
1196
|
+
const questionRows: string[] = []
|
|
1197
|
+
for (const question of questions) {
|
|
1198
|
+
const wrapped = wrapDisplay(question, width - 4)
|
|
1199
|
+
for (const [index, line] of wrapped.entries()) {
|
|
1200
|
+
questionRows.push(
|
|
1201
|
+
truncate(paint(`${index === 0 ? ' ! ' : ' '}${line}`, 'red', color), width),
|
|
1202
|
+
)
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// The indent is dropped before it could eat the field's last column, so this
|
|
1206
|
+
// is at least 1 for every width the early return above lets through.
|
|
1207
|
+
const fieldIndent = width >= 6 ? ' ' : ''
|
|
1208
|
+
const fieldWidth = width - fieldIndent.length
|
|
1209
|
+
const field = layoutComposer(prompt.value, prompt.cursor, fieldWidth)
|
|
1210
|
+
|
|
1211
|
+
let rest = capacity
|
|
1212
|
+
const spend = (want: number): number => {
|
|
1213
|
+
const granted = Math.max(0, Math.min(want, rest))
|
|
1214
|
+
rest -= granted
|
|
1215
|
+
return granted
|
|
1216
|
+
}
|
|
1217
|
+
// Both blocks are built from constants, so an empty one means the width
|
|
1218
|
+
// cannot represent it — never that there was nothing to say. Each halts every
|
|
1219
|
+
// block BELOW it in the priority order.
|
|
1220
|
+
const keysDropped = keysRows.length === 0
|
|
1221
|
+
const hintDropped = keysDropped || hintRows.length === 0
|
|
1222
|
+
|
|
1223
|
+
let fieldGrant = spend(1)
|
|
1224
|
+
const keysGrant = spend(keysRows.length)
|
|
1225
|
+
const titleGrant = keysDropped ? 0 : spend(1)
|
|
1226
|
+
const hintGrant = spend(hintRows.length)
|
|
1227
|
+
let questionGrant = hintDropped
|
|
1228
|
+
? 0
|
|
1229
|
+
: spend(Math.min(questionRows.length, QUESTION_MAX_ROWS, Math.ceil(Math.max(0, rest) / 2)))
|
|
1230
|
+
// Ungated: the field outranks every block that can halt the order, so rows a
|
|
1231
|
+
// halt freed up are exactly what a taller field is for.
|
|
1232
|
+
fieldGrant += spend(Math.min(field.rows.length, FIELD_MAX_ROWS) - fieldGrant)
|
|
1233
|
+
questionGrant += hintDropped
|
|
1234
|
+
? 0
|
|
1235
|
+
: spend(Math.min(questionRows.length, QUESTION_MAX_ROWS) - questionGrant)
|
|
1236
|
+
|
|
1237
|
+
const rows: string[] = []
|
|
1238
|
+
if (titleGrant > 0) rows.push(title)
|
|
1239
|
+
for (const line of hintRows.slice(0, hintGrant)) {
|
|
1240
|
+
rows.push(truncate(paint(line, 'dim', color), width))
|
|
1241
|
+
}
|
|
1242
|
+
if (questionGrant > 0) {
|
|
1243
|
+
const shown = questionRows.slice(0, questionGrant)
|
|
1244
|
+
const omitted = questionRows.length - questionGrant
|
|
1245
|
+
// Whole-row loss is accounted for explicitly rather than cut silently. At
|
|
1246
|
+
// zero granted rows the blocker is absent entirely — AC 7's drop order,
|
|
1247
|
+
// with no row left to carry a notice.
|
|
1248
|
+
if (omitted > 0) {
|
|
1249
|
+
shown[shown.length - 1] = overflowNotice(`and ${omitted + 1} more lines`, color, width)
|
|
1250
|
+
}
|
|
1251
|
+
rows.push(...shown)
|
|
1252
|
+
}
|
|
1253
|
+
// The visible window is the granted slice containing the cursor row, biased
|
|
1254
|
+
// to keep that row last while typing.
|
|
1255
|
+
const start = Math.max(
|
|
1256
|
+
0,
|
|
1257
|
+
Math.min(field.cursorRow - fieldGrant + 1, Math.max(0, field.rows.length - fieldGrant)),
|
|
1258
|
+
)
|
|
1259
|
+
for (const [offset, row] of field.rows.slice(start, start + fieldGrant).entries()) {
|
|
1260
|
+
const index = start + offset
|
|
1261
|
+
rows.push(
|
|
1262
|
+
`${fieldIndent}${renderFieldRow(
|
|
1263
|
+
row,
|
|
1264
|
+
index === field.cursorRow
|
|
1265
|
+
? { offset: field.cursorOffset, column: field.cursorColumn }
|
|
1266
|
+
: undefined,
|
|
1267
|
+
fieldWidth,
|
|
1268
|
+
color,
|
|
1269
|
+
)}`,
|
|
1270
|
+
)
|
|
1271
|
+
}
|
|
1272
|
+
for (const line of keysRows.slice(0, keysGrant)) {
|
|
1273
|
+
rows.push(truncate(paint(line, 'dim', color), width))
|
|
1274
|
+
}
|
|
1275
|
+
return rows
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function dashboardControls(
|
|
1279
|
+
model: DashboardModel,
|
|
1280
|
+
color: boolean,
|
|
1281
|
+
width: number,
|
|
1282
|
+
capacity: number,
|
|
1283
|
+
): string[] {
|
|
1284
|
+
if (model.abortConfirmation !== undefined) {
|
|
1285
|
+
return [
|
|
1286
|
+
truncate(
|
|
1287
|
+
paint(
|
|
1288
|
+
`Abort ${displayText(model.abortConfirmation.slug)} and delete its workspace, PR, and branches? Enter confirm Esc cancel`,
|
|
1289
|
+
'red',
|
|
1290
|
+
color,
|
|
1291
|
+
),
|
|
1292
|
+
width,
|
|
1293
|
+
),
|
|
1294
|
+
]
|
|
1295
|
+
}
|
|
1296
|
+
if (model.resumeInput === undefined) {
|
|
1297
|
+
const legend =
|
|
1298
|
+
model.selection?.kind === 'build'
|
|
1299
|
+
? (() => {
|
|
1300
|
+
const build = model.builds.find(
|
|
1301
|
+
(candidate) =>
|
|
1302
|
+
candidate.slug === (model.selection?.kind === 'build' ? model.selection.slug : ''),
|
|
1303
|
+
)
|
|
1304
|
+
return build === undefined ? DASHBOARD_BUILD_LEGEND : buildLegend(build, false)
|
|
1305
|
+
})()
|
|
1306
|
+
: model.selection?.kind === 'harvest'
|
|
1307
|
+
? model.harvest?.action === 'resume'
|
|
1308
|
+
? DASHBOARD_HARVEST_RESUME_LEGEND
|
|
1309
|
+
: model.harvest?.action === 'acknowledge'
|
|
1310
|
+
? DASHBOARD_HARVEST_ACKNOWLEDGE_LEGEND
|
|
1311
|
+
: DASHBOARD_HARVEST_LEGEND
|
|
1312
|
+
: DASHBOARD_GLOBAL_LEGEND
|
|
1313
|
+
return [truncate(paint(legend, 'dim', color), width)]
|
|
1314
|
+
}
|
|
1315
|
+
return resumePanel(model, color, width, capacity)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function overflowNotice(text: string, color: boolean, width: number): string {
|
|
1319
|
+
return truncate(paint(` ... ${text}`, 'dim', color), width)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
interface RenderedDashboardRow {
|
|
1323
|
+
selection: DashboardSelection
|
|
1324
|
+
lines: string[]
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function flattenRows(rows: readonly RenderedDashboardRow[]): string[] {
|
|
1328
|
+
const lines: string[] = []
|
|
1329
|
+
for (const [index, row] of rows.entries()) {
|
|
1330
|
+
if (index > 0) lines.push('')
|
|
1331
|
+
lines.push(...row.lines)
|
|
1332
|
+
}
|
|
1333
|
+
return lines
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string[] {
|
|
1337
|
+
if (model.view?.kind === 'detail') return renderDetail(model, opts)
|
|
1338
|
+
if (model.view?.kind === 'transcript') return renderTranscript(model, opts)
|
|
1339
|
+
if (model.view?.kind === 'session') return renderSessionView(model, opts)
|
|
1340
|
+
|
|
1341
|
+
const { color, width, height } = opts
|
|
1342
|
+
const selecting = model.selection !== undefined
|
|
1343
|
+
const globalSelection = { kind: 'global' } as const
|
|
1344
|
+
const marker = selectionMarker(sameSelection(globalSelection, model.selection), selecting, color)
|
|
1345
|
+
const intake = model.drained
|
|
1346
|
+
? paint('intake OFF', 'yellow', color)
|
|
1347
|
+
: paint('intake ON', 'green', color)
|
|
1348
|
+
// "default" is implicit at the top level — the header is repo-wide state,
|
|
1349
|
+
// while a build row's own `auto merge` token is that build's setting.
|
|
1350
|
+
const autoMergeDefault = model.defaultAutoMerge
|
|
1351
|
+
? paint('auto merge ON', 'green', color)
|
|
1352
|
+
: paint('auto merge OFF', 'yellow', color)
|
|
1353
|
+
const harvestGate = model.harvestPaused
|
|
1354
|
+
? paint('harvest OFF', 'yellow', color)
|
|
1355
|
+
: paint('harvest ON', 'green', color)
|
|
1356
|
+
const repositoryPause = model.repositoryPaused
|
|
1357
|
+
? paint('repository PAUSED', 'yellow', color)
|
|
1358
|
+
: undefined
|
|
1359
|
+
const summary = truncate(
|
|
1360
|
+
`${marker}${[
|
|
1361
|
+
paint('Autobuild', 'bold', color),
|
|
1362
|
+
displayText(basename(model.repo)),
|
|
1363
|
+
paint(
|
|
1364
|
+
`queue ${model.queued} | active ${model.active.current}/${model.active.limit} | ` +
|
|
1365
|
+
`observations ${model.observations.current}/${model.observations.limit}`,
|
|
1366
|
+
'dim',
|
|
1367
|
+
color,
|
|
1368
|
+
),
|
|
1369
|
+
].join(' ')}`,
|
|
1370
|
+
width,
|
|
1371
|
+
)
|
|
1372
|
+
// The global controls live on their own mandatory line. Its fixed blank
|
|
1373
|
+
// marker prefix aligns the first toggle with the title while keeping the
|
|
1374
|
+
// selection lane empty.
|
|
1375
|
+
const toggles = truncate(
|
|
1376
|
+
` ${[intake, autoMergeDefault, harvestGate, repositoryPause]
|
|
1377
|
+
.filter((token): token is string => token !== undefined)
|
|
1378
|
+
.join(' ')}`,
|
|
1379
|
+
width,
|
|
1380
|
+
)
|
|
1381
|
+
const upgradeNotice = upgradeNoticeLine(model, color, width)
|
|
1382
|
+
// Warnings are conditional chrome, not a reserved log slot. Each notice is
|
|
1383
|
+
// capped to the configured three-row content preview, followed by the
|
|
1384
|
+
// overflow/count notice when content remains; notices are not dropped by
|
|
1385
|
+
// count. The resulting rows join `top`, so `top.slice` limits what short
|
|
1386
|
+
// frames paint and `controlsCapacity` budgets proportionally fewer controls
|
|
1387
|
+
// rows as warnings increase `top.length`.
|
|
1388
|
+
const warnings = warningRows(model.warningLines ?? [], width)
|
|
1389
|
+
const top = [
|
|
1390
|
+
summary,
|
|
1391
|
+
toggles,
|
|
1392
|
+
...(upgradeNotice === undefined ? [] : [upgradeNotice]),
|
|
1393
|
+
...warnings,
|
|
1394
|
+
]
|
|
1395
|
+
// Both structural separators are fixed frame chrome: one between the
|
|
1396
|
+
// completed top section and body, and one between body and controls.
|
|
1397
|
+
const separatorCount = 2
|
|
1398
|
+
const controls = dashboardControls(
|
|
1399
|
+
model,
|
|
1400
|
+
color,
|
|
1401
|
+
width,
|
|
1402
|
+
controlsCapacity(height, top.length, separatorCount),
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
// No paintable height at all (a 1-row screen — see `paintableRows`): paint
|
|
1406
|
+
// nothing. Its trailing newline would scroll a single line off even on the
|
|
1407
|
+
// alternate display, which is worse than an empty region. The two mandatory
|
|
1408
|
+
// header lines outrank warning/body/controls as height disappears.
|
|
1409
|
+
if (height !== undefined && height <= 0) return []
|
|
1410
|
+
if (height !== undefined && height <= top.length) return top.slice(0, height)
|
|
1411
|
+
// Once the complete top section fits, retain controls before spending rows
|
|
1412
|
+
// on body content. Chrome-only remainder is spent on the two structural
|
|
1413
|
+
// separators; a visible body is not admitted until both gaps fit.
|
|
1414
|
+
if (height !== undefined && height <= top.length + separatorCount + controls.length) {
|
|
1415
|
+
const available = height - top.length
|
|
1416
|
+
if (available <= controls.length) return [...top, ...controls.slice(0, available)]
|
|
1417
|
+
return [...top, ...Array.from({ length: available - controls.length }, () => ''), ...controls]
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const widths = frameWidths(model.builds, model.harvest)
|
|
1421
|
+
const rows: RenderedDashboardRow[] = dashboardSelections(model).flatMap(
|
|
1422
|
+
(selection): RenderedDashboardRow[] => {
|
|
1423
|
+
// The global selection is fixed frame chrome above the body. It still
|
|
1424
|
+
// participates in navigation/reconciliation, but never enters viewport
|
|
1425
|
+
// clamping because its header is always visible.
|
|
1426
|
+
if (selection.kind === 'global') return []
|
|
1427
|
+
if (selection.kind === 'harvest') {
|
|
1428
|
+
return [
|
|
1429
|
+
{
|
|
1430
|
+
selection,
|
|
1431
|
+
lines: renderHarvest(
|
|
1432
|
+
model.harvest!,
|
|
1433
|
+
opts,
|
|
1434
|
+
widths,
|
|
1435
|
+
sameSelection(selection, model.selection),
|
|
1436
|
+
selecting,
|
|
1437
|
+
),
|
|
1438
|
+
},
|
|
1439
|
+
]
|
|
1440
|
+
}
|
|
1441
|
+
const build = model.builds.find((candidate) => candidate.slug === selection.slug)!
|
|
1442
|
+
return [
|
|
1443
|
+
{
|
|
1444
|
+
selection,
|
|
1445
|
+
lines: renderBuild(
|
|
1446
|
+
build,
|
|
1447
|
+
model.repositoryPaused,
|
|
1448
|
+
opts,
|
|
1449
|
+
widths,
|
|
1450
|
+
sameSelection(selection, model.selection),
|
|
1451
|
+
selecting,
|
|
1452
|
+
),
|
|
1453
|
+
},
|
|
1454
|
+
]
|
|
1455
|
+
},
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
// Both frame composers derive their body budget from `controls.length`, so a
|
|
1459
|
+
// one-line legend reduces to exactly today's arithmetic and a prompt panel
|
|
1460
|
+
// grows into rows the body gives up.
|
|
1461
|
+
const bodyBudget =
|
|
1462
|
+
height === undefined
|
|
1463
|
+
? Number.POSITIVE_INFINITY
|
|
1464
|
+
: Math.max(0, height - top.length - separatorCount - controls.length)
|
|
1465
|
+
let body: string[]
|
|
1466
|
+
if (rows.length === 0) {
|
|
1467
|
+
body = bodyBudget >= 1 ? [truncate(paint(' no active builds', 'dim', color), width)] : []
|
|
1468
|
+
} else {
|
|
1469
|
+
const allRows = flattenRows(rows)
|
|
1470
|
+
if (allRows.length <= bodyBudget) {
|
|
1471
|
+
body = allRows
|
|
1472
|
+
} else if (bodyBudget <= 0) {
|
|
1473
|
+
body = []
|
|
1474
|
+
} else {
|
|
1475
|
+
const selectedIndex = Math.max(
|
|
1476
|
+
0,
|
|
1477
|
+
rows.findIndex((row) => sameSelection(row.selection, model.selection)),
|
|
1478
|
+
)
|
|
1479
|
+
const prefix = [0]
|
|
1480
|
+
for (const row of rows) prefix.push(prefix.at(-1)! + row.lines.length)
|
|
1481
|
+
|
|
1482
|
+
// Brute force is intentional and tiny (dashboard row counts are bounded
|
|
1483
|
+
// by operator workload): choose the largest contiguous whole-row window
|
|
1484
|
+
// containing selection, with explicit omission notices. Harvest and
|
|
1485
|
+
// builds participate identically.
|
|
1486
|
+
let best: { start: number; end: number; count: number; used: number } | undefined
|
|
1487
|
+
for (let start = 0; start <= selectedIndex; start += 1) {
|
|
1488
|
+
for (let end = selectedIndex; end < rows.length; end += 1) {
|
|
1489
|
+
const rowLines = prefix[end + 1]! - prefix[start]! + (end - start)
|
|
1490
|
+
const notices = (start > 0 ? 1 : 0) + (end < rows.length - 1 ? 1 : 0)
|
|
1491
|
+
const used = rowLines + notices
|
|
1492
|
+
if (used > bodyBudget) continue
|
|
1493
|
+
const count = end - start + 1
|
|
1494
|
+
if (
|
|
1495
|
+
best === undefined ||
|
|
1496
|
+
count > best.count ||
|
|
1497
|
+
(count === best.count && used > best.used)
|
|
1498
|
+
) {
|
|
1499
|
+
best = { start, end, count, used }
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (best !== undefined) {
|
|
1505
|
+
body = []
|
|
1506
|
+
if (best.start > 0) {
|
|
1507
|
+
body.push(overflowNotice(`${best.start} more above`, color, width))
|
|
1508
|
+
}
|
|
1509
|
+
body.push(...flattenRows(rows.slice(best.start, best.end + 1)))
|
|
1510
|
+
const below = rows.length - best.end - 1
|
|
1511
|
+
if (below > 0) {
|
|
1512
|
+
body.push(overflowNotice(`and ${below} more below`, color, width))
|
|
1513
|
+
}
|
|
1514
|
+
} else {
|
|
1515
|
+
// A detailed selected row itself cannot fit. Keep its selectable
|
|
1516
|
+
// identity visible, then spend remaining rows on detail and omission.
|
|
1517
|
+
const selectedLines = rows[selectedIndex]!.lines
|
|
1518
|
+
const above = selectedIndex
|
|
1519
|
+
const below = rows.length - selectedIndex - 1
|
|
1520
|
+
let noticeLines = 0
|
|
1521
|
+
if (bodyBudget > 1 && (above > 0 || below > 0)) {
|
|
1522
|
+
noticeLines = bodyBudget > 2 && above > 0 && below > 0 ? 2 : 1
|
|
1523
|
+
}
|
|
1524
|
+
const detailCapacity = Math.max(1, bodyBudget - noticeLines)
|
|
1525
|
+
body = []
|
|
1526
|
+
if (noticeLines === 2 && above > 0) {
|
|
1527
|
+
body.push(overflowNotice(`${above} more above`, color, width))
|
|
1528
|
+
}
|
|
1529
|
+
body.push(...selectedLines.slice(0, detailCapacity))
|
|
1530
|
+
if (noticeLines === 2 && below > 0) {
|
|
1531
|
+
body.push(overflowNotice(`and ${below} more below`, color, width))
|
|
1532
|
+
} else if (noticeLines === 1) {
|
|
1533
|
+
body.push(
|
|
1534
|
+
overflowNotice(
|
|
1535
|
+
above > 0 && below > 0
|
|
1536
|
+
? `${above} above, ${below} below`
|
|
1537
|
+
: above > 0
|
|
1538
|
+
? `${above} more above`
|
|
1539
|
+
: `and ${below} more below`,
|
|
1540
|
+
color,
|
|
1541
|
+
width,
|
|
1542
|
+
),
|
|
1543
|
+
)
|
|
1544
|
+
}
|
|
1545
|
+
body = body.slice(0, bodyBudget)
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Both blanks are structural frame chrome, independent of warning/upgrade
|
|
1551
|
+
// content, and are charged exactly once in the body budget above.
|
|
1552
|
+
return [...top, '', ...body, '', ...controls]
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** Render the dashboard inside a fixed one-column horizontal gutter.
|
|
1556
|
+
*
|
|
1557
|
+
* All composition happens against the inset content width so wrapping,
|
|
1558
|
+
* truncation, and right-pinning share one geometry. Prefixing only after the
|
|
1559
|
+
* frame is composed preserves its logical line count (including truly blank
|
|
1560
|
+
* separators) and therefore leaves vertical budgeting unchanged. */
|
|
1561
|
+
export function renderDashboard(model: DashboardModel, opts: RenderOpts): string[] {
|
|
1562
|
+
const terminalWidth = Math.max(0, opts.width)
|
|
1563
|
+
const contentWidth = dashboardContentWidth(terminalWidth)
|
|
1564
|
+
const lines = renderDashboardContent(model, { ...opts, width: contentWidth })
|
|
1565
|
+
const leftGutter = terminalWidth > 0 ? ' ' : ''
|
|
1566
|
+
|
|
1567
|
+
return lines.map((line) => (line === '' ? '' : `${leftGutter}${line}`))
|
|
1568
|
+
}
|