@promptctl/cc-candybar 1.0.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 +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// [LAW:locality-or-seam] A `{{ picker "applyAction" "pageAction" closeOnPick
|
|
2
|
+
// paged }}` call renders a width-fit grid of option cells over the action table:
|
|
3
|
+
// each option cell APPLIES the named option action (and, when closeOnPick, also
|
|
4
|
+
// resets the named page action's key in the SAME atomic write); ✕/←/→ navigate
|
|
5
|
+
// the page cursor. The picker is a pure RENDER helper — it owns no state and no
|
|
6
|
+
// new gate. It references two ALREADY-declared, ALREADY-gated actions by name
|
|
7
|
+
// (the apply set-option → its option domain + the theme key's allow-list gate;
|
|
8
|
+
// the page set-int → the page key's int gate), so the rendered clicks and the
|
|
9
|
+
// wire gate share one source: the action table.
|
|
10
|
+
//
|
|
11
|
+
// [LAW:one-type-per-behavior] There is no `menu`/`picker` TYPE — a picker is
|
|
12
|
+
// content a segment template pulls in, exactly like `action`. This is the
|
|
13
|
+
// successor to the deleted menu/buttons widget runtime: the same `paginate` fold
|
|
14
|
+
// and ←/→/✕ projection, re-expressed over named actions instead of a widget union.
|
|
15
|
+
//
|
|
16
|
+
// [LAW:dataflow-not-control-flow] Paged vs wrap is ONE value, not a mode: the
|
|
17
|
+
// `paged` flag selects the available width passed to `paginate` (term.cols vs
|
|
18
|
+
// Infinity). Infinite width ⇒ one page ⇒ the long line wraps via FlexStrip; finite
|
|
19
|
+
// ⇒ a sliced page with ←/→. The same fold, same emit pipeline; the width value
|
|
20
|
+
// (and the matching noWrap) select the shape.
|
|
21
|
+
//
|
|
22
|
+
// [LAW:one-way-deps] Lives in render/ (depends on template-engine/ + ./action.js),
|
|
23
|
+
// injected into the engine by the caller (registerDslConfig hands pickerFuncs in
|
|
24
|
+
// as data). The generic engine never imports this module.
|
|
25
|
+
|
|
26
|
+
import { RichText } from "@promptctl/rich-js";
|
|
27
|
+
import type { FuncMap } from "@promptctl/go-template-js";
|
|
28
|
+
import { toNumber } from "../var-system/types.js";
|
|
29
|
+
import { TERM_COLS_VAR } from "../config/dsl-types.js";
|
|
30
|
+
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
31
|
+
import {
|
|
32
|
+
linkFragment,
|
|
33
|
+
readVar,
|
|
34
|
+
type ActionRuntime,
|
|
35
|
+
type CompiledActionDecl,
|
|
36
|
+
} from "./action.js";
|
|
37
|
+
|
|
38
|
+
const PICKER_CLOSE = "✕";
|
|
39
|
+
const PICKER_PREV = "←";
|
|
40
|
+
const PICKER_NEXT = "→";
|
|
41
|
+
|
|
42
|
+
// [LAW:single-enforcer] One display-width measure — rich-js's cellLength, the
|
|
43
|
+
// same algebra FlexStrip wraps by — so pagination fits the line the strip
|
|
44
|
+
// produces. No second width function.
|
|
45
|
+
function cellWidth(text: string): number {
|
|
46
|
+
return new RichText(text).cellLength;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// [LAW:dataflow-not-control-flow] A pure function of (item widths, available
|
|
50
|
+
// width, reserved width): greedy fill into pages, each reserving room for the
|
|
51
|
+
// ←/→/✕ affordances. The `page` value selects the slice; an oversized lone item
|
|
52
|
+
// gets its own page (it can't be split). Infinite width = one page (the wrap
|
|
53
|
+
// case — everything on one line, FlexStrip breaks it). Exported for unit testing.
|
|
54
|
+
export function paginate(
|
|
55
|
+
widths: readonly number[],
|
|
56
|
+
available: number,
|
|
57
|
+
reserve: number,
|
|
58
|
+
): number[][] {
|
|
59
|
+
if (!Number.isFinite(available)) {
|
|
60
|
+
return widths.length > 0 ? [widths.map((_, i) => i)] : [];
|
|
61
|
+
}
|
|
62
|
+
const usable = Math.max(1, available - reserve);
|
|
63
|
+
const pages: number[][] = [];
|
|
64
|
+
let cur: number[] = [];
|
|
65
|
+
let curW = 0;
|
|
66
|
+
for (let i = 0; i < widths.length; i++) {
|
|
67
|
+
const w = widths[i]!;
|
|
68
|
+
if (cur.length === 0) {
|
|
69
|
+
cur = [i];
|
|
70
|
+
curW = w;
|
|
71
|
+
} else if (curW + 1 + w <= usable) {
|
|
72
|
+
cur.push(i);
|
|
73
|
+
curW += 1 + w;
|
|
74
|
+
} else {
|
|
75
|
+
pages.push(cur);
|
|
76
|
+
cur = [i];
|
|
77
|
+
curW = w;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (cur.length > 0) pages.push(cur);
|
|
81
|
+
return pages;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// [LAW:dataflow-not-control-flow] Join link-bearing spans with single-space
|
|
85
|
+
// separators into ONE RichText (a picker is one `{{ picker }}` expression, so it
|
|
86
|
+
// must emit one value; the option/affordance cells ride as spans on it). `noWrap`
|
|
87
|
+
// is the `paged` value: a paged page is one line that must not wrap; a wrap-mode
|
|
88
|
+
// run is the long line FlexStrip is ALLOWED to break across lines.
|
|
89
|
+
function assemble(frags: readonly RichText[], paged: boolean): RichText {
|
|
90
|
+
const spaced: RichText[] = [];
|
|
91
|
+
for (const frag of frags) {
|
|
92
|
+
if (spaced.length > 0) spaced.push(new RichText(" "));
|
|
93
|
+
spaced.push(frag);
|
|
94
|
+
}
|
|
95
|
+
const assembled = RichText.fromFragments(spaced);
|
|
96
|
+
assembled.noWrap = paged;
|
|
97
|
+
assembled.end = "";
|
|
98
|
+
return assembled;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// [LAW:no-defensive-null-guards] The loader proves both picker arg names resolve
|
|
102
|
+
// to declared actions; this asserts the KIND each must be (apply ⇒ set-option,
|
|
103
|
+
// page ⇒ set-int) — a wrong kind is an author error surfaced loudly at render
|
|
104
|
+
// (composeWithDiagnostics shows it), not a silent empty picker.
|
|
105
|
+
function requireKind<K extends CompiledActionDecl["kind"]>(
|
|
106
|
+
runtime: ActionRuntime,
|
|
107
|
+
name: string,
|
|
108
|
+
kind: K,
|
|
109
|
+
shape: string,
|
|
110
|
+
): Extract<CompiledActionDecl, { kind: K }> {
|
|
111
|
+
const action = runtime.compiled.get(name);
|
|
112
|
+
if (!action || action.kind !== kind) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`picker references action "${name}" which must be ${shape}, got ${action ? `a ${action.kind} action` : "no such action"}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return action as Extract<CompiledActionDecl, { kind: K }>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// [LAW:dataflow-not-control-flow] The page value (and the live width) select
|
|
121
|
+
// which option cells render and which boundary arrows exist — a boundary arrow is
|
|
122
|
+
// an ABSENT fragment, never a skipped branch. Every affordance click is a `set`
|
|
123
|
+
// on the page key: ←/→ navigate (render-computed p±1), ✕ closes (-1). Each option
|
|
124
|
+
// click APPLIES its option AND (when closeOnPick) resets the page key to -1 in one
|
|
125
|
+
// atomic set-state — the picker owns the page key, so it derives the close-write
|
|
126
|
+
// rather than the author re-stating the key.
|
|
127
|
+
function renderPicker(
|
|
128
|
+
applyName: string,
|
|
129
|
+
pageName: string,
|
|
130
|
+
closeOnPick: boolean,
|
|
131
|
+
paged: boolean,
|
|
132
|
+
runtime: ActionRuntime,
|
|
133
|
+
): RichText {
|
|
134
|
+
const apply = requireKind(
|
|
135
|
+
runtime,
|
|
136
|
+
applyName,
|
|
137
|
+
"set-option",
|
|
138
|
+
"a set-option action ({ set, from })",
|
|
139
|
+
);
|
|
140
|
+
const page = requireKind(
|
|
141
|
+
runtime,
|
|
142
|
+
pageName,
|
|
143
|
+
"set-int",
|
|
144
|
+
"an int action ({ set, int: true })",
|
|
145
|
+
);
|
|
146
|
+
const store = runtime.store;
|
|
147
|
+
if (!store) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`picker "${applyName}" rendered without a VariableStore — registerDslConfig was not given one`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const sessionId = readVar(store, "session.id");
|
|
153
|
+
const current = readVar(store, apply.stateVar);
|
|
154
|
+
const widths = apply.options.map(cellWidth);
|
|
155
|
+
|
|
156
|
+
// ✕ is always present; ←/→ appear only on a multi-page menu. Reserve arrow
|
|
157
|
+
// space only after a first pass proves it overflows — reserving it
|
|
158
|
+
// unconditionally is self-fulfilling (a run that fits with just ✕ could be
|
|
159
|
+
// forced to split, making arrows appear unnecessarily). In wrap mode
|
|
160
|
+
// (available = Infinity) paginate yields one page, so neither pass splits.
|
|
161
|
+
const available = paged ? toNumber(store.read(TERM_COLS_VAR)) : Infinity;
|
|
162
|
+
const closeReserve = cellWidth(PICKER_CLOSE) + 1;
|
|
163
|
+
const arrowReserve = cellWidth(PICKER_PREV) + 1 + cellWidth(PICKER_NEXT) + 1;
|
|
164
|
+
const firstPass = paginate(widths, available, closeReserve);
|
|
165
|
+
const pages =
|
|
166
|
+
firstPass.length > 1
|
|
167
|
+
? paginate(widths, available, closeReserve + arrowReserve)
|
|
168
|
+
: firstPass;
|
|
169
|
+
|
|
170
|
+
// [LAW:no-defensive-null-guards] The page value genuinely may be absent/empty
|
|
171
|
+
// (the key was never written) — parse it at this trust boundary; an out-of-range
|
|
172
|
+
// or unset value clamps into the existing page set, so the picker never indexes
|
|
173
|
+
// a non-existent page. The segment's `when` gates visibility on page >= 0.
|
|
174
|
+
const rawPage = parseInt(readVar(store, page.stateVar), 10);
|
|
175
|
+
const pageIdx = Number.isInteger(rawPage)
|
|
176
|
+
? Math.max(0, Math.min(rawPage, pages.length - 1))
|
|
177
|
+
: 0;
|
|
178
|
+
const pageCells = pages[pageIdx] ?? [];
|
|
179
|
+
|
|
180
|
+
const pageUrl = (value: number): string =>
|
|
181
|
+
effectsUrl([
|
|
182
|
+
{ verb: VERB_SET_STATE, args: [sessionId, page.key, String(value)] },
|
|
183
|
+
]);
|
|
184
|
+
const optionUrl = (option: string): string =>
|
|
185
|
+
effectsUrl([
|
|
186
|
+
{
|
|
187
|
+
verb: VERB_SET_STATE,
|
|
188
|
+
args: [
|
|
189
|
+
sessionId,
|
|
190
|
+
apply.key,
|
|
191
|
+
option,
|
|
192
|
+
...(closeOnPick ? [page.key, "-1"] : []),
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
const frags: RichText[] = [linkFragment(PICKER_CLOSE, pageUrl(-1), false)];
|
|
198
|
+
if (pageIdx > 0) {
|
|
199
|
+
frags.push(linkFragment(PICKER_PREV, pageUrl(pageIdx - 1), false));
|
|
200
|
+
}
|
|
201
|
+
for (const i of pageCells) {
|
|
202
|
+
const option = apply.options[i]!;
|
|
203
|
+
frags.push(linkFragment(option, optionUrl(option), option === current));
|
|
204
|
+
}
|
|
205
|
+
if (pageIdx < pages.length - 1) {
|
|
206
|
+
frags.push(linkFragment(PICKER_NEXT, pageUrl(pageIdx + 1), false));
|
|
207
|
+
}
|
|
208
|
+
return assemble(frags, paged);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// [LAW:dataflow-not-control-flow] One func; the two action NAMES select which
|
|
212
|
+
// declared effects fire, the two bools are the bounded author choices
|
|
213
|
+
// (closeOnPick, paged). Returns T (RichText), the single fragment go-template-js
|
|
214
|
+
// emits for `{{ picker … }}`.
|
|
215
|
+
//
|
|
216
|
+
// [LAW:one-way-deps] The caller injects this FuncMap into createCcCandybarEngine
|
|
217
|
+
// (capabilities-over-context) so the generic engine never imports the picker.
|
|
218
|
+
export function pickerFuncs(runtime: ActionRuntime): FuncMap {
|
|
219
|
+
return {
|
|
220
|
+
picker: {
|
|
221
|
+
fn: (
|
|
222
|
+
applyName: string,
|
|
223
|
+
pageName: string,
|
|
224
|
+
closeOnPick: boolean,
|
|
225
|
+
paged: boolean,
|
|
226
|
+
) => renderPicker(applyName, pageName, closeOnPick, paged, runtime),
|
|
227
|
+
argTypes: ["string", "string", "bool", "bool"],
|
|
228
|
+
returnType: "T",
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RichText } from "@promptctl/rich-js";
|
|
2
|
+
|
|
3
|
+
// [LAW:single-enforcer] THE one place a horizontal cell stream is partitioned
|
|
4
|
+
// into vertical lines. Vertical composition lives on the CELL stream — resolved
|
|
5
|
+
// here, before any strip measures a cell — never inside a strip. A "\n" that
|
|
6
|
+
// reaches a horizontal strip is a zero-width lie: the strip measures a cell by
|
|
7
|
+
// its rendered cell-width, for which an embedded "\n" corrupts wrap math,
|
|
8
|
+
// powerline caps, and background fill. Splitting first means each emitted line
|
|
9
|
+
// is a clean newline-free cell run the strip can measure honestly.
|
|
10
|
+
//
|
|
11
|
+
// [LAW:one-source-of-truth] The line boundary is the literal "\n" carried in a
|
|
12
|
+
// cell's text — the SOLE vertical sentinel. Horizontal cell identity is already
|
|
13
|
+
// carried by segment boundaries and link spans, so only the vertical axis lacks
|
|
14
|
+
// a carrier and only it needs a sentinel.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Partition a cell stream into per-line cell groups on the "\n" sentinel.
|
|
18
|
+
*
|
|
19
|
+
* A newline-free cell passes through BY REFERENCE — the common case is
|
|
20
|
+
* byte-identical to handing the original stream straight to a strip. A cell
|
|
21
|
+
* carrying "\n" is split via `RichText.split` (span- and OSC-8-preserving, and
|
|
22
|
+
* it consumes the separator): the piece before the first "\n" closes the
|
|
23
|
+
* current line, each interior piece is a whole line of its own, and the final
|
|
24
|
+
* piece begins the next line. The "\n" is consumed as the partition point,
|
|
25
|
+
* never emitted into a line and never measured.
|
|
26
|
+
*
|
|
27
|
+
* An empty input yields `[[]]` — one empty line group, not zero — so a visible
|
|
28
|
+
* row whose segments all rendered nothing still produces exactly one (empty)
|
|
29
|
+
* line, matching the pre-substrate behavior.
|
|
30
|
+
*/
|
|
31
|
+
export function splitCellsIntoLines(cells: readonly RichText[]): RichText[][] {
|
|
32
|
+
const lines: RichText[][] = [];
|
|
33
|
+
let current: RichText[] = [];
|
|
34
|
+
for (const cell of cells) {
|
|
35
|
+
// [LAW:dataflow-not-control-flow] The discriminator is whether the cell
|
|
36
|
+
// carries the sentinel — a value, read once. The newline-free path keeps
|
|
37
|
+
// the original reference so the dominant case allocates nothing new.
|
|
38
|
+
if (!cell.contains("\n")) {
|
|
39
|
+
current.push(cell);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const pieces = cell.split("\n");
|
|
43
|
+
current.push(pieces[0]!);
|
|
44
|
+
for (let i = 1; i < pieces.length; i++) {
|
|
45
|
+
lines.push(current);
|
|
46
|
+
current = [pieces[i]!];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.push(current);
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Strip,
|
|
3
|
+
RichText,
|
|
4
|
+
Style,
|
|
5
|
+
PowerlineJoiner,
|
|
6
|
+
CapsuleJoiner,
|
|
7
|
+
PlainJoiner,
|
|
8
|
+
FlexStrip,
|
|
9
|
+
renderToString,
|
|
10
|
+
type Joiner,
|
|
11
|
+
type ColorSystemSpec,
|
|
12
|
+
} from "@promptctl/rich-js";
|
|
13
|
+
|
|
14
|
+
export interface RenderedSegmentLike {
|
|
15
|
+
type: string;
|
|
16
|
+
text: string;
|
|
17
|
+
bgHex?: string;
|
|
18
|
+
fgHex?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StripStyle = "powerline" | "capsule" | "plain";
|
|
22
|
+
|
|
23
|
+
// [LAW:one-source-of-truth] Raw terminal cols we assume when the wire
|
|
24
|
+
// didn't give us one (older client, env-stripped spawn). RAW — not
|
|
25
|
+
// post-reserve — so the Claude-Code-UI reserve applies uniformly across
|
|
26
|
+
// wire and fallback paths (callers thread this through
|
|
27
|
+
// applyClaudeCodeReserve from src/utils/terminal-width).
|
|
28
|
+
export const DEFAULT_TERMINAL_WIDTH = 120;
|
|
29
|
+
|
|
30
|
+
export interface BuildLineOptions {
|
|
31
|
+
style: StripStyle;
|
|
32
|
+
colorCompatibility: ColorSystemSpec;
|
|
33
|
+
separator?: string;
|
|
34
|
+
// [LAW:types-are-the-program] Every render carries a width. Finite values
|
|
35
|
+
// wrap via FlexStrip; Number.POSITIVE_INFINITY renders one unbounded line.
|
|
36
|
+
// Required (not optional) so callers cannot silently drop the wire's value.
|
|
37
|
+
width: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pickJoiner(style: StripStyle, separator?: string): Joiner {
|
|
41
|
+
// [LAW:dataflow-not-control-flow] joiner choice is data-driven; one branch
|
|
42
|
+
// per shape, no nested conditionals.
|
|
43
|
+
if (style === "capsule") return new CapsuleJoiner();
|
|
44
|
+
if (style === "plain") {
|
|
45
|
+
return new PlainJoiner(separator !== undefined ? { separator } : {});
|
|
46
|
+
}
|
|
47
|
+
return new PowerlineJoiner();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toCell(seg: RenderedSegmentLike): RichText {
|
|
51
|
+
// Padding mirrors the legacy buildLineFromSegments: one space on each side
|
|
52
|
+
// of the segment text. The joiners sit between cells; padding sits inside.
|
|
53
|
+
const padded = ` ${seg.text} `;
|
|
54
|
+
const style = new Style({
|
|
55
|
+
bgcolor: seg.bgHex || undefined,
|
|
56
|
+
color: seg.fgHex || undefined,
|
|
57
|
+
});
|
|
58
|
+
return new RichText(padded, { style, end: "", noWrap: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* [LAW:single-enforcer] The one place RichText cells become an ANSI byte
|
|
63
|
+
* string. Every render path (DSL RichText[] via the template-engine
|
|
64
|
+
* pipeline, buildLineStrip's input-shape adapter, debug per-segment
|
|
65
|
+
* serialization) flows through here. The wrap dispatch lives here too:
|
|
66
|
+
* finite width → FlexStrip (rich-js owns the wrap algebra); infinite
|
|
67
|
+
* width → Strip.
|
|
68
|
+
*
|
|
69
|
+
* [LAW:dataflow-not-control-flow] The dispatch is on the value, not on a
|
|
70
|
+
* flag. There is no `wrap: boolean` knob; presence of a finite width IS
|
|
71
|
+
* the decision.
|
|
72
|
+
*/
|
|
73
|
+
export function renderStripCells(
|
|
74
|
+
cells: readonly RichText[],
|
|
75
|
+
options: BuildLineOptions,
|
|
76
|
+
): string {
|
|
77
|
+
if (cells.length === 0) return "";
|
|
78
|
+
const joiner = pickJoiner(options.style, options.separator);
|
|
79
|
+
if (Number.isFinite(options.width)) {
|
|
80
|
+
const flex = new FlexStrip([...cells], { joiner });
|
|
81
|
+
const out = renderToString(flex, {
|
|
82
|
+
width: options.width,
|
|
83
|
+
colorSystem: options.colorCompatibility,
|
|
84
|
+
});
|
|
85
|
+
return out.endsWith("\n") ? out.slice(0, -1) : out;
|
|
86
|
+
}
|
|
87
|
+
const strip = new Strip([...cells], joiner);
|
|
88
|
+
return renderToString(strip, {
|
|
89
|
+
colorSystem: options.colorCompatibility,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Input-shape adapter for callers that hold RenderedSegmentLike[] rather
|
|
95
|
+
* than pre-constructed RichText cells. Wrap behavior is identical to
|
|
96
|
+
* renderStripCells — the cell construction is the only difference.
|
|
97
|
+
*/
|
|
98
|
+
export function buildLineStrip(
|
|
99
|
+
segments: readonly RenderedSegmentLike[],
|
|
100
|
+
options: BuildLineOptions,
|
|
101
|
+
): string {
|
|
102
|
+
return renderStripCells(segments.map(toCell), options);
|
|
103
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Prompt-cache warmth provider.
|
|
2
|
+
//
|
|
3
|
+
// Anthropic's prompt cache has a fixed TTL (1h): each turn that reads or
|
|
4
|
+
// creates cache entries refreshes it, and after the TTL the next turn pays
|
|
5
|
+
// full cache-creation cost again. This provider answers one question — when
|
|
6
|
+
// does the current session's cache go cold? — by tail-reading the transcript
|
|
7
|
+
// for the most recent entry that touched the cache and projecting its
|
|
8
|
+
// timestamp forward by the TTL.
|
|
9
|
+
//
|
|
10
|
+
// [LAW:dataflow-not-control-flow] The datum is a single epoch instant, not a
|
|
11
|
+
// rendered string. Whether the timer shows "12m", "cold", or hides entirely,
|
|
12
|
+
// and what color it takes, are all functions of this one number evaluated in
|
|
13
|
+
// the DSL template — the same shape block/weekly use with `resetsAt`. The
|
|
14
|
+
// provider carries no display policy.
|
|
15
|
+
//
|
|
16
|
+
// [LAW:types-are-the-program] The return is `Outcome<number>`: a known expiry
|
|
17
|
+
// instant (`ok`), "no cache activity found" (`absent` — no transcript yet, or
|
|
18
|
+
// no cache-bearing entry), or a real read failure (`failed` — the transcript
|
|
19
|
+
// exists but couldn't be read). Absent becomes a missing payload field, which
|
|
20
|
+
// the segment's `when` predicate reads as hidden; failed reaches the payload
|
|
21
|
+
// boundary, the one place that logs it — there is no "0 means hidden"
|
|
22
|
+
// ambiguity to defend against downstream, and no failure dressed as absence.
|
|
23
|
+
|
|
24
|
+
import { ABSENT, ok, type Outcome } from "../utils/outcome.js";
|
|
25
|
+
import { readTail } from "../utils/transcript-fs.js";
|
|
26
|
+
|
|
27
|
+
// Anthropic prompt cache TTL. A const, not a knob: it is a property of the
|
|
28
|
+
// upstream cache, not of this renderer. If a future cache tier ships a
|
|
29
|
+
// different TTL, that is a new arm here, not a user config field.
|
|
30
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
31
|
+
const TAIL_CHUNK = 64 * 1024;
|
|
32
|
+
const TAIL_MAX = 1 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
// [LAW:types-are-the-program] A cheap CANDIDATE filter, not the authority. It
|
|
35
|
+
// matches any line mentioning a non-zero cache-token field, which includes a
|
|
36
|
+
// line whose *message content* merely quotes the string (a pasted JSON snippet,
|
|
37
|
+
// a transcript of a review discussing these very fields). The authoritative
|
|
38
|
+
// check is the parsed `message.usage` value — the regex only avoids JSON.parsing
|
|
39
|
+
// every line; a match is verified before its timestamp is trusted. The `[1-9]`
|
|
40
|
+
// rejects the `":0` common case so most non-cache lines never reach the parser.
|
|
41
|
+
const CACHE_HIT_RE =
|
|
42
|
+
/"(?:cache_read_input_tokens|cache_creation_input_tokens)":[1-9]/;
|
|
43
|
+
|
|
44
|
+
// The transcript-line shape this provider reads. Untrusted JSON — every field is
|
|
45
|
+
// optional and narrowed at use; only positive `message.usage` cache tokens count.
|
|
46
|
+
interface UsageLine {
|
|
47
|
+
readonly timestamp?: string;
|
|
48
|
+
readonly message?: {
|
|
49
|
+
readonly usage?: {
|
|
50
|
+
readonly cache_read_input_tokens?: number;
|
|
51
|
+
readonly cache_creation_input_tokens?: number;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Parse a candidate line and return its millisecond timestamp ONLY if its
|
|
57
|
+
// `message.usage` actually records positive cache activity. A content-only
|
|
58
|
+
// mention (or unparseable line) yields null, so a false-positive regex match
|
|
59
|
+
// can never set the timer warm.
|
|
60
|
+
function cacheActivityTs(line: string): number | null {
|
|
61
|
+
let parsed: UsageLine;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(line) as UsageLine;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const usage = parsed.message?.usage;
|
|
68
|
+
const positive =
|
|
69
|
+
(usage?.cache_read_input_tokens ?? 0) > 0 ||
|
|
70
|
+
(usage?.cache_creation_input_tokens ?? 0) > 0;
|
|
71
|
+
if (!positive) return null;
|
|
72
|
+
const ms = parsed.timestamp != null ? Date.parse(parsed.timestamp) : NaN;
|
|
73
|
+
return Number.isNaN(ms) ? null : ms;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Epoch *seconds* at which the session's prompt cache expires; `absent` when
|
|
78
|
+
* no cache-bearing transcript entry can be found; `failed` when the
|
|
79
|
+
* transcript exists but couldn't be read. Seconds (not millis) to match the
|
|
80
|
+
* unit of block/weekly `resetsAt`, so the DSL composes
|
|
81
|
+
* `minutesUntilReset .cache.expiresAt` with no unit translation.
|
|
82
|
+
*/
|
|
83
|
+
export async function cacheExpiresAt(
|
|
84
|
+
transcriptPath: string,
|
|
85
|
+
): Promise<Outcome<number>> {
|
|
86
|
+
const lastCacheMs = await findLastCacheActivityTs(transcriptPath);
|
|
87
|
+
if (lastCacheMs.kind !== "ok") return lastCacheMs;
|
|
88
|
+
return ok(Math.floor((lastCacheMs.value + CACHE_TTL_MS) / 1000));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Tail-read the JSONL transcript and return the millisecond timestamp of the
|
|
92
|
+
// last entry with cache activity. The relevant entry is almost always within
|
|
93
|
+
// the final few KB, so the common case reads one TAIL_CHUNK; only a transcript
|
|
94
|
+
// whose last cache hit is deeper grows to TAIL_MAX. [LAW:single-enforcer] both
|
|
95
|
+
// reads go through the gated transcript-fs seam (readTail), so this scanner is
|
|
96
|
+
// bounded with every other transcript read instead of blocking the event loop
|
|
97
|
+
// on synchronous fs.
|
|
98
|
+
async function findLastCacheActivityTs(
|
|
99
|
+
transcriptPath: string,
|
|
100
|
+
): Promise<Outcome<number>> {
|
|
101
|
+
for (const maxBytes of [TAIL_CHUNK, TAIL_MAX]) {
|
|
102
|
+
const tail = await readTail(transcriptPath, maxBytes);
|
|
103
|
+
// absent (no transcript yet) and failed (unreadable) both end the scan;
|
|
104
|
+
// the outcome carries which one happened to the payload boundary.
|
|
105
|
+
if (tail.kind !== "ok") return tail;
|
|
106
|
+
const ts = scanBufferForLastCacheTs(tail.value.buf, tail.value.fromStart);
|
|
107
|
+
if (ts != null) return ok(ts);
|
|
108
|
+
// The window reached the file start: the whole transcript is scanned, no
|
|
109
|
+
// hit exists — growing further would re-read the same bytes.
|
|
110
|
+
if (tail.value.fromStart) return ABSENT;
|
|
111
|
+
}
|
|
112
|
+
return ABSENT;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scanBufferForLastCacheTs(
|
|
116
|
+
buf: Buffer,
|
|
117
|
+
bufStartsAtFileBeginning: boolean,
|
|
118
|
+
): number | null {
|
|
119
|
+
const text = buf.toString("utf8");
|
|
120
|
+
const lines = text.split("\n");
|
|
121
|
+
// When the window doesn't start at the file beginning, the first line is
|
|
122
|
+
// likely a partial JSON object — skip it so we never mis-parse a fragment.
|
|
123
|
+
const start = bufStartsAtFileBeginning ? 0 : 1;
|
|
124
|
+
for (let i = lines.length - 1; i >= start; i--) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
if (!line || !CACHE_HIT_RE.test(line)) continue; // cheap candidate filter
|
|
127
|
+
const ts = cacheActivityTs(line); // authoritative: parsed usage must be > 0
|
|
128
|
+
if (ts != null) return ts;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|