opencode-clm 0.1.0 → 0.2.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/NOTICE +9 -1
- package/README.md +85 -258
- package/index.ts +68 -14
- package/package.json +54 -9
- package/src/atomic.ts +27 -0
- package/src/budget.ts +129 -5
- package/src/clm.ts +374 -20
- package/src/commands.ts +18 -0
- package/src/context-document.ts +31 -4
- package/src/mirror-store.ts +1 -1
- package/src/overrides.ts +193 -0
- package/src/panel/command.ts +69 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +103 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +547 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +454 -0
- package/src/panel/view.ts +545 -0
- package/src/presentation.ts +10 -1
- package/src/session-files.ts +184 -0
- package/src/settings-table.ts +406 -0
- package/src/settings.ts +2 -2
- package/src/state.ts +49 -2
- package/src/tui/data.ts +161 -0
- package/src/tui/intercept.ts +56 -0
- package/src/tui/panel.ts +194 -0
- package/src/tui/plugin.ts +280 -0
- package/tui.ts +7 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// Adapted from pi-clm src/timeline.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
2
|
+
//
|
|
3
|
+
// Context-size timeline for the `/clm` overview page: one point per request, markers where
|
|
4
|
+
// an edit was accepted or rejected, the projection reset, or OpenCode compacted. pi builds
|
|
5
|
+
// the timeline from session entries; here model.ts builds it from events.jsonl, so this
|
|
6
|
+
// module holds only the shapes, the column layout and the chart. The chart returns styled
|
|
7
|
+
// lines (tones, see lines.ts) instead of strings run through style hooks.
|
|
8
|
+
|
|
9
|
+
import { normalize, span, type Line, type Span, type Tone } from "./lines.ts";
|
|
10
|
+
|
|
11
|
+
export interface TimelinePoint {
|
|
12
|
+
/** 1-based request number in this session. */
|
|
13
|
+
request: number;
|
|
14
|
+
at?: string;
|
|
15
|
+
/** Context size of this request: provider-reported when `measured`, else the CLM estimate. */
|
|
16
|
+
tokens: number;
|
|
17
|
+
measured: boolean;
|
|
18
|
+
/** Live-context revision active when the request was sent. */
|
|
19
|
+
revision: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MarkerKind = "applied" | "rejected" | "reset" | "compacted";
|
|
23
|
+
|
|
24
|
+
export interface TimelineMarker {
|
|
25
|
+
kind: MarkerKind;
|
|
26
|
+
/** Revision in force after the marker. */
|
|
27
|
+
revision: number;
|
|
28
|
+
at?: string;
|
|
29
|
+
/** Index into `points` of the request after which this marker was recorded (−1 = before any). */
|
|
30
|
+
afterPoint: number;
|
|
31
|
+
beforeTokens?: number;
|
|
32
|
+
afterTokens?: number;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ContextTimeline {
|
|
37
|
+
points: TimelinePoint[];
|
|
38
|
+
markers: TimelineMarker[];
|
|
39
|
+
peakTokens: number;
|
|
40
|
+
/** Indices into `points` of the first request after each user message (turn landmarks). */
|
|
41
|
+
turnStarts: number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatTokenCount(value: number): string {
|
|
45
|
+
if (value < 1_000) return String(Math.round(value));
|
|
46
|
+
if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`;
|
|
47
|
+
if (value < 1_000_000) return `${Math.round(value / 1_000)}k`;
|
|
48
|
+
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How requests map to columns.
|
|
53
|
+
* - `fit`: the whole session, consecutive requests bucketed to the width (default).
|
|
54
|
+
* - `requests`: one column per request; a window around the focus when the session is wider.
|
|
55
|
+
* - `turns`: one column per user turn, consecutive turns grouped when there are too many.
|
|
56
|
+
* Wall-clock buckets are not offered: idle gaps would become empty space and bursts of tool
|
|
57
|
+
* calls would collapse into one bar.
|
|
58
|
+
*/
|
|
59
|
+
export type TimelineZoom = "fit" | "requests" | "turns";
|
|
60
|
+
export const TIMELINE_ZOOMS: readonly TimelineZoom[] = ["fit", "requests", "turns"];
|
|
61
|
+
|
|
62
|
+
export interface TimelineChartOptions {
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
/** Default `requests`, as in pi; the panel passes its zoom state (default `fit`). */
|
|
66
|
+
zoom?: TimelineZoom;
|
|
67
|
+
/** Budget drawn as a dashed row when it is within 2× the peak. */
|
|
68
|
+
budget?: number;
|
|
69
|
+
/** Index into `markers` to highlight. */
|
|
70
|
+
selectedMarker?: number;
|
|
71
|
+
/** Index into `points` whose column is highlighted (the panel's "now"). Wins over `selectedMarker`. */
|
|
72
|
+
selectedPoint?: number;
|
|
73
|
+
/** Index into `points` kept visible in `requests` zoom (window centred on it). */
|
|
74
|
+
focusPoint?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Tones of the chart parts: grey context, blue edits, white selection, yellow budget. */
|
|
78
|
+
const STYLE = {
|
|
79
|
+
bar: { tone: "muted" },
|
|
80
|
+
editBar: { tone: "edit" },
|
|
81
|
+
marker: { tone: "edit" },
|
|
82
|
+
budget: { tone: "warning" },
|
|
83
|
+
axis: { tone: "dim" },
|
|
84
|
+
selected: { tone: "text", bold: true },
|
|
85
|
+
selectedMarker: { tone: "edit", bold: true },
|
|
86
|
+
landmark: { tone: "dim" },
|
|
87
|
+
} as const satisfies Record<string, { tone: Tone; bold?: boolean }>;
|
|
88
|
+
|
|
89
|
+
function styled(text: string, style: { tone: Tone; bold?: boolean }): Span {
|
|
90
|
+
return span(text, style.tone, "bold" in style ? style.bold : undefined);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The window of consecutive requests to draw: at most `columns`, centred on `focus` and
|
|
95
|
+
* clamped so it stays full (a focus near the newest request keeps the newest at the right edge).
|
|
96
|
+
*/
|
|
97
|
+
export function timelineWindow(pointCount: number, columns: number, focus: number | undefined): { start: number; end: number } {
|
|
98
|
+
const size = Math.max(1, Math.min(columns, pointCount));
|
|
99
|
+
if (pointCount <= size) return { start: 0, end: pointCount };
|
|
100
|
+
let start = pointCount - size;
|
|
101
|
+
if (focus !== undefined && focus >= 0 && focus < pointCount) {
|
|
102
|
+
start = Math.max(0, Math.min(pointCount - size, focus - Math.floor(size / 2)));
|
|
103
|
+
}
|
|
104
|
+
return { start, end: start + size };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TimelineBucket {
|
|
108
|
+
/** Inclusive range of indices into `points`. */
|
|
109
|
+
first: number;
|
|
110
|
+
last: number;
|
|
111
|
+
/** Peak size in the bucket (keeps spikes visible). */
|
|
112
|
+
tokens: number;
|
|
113
|
+
/** Size of the bucket's last request. */
|
|
114
|
+
finalTokens: number;
|
|
115
|
+
/** User turns that start in this bucket. */
|
|
116
|
+
turns: number;
|
|
117
|
+
/** Indices into `markers` of accepted edits recorded after a request in this bucket. */
|
|
118
|
+
edits: number[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TimelineLayout {
|
|
122
|
+
zoom: TimelineZoom;
|
|
123
|
+
buckets: TimelineBucket[];
|
|
124
|
+
columnWidth: number;
|
|
125
|
+
gap: number;
|
|
126
|
+
/** Largest number of requests in one bucket. */
|
|
127
|
+
requestsPerColumn: number;
|
|
128
|
+
/** Turns per column in `turns` zoom. */
|
|
129
|
+
turnsPerColumn: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Cells before the axis, e.g. ` 32.0k `. */
|
|
133
|
+
export const CHART_LABEL_WIDTH = 7;
|
|
134
|
+
|
|
135
|
+
function plotWidthFor(width: number): number {
|
|
136
|
+
return Math.max(8, width - CHART_LABEL_WIDTH - 1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Few buckets: up to 3 cells per column plus a gap; many: one cell each. */
|
|
140
|
+
function columnGeometry(bucketCount: number, plotWidth: number): { columnWidth: number; gap: number; columns: number } {
|
|
141
|
+
const columnWidth = Math.max(1, Math.min(3, Math.floor(plotWidth / Math.max(1, bucketCount)) - 1));
|
|
142
|
+
const gap = bucketCount * (columnWidth + 1) <= plotWidth && columnWidth > 1 ? 1 : 0;
|
|
143
|
+
return { columnWidth, gap, columns: Math.max(1, Math.floor(plotWidth / (columnWidth + gap))) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Start indices of user-turn groups; requests before the first user message form group 0. */
|
|
147
|
+
function turnGroupStarts(timeline: ContextTimeline): number[] {
|
|
148
|
+
const starts = new Set(timeline.turnStarts);
|
|
149
|
+
starts.add(0);
|
|
150
|
+
return [...starts].filter((index) => index >= 0 && index < timeline.points.length).sort((a, b) => a - b);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bucketOf(timeline: ContextTimeline, first: number, last: number, turnStarts: ReadonlySet<number>): TimelineBucket {
|
|
154
|
+
let tokens = 0;
|
|
155
|
+
let turns = 0;
|
|
156
|
+
for (let index = first; index <= last; index++) {
|
|
157
|
+
tokens = Math.max(tokens, timeline.points[index]?.tokens ?? 0);
|
|
158
|
+
if (turnStarts.has(index)) turns++;
|
|
159
|
+
}
|
|
160
|
+
return { first, last, tokens, finalTokens: timeline.points[last]?.tokens ?? 0, turns, edits: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The columns a chart of `width` shows for `zoom`; shared by the chart and the panel's detail line. */
|
|
164
|
+
export function layoutTimeline(
|
|
165
|
+
timeline: ContextTimeline,
|
|
166
|
+
options: Pick<TimelineChartOptions, "width" | "zoom" | "focusPoint">,
|
|
167
|
+
): TimelineLayout {
|
|
168
|
+
const zoom = options.zoom ?? "requests";
|
|
169
|
+
const count = timeline.points.length;
|
|
170
|
+
const plotWidth = plotWidthFor(options.width);
|
|
171
|
+
const turnStarts = new Set(timeline.turnStarts);
|
|
172
|
+
const ranges: Array<[number, number]> = [];
|
|
173
|
+
let requestsPerColumn = 1;
|
|
174
|
+
let turnsPerColumn = 1;
|
|
175
|
+
switch (zoom) {
|
|
176
|
+
case "requests": {
|
|
177
|
+
const { columns } = columnGeometry(count, plotWidth);
|
|
178
|
+
const window = timelineWindow(count, columns, options.focusPoint);
|
|
179
|
+
for (let index = window.start; index < window.end; index++) ranges.push([index, index]);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "fit": {
|
|
183
|
+
requestsPerColumn = Math.max(1, Math.ceil(count / plotWidth));
|
|
184
|
+
for (let first = 0; first < count; first += requestsPerColumn) {
|
|
185
|
+
ranges.push([first, Math.min(count, first + requestsPerColumn) - 1]);
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "turns": {
|
|
190
|
+
const starts = turnGroupStarts(timeline);
|
|
191
|
+
turnsPerColumn = Math.max(1, Math.ceil(starts.length / plotWidth));
|
|
192
|
+
for (let group = 0; group < starts.length; group += turnsPerColumn) {
|
|
193
|
+
const next = starts[group + turnsPerColumn];
|
|
194
|
+
ranges.push([starts[group]!, (next ?? count) - 1]);
|
|
195
|
+
}
|
|
196
|
+
requestsPerColumn = ranges.reduce((most, [first, last]) => Math.max(most, last - first + 1), 1);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const buckets = ranges.map(([first, last]) => bucketOf(timeline, first, last, turnStarts));
|
|
201
|
+
timeline.markers.forEach((marker, index) => {
|
|
202
|
+
if (marker.kind !== "applied") return;
|
|
203
|
+
const column = bucketIndexIn(buckets, marker.afterPoint);
|
|
204
|
+
if (column >= 0) buckets[column]!.edits.push(index);
|
|
205
|
+
});
|
|
206
|
+
const { columnWidth, gap } = columnGeometry(buckets.length, plotWidth);
|
|
207
|
+
return { zoom, buckets, columnWidth, gap, requestsPerColumn, turnsPerColumn };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function bucketIndexIn(buckets: readonly TimelineBucket[], point: number | undefined): number {
|
|
211
|
+
if (point === undefined || point < 0) return -1;
|
|
212
|
+
return buckets.findIndex((bucket) => point >= bucket.first && point <= bucket.last);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Index of the bucket holding `point`, or −1 (e.g. panned out of view in `requests` zoom). */
|
|
216
|
+
export function bucketIndexOf(layout: TimelineLayout, point: number | undefined): number {
|
|
217
|
+
return bucketIndexIn(layout.buckets, point);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Zooms worth offering at this width. When every request already has its own column, `fit`
|
|
222
|
+
* is the per-request view, so `requests` is dropped.
|
|
223
|
+
*/
|
|
224
|
+
export function availableTimelineZooms(timeline: ContextTimeline, width: number): TimelineZoom[] {
|
|
225
|
+
const fitsWidth = timeline.points.length <= plotWidthFor(width);
|
|
226
|
+
return TIMELINE_ZOOMS.filter((zoom) => !(fitsWidth && zoom === "requests"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `hh:mm` in the local time zone, or undefined for a missing or invalid time. */
|
|
230
|
+
export function formatClockTime(at: string | undefined): string | undefined {
|
|
231
|
+
if (!at) return undefined;
|
|
232
|
+
const date = new Date(at);
|
|
233
|
+
return Number.isNaN(date.valueOf()) ? undefined : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatDuration(milliseconds: number): string {
|
|
237
|
+
const minutes = Math.round(milliseconds / 60_000);
|
|
238
|
+
if (minutes < 1) return "<1 min";
|
|
239
|
+
if (minutes < 60) return `${minutes} min`;
|
|
240
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function timeSpan(timeline: ContextTimeline, first: number, last: number): string | undefined {
|
|
244
|
+
const start = timeline.points[first]?.at;
|
|
245
|
+
const end = timeline.points[last]?.at;
|
|
246
|
+
const from = formatClockTime(start);
|
|
247
|
+
const to = formatClockTime(end);
|
|
248
|
+
if (!from) return undefined;
|
|
249
|
+
if (first === last || !to || !start || !end) return from;
|
|
250
|
+
return `${from}–${to} (${formatDuration(new Date(end).valueOf() - new Date(start).valueOf())})`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Clock span a column covers, e.g. `11:44–11:53 (9 min)`, or the time of one request. */
|
|
254
|
+
export function formatBucketSpan(timeline: ContextTimeline, bucket: TimelineBucket): string | undefined {
|
|
255
|
+
return timeSpan(timeline, bucket.first, bucket.last);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One-line description of a column: range, time, peak/final size, turns, edits. `~` marks estimates. */
|
|
259
|
+
export function describeTimelineBucket(timeline: ContextTimeline, bucket: TimelineBucket): string {
|
|
260
|
+
const first = timeline.points[bucket.first];
|
|
261
|
+
const last = timeline.points[bucket.last];
|
|
262
|
+
if (!first || !last) return "";
|
|
263
|
+
const parts = bucket.first === bucket.last
|
|
264
|
+
? [`request ${first.request}`, `${first.measured ? "" : "~"}${formatTokenCount(bucket.tokens)} tok`]
|
|
265
|
+
: [
|
|
266
|
+
`requests ${first.request}–${last.request} (${bucket.last - bucket.first + 1})`,
|
|
267
|
+
`peak ${formatTokenCount(bucket.tokens)} · last ${last.measured ? "" : "~"}${formatTokenCount(bucket.finalTokens)} tok`,
|
|
268
|
+
];
|
|
269
|
+
const span = timeSpan(timeline, bucket.first, bucket.last);
|
|
270
|
+
if (span) parts.splice(1, 0, span);
|
|
271
|
+
if (bucket.turns > 0) parts.push(`${bucket.turns} user turn${bucket.turns === 1 ? "" : "s"}`);
|
|
272
|
+
if (bucket.edits.length > 0) parts.push(`${bucket.edits.length} accepted edit${bucket.edits.length === 1 ? "" : "s"}`);
|
|
273
|
+
return parts.join(" · ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Vertical bar chart, one column per request or bucket. Bars show the bucket's peak;
|
|
278
|
+
* columns followed by an accepted edit use `▓` and get a marker above the bar (`▿`, `▼`
|
|
279
|
+
* when selected, a digit when several edits share the column). User turns are `•` on the
|
|
280
|
+
* baseline; the budget, when near the data, is a dashed row with `┼` on the axis.
|
|
281
|
+
*/
|
|
282
|
+
export function renderTimelineChart(timeline: ContextTimeline, options: TimelineChartOptions): Line[] {
|
|
283
|
+
if (timeline.points.length === 0) return [[span("No completed requests in this session yet.", "muted")]];
|
|
284
|
+
const height = Math.max(4, options.height);
|
|
285
|
+
const layout = layoutTimeline(timeline, options);
|
|
286
|
+
const { buckets, columnWidth, gap } = layout;
|
|
287
|
+
const blank = (cells: number): Span => span(" ".repeat(cells));
|
|
288
|
+
const cell = (glyph: string, style: { tone: Tone; bold?: boolean }): Span[] =>
|
|
289
|
+
gap > 0 ? [styled(glyph.repeat(columnWidth), style), blank(gap)] : [styled(glyph.repeat(columnWidth), style)];
|
|
290
|
+
|
|
291
|
+
// A budget far above the data (a 272k window against 10k requests) would flatten every
|
|
292
|
+
// bar, so it is drawn only within 2× the peak and otherwise named in the x label.
|
|
293
|
+
const budgetOnChart = options.budget !== undefined && options.budget <= Math.max(1, timeline.peakTokens) * 2;
|
|
294
|
+
const ceiling = Math.max(timeline.peakTokens, budgetOnChart ? options.budget ?? 0 : 0, 1);
|
|
295
|
+
const selectedMarker = options.selectedMarker !== undefined ? timeline.markers[options.selectedMarker] : undefined;
|
|
296
|
+
let selectedColumn = selectedMarker?.kind === "applied" ? bucketIndexOf(layout, selectedMarker.afterPoint) : -1;
|
|
297
|
+
if (options.selectedPoint !== undefined) selectedColumn = bucketIndexOf(layout, options.selectedPoint);
|
|
298
|
+
const budgetRow = budgetOnChart && options.budget !== undefined
|
|
299
|
+
? height - 1 - Math.min(height - 1, Math.round((options.budget / ceiling) * (height - 1)))
|
|
300
|
+
: undefined;
|
|
301
|
+
|
|
302
|
+
const filledAt = (bucket: TimelineBucket, row: number) =>
|
|
303
|
+
bucket.tokens >= ((height - row) / height) * ceiling - ceiling / height / 2 || (row === height - 1 && bucket.tokens > 0);
|
|
304
|
+
const topRows = buckets.map((bucket) => {
|
|
305
|
+
for (let row = 0; row < height; row++) if (filledAt(bucket, row)) return row;
|
|
306
|
+
return height;
|
|
307
|
+
});
|
|
308
|
+
const markerSelected = (column: number) => column === selectedColumn && selectedMarker !== undefined;
|
|
309
|
+
// A shared column keeps its count when selected; selection shows through the style.
|
|
310
|
+
const markerGlyph = (bucket: TimelineBucket, column: number) =>
|
|
311
|
+
bucket.edits.length > 1
|
|
312
|
+
? (bucket.edits.length > 9 ? "+" : String(bucket.edits.length))
|
|
313
|
+
: markerSelected(column) ? "▼" : "▿";
|
|
314
|
+
const markerCell = (bucket: TimelineBucket, column: number): Span[] => {
|
|
315
|
+
const text = markerGlyph(bucket, column).padStart(Math.ceil((columnWidth + 1) / 2)).padEnd(columnWidth);
|
|
316
|
+
const out = [styled(text, markerSelected(column) ? STYLE.selectedMarker : STYLE.marker)];
|
|
317
|
+
if (gap > 0) out.push(blank(gap));
|
|
318
|
+
return out;
|
|
319
|
+
};
|
|
320
|
+
// The marker sits in the empty cell above its bar; a bar that reaches the top needs a headroom row.
|
|
321
|
+
const markerRowOf = (column: number) => (buckets[column]!.edits.length > 0 ? topRows[column]! - 1 : undefined);
|
|
322
|
+
|
|
323
|
+
const rows: Line[] = [];
|
|
324
|
+
if (buckets.some((_bucket, column) => markerRowOf(column) === -1)) {
|
|
325
|
+
const headroom: Span[] = [blank(CHART_LABEL_WIDTH + 1)];
|
|
326
|
+
buckets.forEach((bucket, column) => {
|
|
327
|
+
headroom.push(...(markerRowOf(column) === -1 ? markerCell(bucket, column) : [blank(columnWidth + gap)]));
|
|
328
|
+
});
|
|
329
|
+
rows.push(headroom);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (let row = 0; row < height; row++) {
|
|
333
|
+
let label = " ".repeat(CHART_LABEL_WIDTH);
|
|
334
|
+
if (row === 0) label = formatTokenCount(ceiling).padStart(6) + " ";
|
|
335
|
+
else if (row === Math.floor(height / 2)) label = formatTokenCount(ceiling / 2).padStart(6) + " ";
|
|
336
|
+
const line: Span[] = [styled(label, STYLE.axis), budgetRow === row ? styled("┼", STYLE.budget) : styled("┤", STYLE.axis)];
|
|
337
|
+
buckets.forEach((bucket, column) => {
|
|
338
|
+
if (filledAt(bucket, row)) {
|
|
339
|
+
if (column === selectedColumn) line.push(...cell("█", STYLE.selected));
|
|
340
|
+
else if (bucket.edits.length > 0) line.push(...cell("▓", STYLE.editBar));
|
|
341
|
+
else line.push(...cell("█", STYLE.bar));
|
|
342
|
+
} else if (markerRowOf(column) === row) {
|
|
343
|
+
line.push(...markerCell(bucket, column));
|
|
344
|
+
} else if (budgetRow === row) {
|
|
345
|
+
line.push(styled("╌".repeat(columnWidth + gap), STYLE.budget));
|
|
346
|
+
} else {
|
|
347
|
+
line.push(blank(columnWidth + gap));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
rows.push(line);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Baseline with user-turn landmarks (none in `turns` zoom, where every column is a turn).
|
|
354
|
+
const baseline: Span[] = [styled(" 0 ", STYLE.axis), styled("┴", STYLE.axis)];
|
|
355
|
+
for (const bucket of buckets) {
|
|
356
|
+
if (layout.zoom !== "turns" && bucket.turns > 0) {
|
|
357
|
+
baseline.push(styled("•", STYLE.landmark));
|
|
358
|
+
if (columnWidth - 1 + gap > 0) baseline.push(styled("─".repeat(columnWidth - 1 + gap), STYLE.axis));
|
|
359
|
+
} else {
|
|
360
|
+
baseline.push(styled("─".repeat(columnWidth + gap), STYLE.axis));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
rows.push(baseline);
|
|
364
|
+
|
|
365
|
+
rows.push([blank(CHART_LABEL_WIDTH + 1), styled(xLabel(timeline, layout, options, budgetOnChart), STYLE.axis)]);
|
|
366
|
+
return rows.map(normalize);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function xLabel(timeline: ContextTimeline, layout: TimelineLayout, options: TimelineChartOptions, budgetOnChart: boolean): string {
|
|
370
|
+
const total = timeline.points.length;
|
|
371
|
+
const firstShown = layout.buckets[0]?.first ?? 0;
|
|
372
|
+
const lastShown = layout.buckets.at(-1)?.last ?? total - 1;
|
|
373
|
+
const offChart = options.budget !== undefined && !budgetOnChart ? ` · budget ${formatTokenCount(options.budget)} (above scale)` : "";
|
|
374
|
+
let label: string;
|
|
375
|
+
switch (layout.zoom) {
|
|
376
|
+
case "requests": {
|
|
377
|
+
const before = firstShown;
|
|
378
|
+
const after = total - 1 - lastShown;
|
|
379
|
+
const range = firstShown === lastShown ? `request ${firstShown + 1}` : `requests ${firstShown + 1}–${lastShown + 1}`;
|
|
380
|
+
label = `${before > 0 ? `◂ ${before} earlier ` : ""}${range} of ${total}${after > 0 ? ` ${after} later ▸` : ""}`;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
case "fit":
|
|
384
|
+
label = layout.requestsPerColumn === 1 ? `all ${total} requests` : `all ${total} requests · ${layout.requestsPerColumn}/column`;
|
|
385
|
+
break;
|
|
386
|
+
case "turns": {
|
|
387
|
+
const turns = turnGroupStarts(timeline).length;
|
|
388
|
+
label = `${turns} user turn${turns === 1 ? "" : "s"} · ${layout.turnsPerColumn}/column · ${total} requests`;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (layout.zoom !== "requests") {
|
|
393
|
+
// The time span is extra context: only when the label still fits.
|
|
394
|
+
const span = timeSpan(timeline, 0, total - 1);
|
|
395
|
+
if (span && CHART_LABEL_WIDTH + 1 + `${label} · ${span}${offChart}`.length <= options.width) label += ` · ${span}`;
|
|
396
|
+
}
|
|
397
|
+
return label + offChart;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function changePercent(before: number, after: number): string {
|
|
401
|
+
if (before <= 0) return "";
|
|
402
|
+
return `${after <= before ? "−" : "+"}${Math.abs(Math.round(((before - after) / before) * 100))}%`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** List row for the overview: `r1 11:43 9.5k → 2.9k −69%`. */
|
|
406
|
+
export function formatMarkerCompact(marker: TimelineMarker): string {
|
|
407
|
+
const time = formatClockTime(marker.at) ?? "--:--";
|
|
408
|
+
switch (marker.kind) {
|
|
409
|
+
case "reset":
|
|
410
|
+
return `r${marker.revision} ${time} reset to raw context`;
|
|
411
|
+
case "compacted":
|
|
412
|
+
return `r${marker.revision} ${time} compacted by OpenCode`;
|
|
413
|
+
case "rejected":
|
|
414
|
+
return `r${marker.revision} ${time} rejected: ${marker.message}`;
|
|
415
|
+
case "applied": {
|
|
416
|
+
const { beforeTokens: before, afterTokens: after } = marker;
|
|
417
|
+
if (before === undefined || after === undefined) return `r${marker.revision} ${time} size not recorded`;
|
|
418
|
+
return `r${marker.revision} ${time} ${formatTokenCount(before).padStart(5)} → ${formatTokenCount(after).padEnd(5)} ${changePercent(before, after)}`.trimEnd();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Long row for the text report: `r3 16:52 edit accepted 9.5k → 2.9k −70% (after request 4)`. */
|
|
424
|
+
export function formatMarkerRow(marker: TimelineMarker, timeline: ContextTimeline): string {
|
|
425
|
+
const time = formatClockTime(marker.at) ?? "--:--";
|
|
426
|
+
const where = marker.afterPoint >= 0
|
|
427
|
+
? `after request ${timeline.points[marker.afterPoint]?.request ?? marker.afterPoint + 1}`
|
|
428
|
+
: "before first request";
|
|
429
|
+
switch (marker.kind) {
|
|
430
|
+
case "applied": {
|
|
431
|
+
const { beforeTokens: before, afterTokens: after } = marker;
|
|
432
|
+
const change = before !== undefined && after !== undefined ? changePercent(before, after) : "";
|
|
433
|
+
const sizes = before !== undefined && after !== undefined
|
|
434
|
+
? `${formatTokenCount(before)} → ${formatTokenCount(after)}${change ? ` ${change}` : ""}`
|
|
435
|
+
: "size not recorded";
|
|
436
|
+
return `r${marker.revision} ${time} edit accepted ${sizes} (${where})`;
|
|
437
|
+
}
|
|
438
|
+
case "reset":
|
|
439
|
+
return `r${marker.revision} ${time} reset to raw context (${where})`;
|
|
440
|
+
case "compacted":
|
|
441
|
+
return `r${marker.revision} ${time} compacted by OpenCode (${where})`;
|
|
442
|
+
case "rejected":
|
|
443
|
+
return `r${marker.revision} ${time} edit rejected: ${marker.message} (${where})`;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Plain-text chart plus marker list, for output outside the TUI. */
|
|
448
|
+
export function formatTimelineReport(timeline: ContextTimeline, width = 72, budget?: number): string {
|
|
449
|
+
const lines = renderTimelineChart(timeline, { width, height: 8, budget }).map((line) => line.map((part) => part.text).join(""));
|
|
450
|
+
const applied = timeline.markers.filter((marker) => marker.kind === "applied").length;
|
|
451
|
+
lines.push("", applied === 0 ? "No accepted context edits yet." : `${applied} accepted context edit${applied === 1 ? "" : "s"}:`);
|
|
452
|
+
for (const marker of timeline.markers) lines.push(` ${formatMarkerRow(marker, timeline)}`);
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|