tickmarkr 1.80.0 → 1.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/brand.d.ts +4 -1
- package/dist/brand.js +46 -7
- package/dist/cli/commands/approve.js +53 -8
- package/dist/cli/commands/plan.js +17 -2
- package/dist/cli/commands/report.js +15 -6
- package/dist/cli/commands/ui.d.ts +1 -1
- package/dist/cli/commands/ui.js +47 -4
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/collateral.d.ts +19 -0
- package/dist/compile/collateral.js +90 -0
- package/dist/compile/index.js +18 -4
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +19 -0
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/acceptance.js +50 -8
- package/dist/gates/llm.js +34 -19
- package/dist/gates/review.d.ts +9 -1
- package/dist/gates/review.js +173 -7
- package/dist/gates/run-gates.d.ts +1 -0
- package/dist/gates/run-gates.js +18 -1
- package/dist/report/bundle.d.ts +6 -1
- package/dist/report/bundle.js +13 -3
- package/dist/run/daemon.d.ts +5 -0
- package/dist/run/daemon.js +188 -27
- package/dist/run/journal.d.ts +5 -0
- package/dist/run/journal.js +71 -1
- package/dist/tui/cockpit/capture.d.ts +107 -1
- package/dist/tui/cockpit/capture.js +287 -10
- package/dist/tui/cockpit/components.d.ts +15 -86
- package/dist/tui/cockpit/components.js +49 -55
- package/dist/tui/cockpit/derive.d.ts +73 -1
- package/dist/tui/cockpit/derive.js +211 -15
- package/dist/tui/cockpit/keys.d.ts +244 -0
- package/dist/tui/cockpit/keys.js +634 -0
- package/dist/tui/cockpit/layout.d.ts +162 -0
- package/dist/tui/cockpit/layout.js +229 -0
- package/dist/tui/cockpit/live.d.ts +80 -0
- package/dist/tui/cockpit/live.js +311 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +65 -2
- package/dist/tui/cockpit/run-cockpit.js +705 -128
- package/dist/tui/cockpit/setup-cockpit.d.ts +149 -1
- package/dist/tui/cockpit/setup-cockpit.js +432 -56
- package/dist/tui/cockpit/views.d.ts +72 -0
- package/dist/tui/cockpit/views.js +56 -0
- package/dist/tui/cockpit/width.d.ts +105 -0
- package/dist/tui/cockpit/width.js +276 -0
- package/fixtures/speckit-sample/tasks.md +2 -2
- package/package.json +3 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The run cockpit's rail order. Number keys are one-based positions here. */
|
|
2
|
+
export const RUN_VIEWS = [
|
|
3
|
+
{ id: "run", label: "Run", title: "RUN", detailTitle: "RUN DETAIL" },
|
|
4
|
+
{ id: "tasks", label: "Tasks", title: "TASKS", detailTitle: "TASK DETAIL" },
|
|
5
|
+
{ id: "gates", label: "Gates", title: "GATES", detailTitle: "GATE DETAIL" },
|
|
6
|
+
{ id: "journal", label: "Journal", title: "JOURNAL", detailTitle: "EVENT DETAIL" },
|
|
7
|
+
{ id: "fleet", label: "Fleet", title: "FLEET", detailTitle: "CHANNEL DETAIL" },
|
|
8
|
+
];
|
|
9
|
+
export function runViewAt(index) {
|
|
10
|
+
return Number.isInteger(index) ? RUN_VIEWS[index] : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function runViewById(id) {
|
|
13
|
+
return RUN_VIEWS.find((view) => view.id === id) ?? RUN_VIEWS[0];
|
|
14
|
+
}
|
|
15
|
+
/** Rail movement stops at either end; it never points outside the five views. */
|
|
16
|
+
export function moveRunViewSelection(selection, delta) {
|
|
17
|
+
return Math.max(0, Math.min(RUN_VIEWS.length - 1, selection + delta));
|
|
18
|
+
}
|
|
19
|
+
/** A one-based number key's view, or undefined for a digit the rail does not own. */
|
|
20
|
+
export function runViewForNumber(input) {
|
|
21
|
+
if (!/^\d$/u.test(input))
|
|
22
|
+
return undefined;
|
|
23
|
+
return runViewAt(Number(input) - 1);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The second tab's drawn name. It was SETUP through v1.83; the operator's UAT
|
|
27
|
+
* read that as configuration, and what the tab draws is journal-parked human
|
|
28
|
+
* decisions whose only writes are approve and uphold. Stated once here so the
|
|
29
|
+
* tab strip, the keybar hint and the surface's own header cannot drift apart.
|
|
30
|
+
*/
|
|
31
|
+
export const DECISIONS_TAB_TITLE = "DECISIONS";
|
|
32
|
+
/** The same name in the keybar's sentence case — the word drawn beside Tab. */
|
|
33
|
+
export const DECISIONS_TAB_HINT = "Decisions";
|
|
34
|
+
/**
|
|
35
|
+
* The decisions tab's rail. Every label fits the rail's own column unwrapped —
|
|
36
|
+
* a wrapped rail row is a row nothing charges to the height budget, and that is
|
|
37
|
+
* what pushed the surface past a 14-row terminal. Read-only sections say so in
|
|
38
|
+
* their body, not in a label long enough to wrap.
|
|
39
|
+
*/
|
|
40
|
+
export const SETUP_SECTIONS = [
|
|
41
|
+
{ id: "decisions", label: "Decisions", title: "DECISIONS", readOnly: false },
|
|
42
|
+
{ id: "fleet", label: "Fleet", title: "FLEET", readOnly: true },
|
|
43
|
+
{ id: "config", label: "Config", title: "CONFIG", readOnly: true },
|
|
44
|
+
];
|
|
45
|
+
/** The section a rail position names; out-of-range positions clamp to the rail. */
|
|
46
|
+
export function setupSectionAt(index) {
|
|
47
|
+
const clamped = Math.max(0, Math.min(SETUP_SECTIONS.length - 1, Math.floor(index)));
|
|
48
|
+
return SETUP_SECTIONS[clamped];
|
|
49
|
+
}
|
|
50
|
+
/** Rail movement stops at either end; it never points outside the sections. */
|
|
51
|
+
export function moveSetupSectionSelection(selection, delta) {
|
|
52
|
+
return Math.max(0, Math.min(SETUP_SECTIONS.length - 1, selection + delta));
|
|
53
|
+
}
|
|
54
|
+
// Sections carry no number key: 1–5 names a view on the whole surface, and a
|
|
55
|
+
// digit that meant a section here would be the same key with two meanings.
|
|
56
|
+
// The sections are reached the way the contract gives them — ↑↓ and ⏎.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-cell measurement by grapheme cluster — the cockpit's width authority.
|
|
3
|
+
*
|
|
4
|
+
* Terminals advance on grapheme clusters, not code points: a ZWJ emoji
|
|
5
|
+
* sequence, a regional-indicator flag pair, a skin-tone-modified emoji and a
|
|
6
|
+
* variation-selector sequence each occupy the cells of ONE cluster, while
|
|
7
|
+
* zero-width combining marks add no cells at all. Counting code points
|
|
8
|
+
* over-charges every one of them.
|
|
9
|
+
*
|
|
10
|
+
* Every operation a cockpit surface performs on text — measure it, find which
|
|
11
|
+
* glyph sits under a cell, cut it for a wrap, wrap it into rows, clip it into
|
|
12
|
+
* a fixed column — is exported here, each cluster-safe and each reporting the
|
|
13
|
+
* cell count it produced, so a caller states policy (where to break, what to
|
|
14
|
+
* pad with, which continuation marker to use) and never does cell arithmetic
|
|
15
|
+
* to carry it out. `width.test.ts` pins that coverage against the arithmetic
|
|
16
|
+
* the live call sites perform today.
|
|
17
|
+
*
|
|
18
|
+
* Migration of those call sites is deliberately NOT here. The spec's
|
|
19
|
+
* separability law gives every file exactly one owning task, and the callers
|
|
20
|
+
* that hand-count code points today are owned elsewhere: `layout.ts` by T3,
|
|
21
|
+
* `run-cockpit.tsx`/`live.ts` by T4, `components.tsx` by T5 — each of which
|
|
22
|
+
* carries this file in its context list precisely so it migrates onto these
|
|
23
|
+
* exports. T1 writing into those files would be the concurrent overwrite of
|
|
24
|
+
* another task's surface that the law exists to prevent (OBS-212), so this
|
|
25
|
+
* task owes them a complete module, and owes them nothing else.
|
|
26
|
+
*
|
|
27
|
+
* ponytail: the base-character width table is `string-width`, which
|
|
28
|
+
* `components.tsx` already imports — borrowing it leaves the cockpit ONE East
|
|
29
|
+
* Asian width model instead of two that disagree wherever Unicode moved on
|
|
30
|
+
* (the OBS-198 failure class). This module owns the grapheme layer and decides
|
|
31
|
+
* one width per cluster; marks and joiners are never handed to the table as
|
|
32
|
+
* separately chargeable characters.
|
|
33
|
+
*/
|
|
34
|
+
/** Split a string into its grapheme clusters (UAX #29). */
|
|
35
|
+
export declare function graphemeClusters(text: string): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Display cells occupied by `text`, summed cluster by cluster. Summing the
|
|
38
|
+
* parts rather than measuring the whole is what keeps measurement, location
|
|
39
|
+
* and slicing agreeing: every cell this returns belongs to exactly one cluster
|
|
40
|
+
* the other three can name.
|
|
41
|
+
*/
|
|
42
|
+
export declare function cellWidth(text: string): number;
|
|
43
|
+
export interface CellLocation {
|
|
44
|
+
readonly clusterIndex: number;
|
|
45
|
+
readonly cluster: string;
|
|
46
|
+
/** First cell of the cluster, inclusive. */
|
|
47
|
+
readonly cellStart: number;
|
|
48
|
+
/** End of the cluster, exclusive. */
|
|
49
|
+
readonly cellEnd: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a cell offset to the grapheme cluster covering it. The result is
|
|
53
|
+
* always a whole cluster — resolution never lands mid-cluster, so both cells
|
|
54
|
+
* of a double-cell cluster resolve to that same cluster. Returns null when the
|
|
55
|
+
* offset is not a non-negative integer inside the string's cell span.
|
|
56
|
+
*/
|
|
57
|
+
export declare function locateCell(text: string, cellOffset: number): CellLocation | null;
|
|
58
|
+
export interface CellSlice {
|
|
59
|
+
/** The leading whole clusters that were taken. */
|
|
60
|
+
readonly head: string;
|
|
61
|
+
/** Everything after them — `head + tail` reconstructs the input. */
|
|
62
|
+
readonly tail: string;
|
|
63
|
+
/** Cells `head` occupies, so a caller never re-measures what it just cut. */
|
|
64
|
+
readonly cells: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Cut `text` for a wrap: the longest leading run of whole clusters fitting
|
|
68
|
+
* `maxCells`. A leading cluster wider than `maxCells` is taken anyway —
|
|
69
|
+
* mirroring a terminal painting one glyph past the cells left to it, and
|
|
70
|
+
* guaranteeing a wrap loop advances instead of stalling on a glyph too wide
|
|
71
|
+
* for its column. `cells` therefore exceeds `maxCells` in that one case, and
|
|
72
|
+
* only there. `maxCells` below 1 takes nothing.
|
|
73
|
+
*/
|
|
74
|
+
export declare function sliceCells(text: string, maxCells: number): CellSlice;
|
|
75
|
+
export interface WrapCellsOptions {
|
|
76
|
+
/** Prefix painted on fitting rows after the first, included in each result. */
|
|
77
|
+
readonly continuationPrefix?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Wrap `text` into rows of at most `maxCells` cells — greedy, breaking between
|
|
81
|
+
* words, and cutting a word that cannot fit a row of its own on a cluster
|
|
82
|
+
* boundary. This is the operation that forces cell arithmetic on a caller that
|
|
83
|
+
* lacks it (`width - measure(current)`, repeated), so it is exported rather
|
|
84
|
+
* than left to be rebuilt per surface; a caller keeps only its own policy —
|
|
85
|
+
* padding and how many rows it will draw. Continuation-marker policy lives in
|
|
86
|
+
* this operation too: when supplied, its measured cells are deducted from
|
|
87
|
+
* subsequent content budgets and the returned continuation rows include it.
|
|
88
|
+
*
|
|
89
|
+
* Rows are never empty of intent. Without a continuation prefix, joining them
|
|
90
|
+
* re-inserts no space that was not in the input and drops none that was. A row
|
|
91
|
+
* exceeds `maxCells` only when its content is a single cluster too wide for
|
|
92
|
+
* the full budget. A continuation prefix is omitted when it leaves no content
|
|
93
|
+
* cell or would make such an over-wide cluster overflow; a marker can therefore
|
|
94
|
+
* never create an overflow or a non-advancing wrap. Always returns at least
|
|
95
|
+
* one row.
|
|
96
|
+
*/
|
|
97
|
+
export declare function wrapCells(text: string, maxCells: number, options?: WrapCellsOptions): string[];
|
|
98
|
+
/**
|
|
99
|
+
* Clip `text` into a fixed column: the result occupies exactly `cells` display
|
|
100
|
+
* cells, space-padded when the text is short and cut on a cluster boundary
|
|
101
|
+
* when it is long. A cluster that will not fit whole is dropped rather than
|
|
102
|
+
* half-drawn, so a clipped line can never overflow its region — the frame
|
|
103
|
+
* plan's spans hold by construction, not by the caller's care.
|
|
104
|
+
*/
|
|
105
|
+
export declare function fitCells(text: string, cells: number): string;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-cell measurement by grapheme cluster — the cockpit's width authority.
|
|
3
|
+
*
|
|
4
|
+
* Terminals advance on grapheme clusters, not code points: a ZWJ emoji
|
|
5
|
+
* sequence, a regional-indicator flag pair, a skin-tone-modified emoji and a
|
|
6
|
+
* variation-selector sequence each occupy the cells of ONE cluster, while
|
|
7
|
+
* zero-width combining marks add no cells at all. Counting code points
|
|
8
|
+
* over-charges every one of them.
|
|
9
|
+
*
|
|
10
|
+
* Every operation a cockpit surface performs on text — measure it, find which
|
|
11
|
+
* glyph sits under a cell, cut it for a wrap, wrap it into rows, clip it into
|
|
12
|
+
* a fixed column — is exported here, each cluster-safe and each reporting the
|
|
13
|
+
* cell count it produced, so a caller states policy (where to break, what to
|
|
14
|
+
* pad with, which continuation marker to use) and never does cell arithmetic
|
|
15
|
+
* to carry it out. `width.test.ts` pins that coverage against the arithmetic
|
|
16
|
+
* the live call sites perform today.
|
|
17
|
+
*
|
|
18
|
+
* Migration of those call sites is deliberately NOT here. The spec's
|
|
19
|
+
* separability law gives every file exactly one owning task, and the callers
|
|
20
|
+
* that hand-count code points today are owned elsewhere: `layout.ts` by T3,
|
|
21
|
+
* `run-cockpit.tsx`/`live.ts` by T4, `components.tsx` by T5 — each of which
|
|
22
|
+
* carries this file in its context list precisely so it migrates onto these
|
|
23
|
+
* exports. T1 writing into those files would be the concurrent overwrite of
|
|
24
|
+
* another task's surface that the law exists to prevent (OBS-212), so this
|
|
25
|
+
* task owes them a complete module, and owes them nothing else.
|
|
26
|
+
*
|
|
27
|
+
* ponytail: the base-character width table is `string-width`, which
|
|
28
|
+
* `components.tsx` already imports — borrowing it leaves the cockpit ONE East
|
|
29
|
+
* Asian width model instead of two that disagree wherever Unicode moved on
|
|
30
|
+
* (the OBS-198 failure class). This module owns the grapheme layer and decides
|
|
31
|
+
* one width per cluster; marks and joiners are never handed to the table as
|
|
32
|
+
* separately chargeable characters.
|
|
33
|
+
*/
|
|
34
|
+
import stringWidth from "string-width";
|
|
35
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, {
|
|
36
|
+
granularity: "grapheme",
|
|
37
|
+
});
|
|
38
|
+
const ZERO_WIDTH_CLUSTER_PART = /[\p{Mn}\p{Me}\p{Cf}]/u;
|
|
39
|
+
const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u;
|
|
40
|
+
const TEXT_VARIATION_SELECTOR = "\uFE0E";
|
|
41
|
+
const EMOJI_VARIATION_SELECTOR = "\uFE0F";
|
|
42
|
+
const COMBINING_ENCLOSING_KEYCAP = "\u20E3";
|
|
43
|
+
/**
|
|
44
|
+
* ECMA-48 control strings and escape sequences used by terminal output. The
|
|
45
|
+
* alternatives are ordered longest first: OSC/DCS strings must be consumed
|
|
46
|
+
* through their BEL or ST terminator before the shorter two-byte ESC form can
|
|
47
|
+
* match their introducer. CSI accepts either the seven-bit ESC [ form or its
|
|
48
|
+
* eight-bit C1 equivalent.
|
|
49
|
+
*/
|
|
50
|
+
const ANSI_SEQUENCE = /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u009D[\s\S]*?(?:\u0007|\u009C)|\u001B[P^_X][\s\S]*?\u001B\\|[\u0090\u0098\u009E\u009F][\s\S]*?\u009C|(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B[@-_])/gu;
|
|
51
|
+
function segmentedGraphemes(text) {
|
|
52
|
+
return [...graphemeSegmenter.segment(text)].map(({ segment }) => segment);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve one segmented grapheme to the cells a terminal advances for it.
|
|
56
|
+
* Emoji-presentation clusters are a single double-cell glyph. Every other
|
|
57
|
+
* cluster takes the width of its first printable base; non-spacing marks,
|
|
58
|
+
* enclosing marks, variation selectors and joiners cannot add cells merely
|
|
59
|
+
* because an older width table iterates their code points.
|
|
60
|
+
*/
|
|
61
|
+
function clusterCells(cluster) {
|
|
62
|
+
const emojiPresentation = !cluster.includes(TEXT_VARIATION_SELECTOR) &&
|
|
63
|
+
(cluster.includes(EMOJI_VARIATION_SELECTOR) ||
|
|
64
|
+
cluster.includes(COMBINING_ENCLOSING_KEYCAP) ||
|
|
65
|
+
EMOJI_PRESENTATION.test(cluster));
|
|
66
|
+
if (emojiPresentation)
|
|
67
|
+
return 2;
|
|
68
|
+
for (const character of cluster) {
|
|
69
|
+
if (!ZERO_WIDTH_CLUSTER_PART.test(character))
|
|
70
|
+
return stringWidth(character);
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Tokenize controls before grapheme segmentation. ANSI payload bytes are not
|
|
76
|
+
* printable graphemes: each complete control is one atomic zero-cell unit,
|
|
77
|
+
* while the plain spans between controls are segmented by UAX #29.
|
|
78
|
+
*/
|
|
79
|
+
function displayUnits(text) {
|
|
80
|
+
const units = [];
|
|
81
|
+
let plainStart = 0;
|
|
82
|
+
for (const match of text.matchAll(ANSI_SEQUENCE)) {
|
|
83
|
+
const controlStart = match.index;
|
|
84
|
+
for (const cluster of segmentedGraphemes(text.slice(plainStart, controlStart))) {
|
|
85
|
+
units.push({ text: cluster, cells: clusterCells(cluster), control: false });
|
|
86
|
+
}
|
|
87
|
+
units.push({ text: match[0], cells: 0, control: true });
|
|
88
|
+
plainStart = controlStart + match[0].length;
|
|
89
|
+
}
|
|
90
|
+
for (const cluster of segmentedGraphemes(text.slice(plainStart))) {
|
|
91
|
+
units.push({ text: cluster, cells: clusterCells(cluster), control: false });
|
|
92
|
+
}
|
|
93
|
+
return units;
|
|
94
|
+
}
|
|
95
|
+
/** Split a string into its grapheme clusters (UAX #29). */
|
|
96
|
+
export function graphemeClusters(text) {
|
|
97
|
+
return segmentedGraphemes(text);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Display cells occupied by `text`, summed cluster by cluster. Summing the
|
|
101
|
+
* parts rather than measuring the whole is what keeps measurement, location
|
|
102
|
+
* and slicing agreeing: every cell this returns belongs to exactly one cluster
|
|
103
|
+
* the other three can name.
|
|
104
|
+
*/
|
|
105
|
+
export function cellWidth(text) {
|
|
106
|
+
return displayUnits(text).reduce((cells, unit) => cells + unit.cells, 0);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a cell offset to the grapheme cluster covering it. The result is
|
|
110
|
+
* always a whole cluster — resolution never lands mid-cluster, so both cells
|
|
111
|
+
* of a double-cell cluster resolve to that same cluster. Returns null when the
|
|
112
|
+
* offset is not a non-negative integer inside the string's cell span.
|
|
113
|
+
*/
|
|
114
|
+
export function locateCell(text, cellOffset) {
|
|
115
|
+
if (!Number.isInteger(cellOffset) || cellOffset < 0)
|
|
116
|
+
return null;
|
|
117
|
+
let cellStart = 0;
|
|
118
|
+
let clusterIndex = 0;
|
|
119
|
+
for (const unit of displayUnits(text)) {
|
|
120
|
+
if (unit.control)
|
|
121
|
+
continue;
|
|
122
|
+
const cellEnd = cellStart + unit.cells;
|
|
123
|
+
if (cellOffset < cellEnd) {
|
|
124
|
+
return { clusterIndex, cluster: unit.text, cellStart, cellEnd };
|
|
125
|
+
}
|
|
126
|
+
cellStart = cellEnd;
|
|
127
|
+
clusterIndex += 1;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Cut `text` for a wrap: the longest leading run of whole clusters fitting
|
|
133
|
+
* `maxCells`. A leading cluster wider than `maxCells` is taken anyway —
|
|
134
|
+
* mirroring a terminal painting one glyph past the cells left to it, and
|
|
135
|
+
* guaranteeing a wrap loop advances instead of stalling on a glyph too wide
|
|
136
|
+
* for its column. `cells` therefore exceeds `maxCells` in that one case, and
|
|
137
|
+
* only there. `maxCells` below 1 takes nothing.
|
|
138
|
+
*/
|
|
139
|
+
export function sliceCells(text, maxCells) {
|
|
140
|
+
let head = "";
|
|
141
|
+
let cells = 0;
|
|
142
|
+
let clusters = 0;
|
|
143
|
+
if (maxCells >= 1) {
|
|
144
|
+
for (const unit of displayUnits(text)) {
|
|
145
|
+
if (!unit.control && clusters > 0 && cells + unit.cells > maxCells)
|
|
146
|
+
break;
|
|
147
|
+
head += unit.text;
|
|
148
|
+
cells += unit.cells;
|
|
149
|
+
if (!unit.control)
|
|
150
|
+
clusters += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { head, tail: text.slice(head.length), cells };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Divide text into its first word and subsequent space-prefixed runs without
|
|
157
|
+
* ever splitting a cluster or ANSI control. Keeping separators in the runs is
|
|
158
|
+
* what makes wrapping lossless at a line break. A combining mark following a
|
|
159
|
+
* space forms one non-separator cluster, so it stays attached to its word.
|
|
160
|
+
*/
|
|
161
|
+
function spacePrefixedRuns(text) {
|
|
162
|
+
const runs = [];
|
|
163
|
+
let run = "";
|
|
164
|
+
let hasWordCluster = false;
|
|
165
|
+
for (const unit of displayUnits(text)) {
|
|
166
|
+
if (!unit.control && unit.text === " " && hasWordCluster) {
|
|
167
|
+
runs.push(run);
|
|
168
|
+
run = " ";
|
|
169
|
+
hasWordCluster = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
run += unit.text;
|
|
173
|
+
if (!unit.control && unit.text !== " ")
|
|
174
|
+
hasWordCluster = true;
|
|
175
|
+
}
|
|
176
|
+
if (run.length > 0)
|
|
177
|
+
runs.push(run);
|
|
178
|
+
return runs;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Wrap `text` into rows of at most `maxCells` cells — greedy, breaking between
|
|
182
|
+
* words, and cutting a word that cannot fit a row of its own on a cluster
|
|
183
|
+
* boundary. This is the operation that forces cell arithmetic on a caller that
|
|
184
|
+
* lacks it (`width - measure(current)`, repeated), so it is exported rather
|
|
185
|
+
* than left to be rebuilt per surface; a caller keeps only its own policy —
|
|
186
|
+
* padding and how many rows it will draw. Continuation-marker policy lives in
|
|
187
|
+
* this operation too: when supplied, its measured cells are deducted from
|
|
188
|
+
* subsequent content budgets and the returned continuation rows include it.
|
|
189
|
+
*
|
|
190
|
+
* Rows are never empty of intent. Without a continuation prefix, joining them
|
|
191
|
+
* re-inserts no space that was not in the input and drops none that was. A row
|
|
192
|
+
* exceeds `maxCells` only when its content is a single cluster too wide for
|
|
193
|
+
* the full budget. A continuation prefix is omitted when it leaves no content
|
|
194
|
+
* cell or would make such an over-wide cluster overflow; a marker can therefore
|
|
195
|
+
* never create an overflow or a non-advancing wrap. Always returns at least
|
|
196
|
+
* one row.
|
|
197
|
+
*/
|
|
198
|
+
export function wrapCells(text, maxCells, options = {}) {
|
|
199
|
+
const budget = Math.max(1, Math.floor(maxCells));
|
|
200
|
+
const requestedPrefix = options.continuationPrefix ?? "";
|
|
201
|
+
const prefixCells = cellWidth(requestedPrefix);
|
|
202
|
+
const continuationPrefix = prefixCells < budget ? requestedPrefix : "";
|
|
203
|
+
const continuationBudget = continuationPrefix.length > 0
|
|
204
|
+
? budget - prefixCells
|
|
205
|
+
: budget;
|
|
206
|
+
const contentRows = [];
|
|
207
|
+
let row = "";
|
|
208
|
+
let rowCells = 0;
|
|
209
|
+
const contentBudget = () => contentRows.length === 0 ? budget : continuationBudget;
|
|
210
|
+
for (const run of spacePrefixedRuns(text)) {
|
|
211
|
+
let rest = run;
|
|
212
|
+
while (rest.length > 0) {
|
|
213
|
+
const available = contentBudget() - rowCells;
|
|
214
|
+
const restCells = cellWidth(rest);
|
|
215
|
+
if (restCells <= available) {
|
|
216
|
+
row += rest;
|
|
217
|
+
rowCells += restCells;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
// Prefer a word boundary: a whole space-prefixed run that fits on the
|
|
221
|
+
// next row should not be hard-cut merely to fill the current one.
|
|
222
|
+
if (row.length > 0 && restCells <= continuationBudget) {
|
|
223
|
+
contentRows.push(row);
|
|
224
|
+
row = "";
|
|
225
|
+
rowCells = 0;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const { head, tail, cells } = sliceCells(rest, Math.max(1, available));
|
|
229
|
+
// `sliceCells` deliberately takes an over-wide first cluster to ensure
|
|
230
|
+
// progress. That escape hatch is valid only on an otherwise empty row;
|
|
231
|
+
// flush existing content before retrying it against a fresh budget.
|
|
232
|
+
if (row.length > 0 && cells > available) {
|
|
233
|
+
contentRows.push(row);
|
|
234
|
+
row = "";
|
|
235
|
+
rowCells = 0;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
row += head;
|
|
239
|
+
rowCells += cells;
|
|
240
|
+
rest = tail;
|
|
241
|
+
contentRows.push(row);
|
|
242
|
+
row = "";
|
|
243
|
+
rowCells = 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (row.length > 0 || contentRows.length === 0)
|
|
247
|
+
contentRows.push(row);
|
|
248
|
+
return contentRows.map((content, index) => {
|
|
249
|
+
if (index === 0 ||
|
|
250
|
+
continuationPrefix.length === 0 ||
|
|
251
|
+
prefixCells + cellWidth(content) > budget) {
|
|
252
|
+
return content;
|
|
253
|
+
}
|
|
254
|
+
return `${continuationPrefix}${content}`;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Clip `text` into a fixed column: the result occupies exactly `cells` display
|
|
259
|
+
* cells, space-padded when the text is short and cut on a cluster boundary
|
|
260
|
+
* when it is long. A cluster that will not fit whole is dropped rather than
|
|
261
|
+
* half-drawn, so a clipped line can never overflow its region — the frame
|
|
262
|
+
* plan's spans hold by construction, not by the caller's care.
|
|
263
|
+
*/
|
|
264
|
+
export function fitCells(text, cells) {
|
|
265
|
+
if (cells < 1)
|
|
266
|
+
return "";
|
|
267
|
+
let clipped = "";
|
|
268
|
+
let used = 0;
|
|
269
|
+
for (const unit of displayUnits(text)) {
|
|
270
|
+
if (used + unit.cells > cells)
|
|
271
|
+
break;
|
|
272
|
+
clipped += unit.text;
|
|
273
|
+
used += unit.cells;
|
|
274
|
+
}
|
|
275
|
+
return clipped + " ".repeat(cells - used);
|
|
276
|
+
}
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
- [ ] T002 [P] Implement token refresh in src/auth/refresh.ts
|
|
11
11
|
- acceptance: expired token triggers exactly one refresh attempt
|
|
12
12
|
- acceptance: refresh failure surfaces a typed error
|
|
13
|
-
- files: src/auth
|
|
13
|
+
- files: src/auth/refresh.ts
|
|
14
14
|
- complexity: 7
|
|
15
15
|
- [ ] T003 [P] Implement session store in src/auth/store.ts
|
|
16
16
|
- acceptance: sessions persist across restarts
|
|
17
|
-
- files: src/auth
|
|
17
|
+
- files: src/auth/store.ts
|
|
18
18
|
- [ ] T004 Write integration tests for auth flow
|
|
19
19
|
- acceptance: happy path and refresh-failure path covered
|
|
20
20
|
- files: tests/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickmarkr",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.0",
|
|
4
4
|
"description": "Spec in, verified work out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,9 +41,11 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@types/react": "^19.2.17",
|
|
44
|
+
"chalk": "^5.6.2",
|
|
44
45
|
"ink": "^6.8.0",
|
|
45
46
|
"picomatch": "^4.0.2",
|
|
46
47
|
"react": "^19.2.8",
|
|
48
|
+
"string-width": "5.1.2",
|
|
47
49
|
"yaml": "^2.5.0",
|
|
48
50
|
"zod": "^4.0.0"
|
|
49
51
|
},
|