opencode-clm 0.1.1 → 0.3.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/CHANGELOG.md +1 -1
- package/NOTICE +9 -1
- package/README.md +112 -299
- package/index.ts +778 -34
- package/package.json +19 -4
- package/src/atomic.ts +27 -0
- package/src/budget.ts +12 -10
- package/src/channel.ts +187 -0
- package/src/clm.ts +610 -27
- package/src/commands.ts +18 -0
- package/src/compaction.ts +243 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +41 -3
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +205 -0
- package/src/panel/command.ts +75 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +107 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +569 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +460 -0
- package/src/panel/view.ts +554 -0
- package/src/presentation.ts +6 -0
- package/src/projection.ts +8 -0
- package/src/session-files.ts +286 -0
- package/src/settings-table.ts +454 -0
- package/src/settings.ts +33 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +241 -0
- package/src/tui/intercept.ts +60 -0
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +205 -0
- package/src/tui/plugin.ts +466 -0
- package/src/tui/remote.ts +199 -0
- package/tui.ts +7 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Key handling for the `/clm` panel as a pure reducer. The key table follows pi-clm
|
|
2
|
+
// src/viewer.ts `handleInput` (MIT, Copyright 2026 Emanuel Casco), plus `r` reload; the
|
|
3
|
+
// edits page's scroll-inside-an-expanded-diff rule follows its `scrollWithinExpandedEdit`.
|
|
4
|
+
|
|
5
|
+
import { PAGES } from "./command.ts";
|
|
6
|
+
import type { PanelModel } from "./model.ts";
|
|
7
|
+
import { availableTimelineZooms } from "./timeline.ts";
|
|
8
|
+
import {
|
|
9
|
+
contentWidth,
|
|
10
|
+
currentRevision,
|
|
11
|
+
editKey,
|
|
12
|
+
editsPage,
|
|
13
|
+
effectiveZoom,
|
|
14
|
+
markerSelection,
|
|
15
|
+
maxScroll,
|
|
16
|
+
revisionIndex,
|
|
17
|
+
settingsPage,
|
|
18
|
+
viewportHeight,
|
|
19
|
+
type PanelSize,
|
|
20
|
+
type PanelState,
|
|
21
|
+
} from "./view.ts";
|
|
22
|
+
|
|
23
|
+
export type { PanelState } from "./view.ts";
|
|
24
|
+
export { initialPanelState } from "./view.ts";
|
|
25
|
+
|
|
26
|
+
export type Key =
|
|
27
|
+
| "1" | "2" | "3" | "4"
|
|
28
|
+
| "tab" | "shift+tab"
|
|
29
|
+
| "left" | "right" | "up" | "down"
|
|
30
|
+
| "pageup" | "pagedown"
|
|
31
|
+
| "home" | "end" | "g" | "G"
|
|
32
|
+
| "enter" | "space"
|
|
33
|
+
| "a" | "z" | "r" | "q" | "escape"
|
|
34
|
+
// Aliases: j k for down/up, [ ] for left/right.
|
|
35
|
+
| "j" | "k" | "[" | "]";
|
|
36
|
+
|
|
37
|
+
export type Effect =
|
|
38
|
+
| { kind: "close" }
|
|
39
|
+
| { kind: "reload" }
|
|
40
|
+
| { kind: "prompt"; setting: string; current: string; placeholder?: string }
|
|
41
|
+
| { kind: "apply"; setting: string; value: string };
|
|
42
|
+
|
|
43
|
+
export interface ReduceResult {
|
|
44
|
+
state: PanelState;
|
|
45
|
+
effect?: Effect;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ALIASES: Partial<Record<Key, Key>> = { j: "down", k: "up", "[": "left", "]": "right" };
|
|
49
|
+
|
|
50
|
+
function withPage(state: PanelState, page: PanelState["page"]): PanelState {
|
|
51
|
+
return { ...state, page, scroll: 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Edits page: keep the selected row (header plus any expanded diff) on screen, header first. */
|
|
55
|
+
function revealEdit(state: PanelState, model: PanelModel, size: PanelSize): PanelState {
|
|
56
|
+
const { lines, rowStarts } = editsPage(model, state, contentWidth(size));
|
|
57
|
+
const viewport = viewportHeight(size);
|
|
58
|
+
const start = rowStarts[state.editSel];
|
|
59
|
+
if (start === undefined) return state;
|
|
60
|
+
const end = rowStarts[state.editSel + 1] ?? lines.length;
|
|
61
|
+
let scroll = state.scroll;
|
|
62
|
+
if (start < scroll) scroll = start;
|
|
63
|
+
else if (end > scroll + viewport) scroll = Math.min(start, end - viewport);
|
|
64
|
+
return { ...state, scroll };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ↓/↑ first scroll through the selected row's expanded diff while it extends past the
|
|
69
|
+
* viewport, then move to the next/previous row. Returns undefined when it did not scroll.
|
|
70
|
+
*/
|
|
71
|
+
function scrollWithinExpanded(state: PanelState, model: PanelModel, size: PanelSize, delta: number): PanelState | undefined {
|
|
72
|
+
const revision = currentRevision(model, state);
|
|
73
|
+
if (!state.expanded.has(editKey(revision, state.editSel))) return undefined;
|
|
74
|
+
const { lines, rowStarts } = editsPage(model, state, contentWidth(size));
|
|
75
|
+
const start = rowStarts[state.editSel];
|
|
76
|
+
if (start === undefined) return undefined;
|
|
77
|
+
const end = rowStarts[state.editSel + 1] ?? lines.length;
|
|
78
|
+
const viewport = viewportHeight(size);
|
|
79
|
+
if (delta > 0 && end > state.scroll + viewport) {
|
|
80
|
+
return { ...state, scroll: state.scroll + Math.min(delta, end - (state.scroll + viewport)) };
|
|
81
|
+
}
|
|
82
|
+
if (delta < 0 && start < state.scroll) {
|
|
83
|
+
return { ...state, scroll: state.scroll - Math.min(-delta, state.scroll - start) };
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function moveSelection(state: PanelState, model: PanelModel, size: PanelSize, delta: number): PanelState {
|
|
89
|
+
switch (state.page) {
|
|
90
|
+
case "overview":
|
|
91
|
+
return moveMarker(state, model, delta);
|
|
92
|
+
case "edits": {
|
|
93
|
+
const scrolled = scrollWithinExpanded(state, model, size, delta);
|
|
94
|
+
if (scrolled) return scrolled;
|
|
95
|
+
const count = currentRevision(model, state)?.edits.length ?? 0;
|
|
96
|
+
if (count === 0) return { ...state, scroll: Math.max(0, state.scroll + delta) };
|
|
97
|
+
return revealEdit({ ...state, editSel: Math.max(0, Math.min(count - 1, state.editSel + delta)) }, model, size);
|
|
98
|
+
}
|
|
99
|
+
case "settings":
|
|
100
|
+
return revealSetting({ ...state, settingSel: clampSetting(model, state.settingSel + delta), message: undefined }, model, size);
|
|
101
|
+
case "input":
|
|
102
|
+
return { ...state, scroll: Math.max(0, state.scroll + delta) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** ← → (and ↑ ↓ on the overview) step through markers; the stop after the newest is "now". */
|
|
107
|
+
function moveMarker(state: PanelState, model: PanelModel, delta: number): PanelState {
|
|
108
|
+
const now = model.timeline.markers.length;
|
|
109
|
+
const next = Math.max(0, Math.min(now, markerSelection(model, state) + delta));
|
|
110
|
+
return { ...state, markerSel: next === now ? undefined : next };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function clampSetting(model: PanelModel, index: number): number {
|
|
114
|
+
return Math.max(0, Math.min(model.settings.rows.length - 1, index));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function revealSetting(state: PanelState, model: PanelModel, size: PanelSize): PanelState {
|
|
118
|
+
const { focus } = settingsPage(model, state, contentWidth(size));
|
|
119
|
+
const viewport = viewportHeight(size);
|
|
120
|
+
let scroll = state.scroll;
|
|
121
|
+
if (focus < scroll) scroll = focus;
|
|
122
|
+
else if (focus >= scroll + viewport) scroll = focus - viewport + 1;
|
|
123
|
+
return { ...state, scroll };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Enter on the overview: a marker with a revision opens edits there; "now" opens input. */
|
|
127
|
+
function openMarker(state: PanelState, model: PanelModel): PanelState {
|
|
128
|
+
const selection = markerSelection(model, state);
|
|
129
|
+
if (selection === model.timeline.markers.length) return withPage(state, "input");
|
|
130
|
+
const marker = model.timeline.markers[selection];
|
|
131
|
+
if (!marker || marker.kind !== "applied") return state;
|
|
132
|
+
const index = model.revisions.findIndex((revision) => revision.revision === marker.revision);
|
|
133
|
+
if (index < 0) return state;
|
|
134
|
+
return { ...withPage(state, "edits"), revisionIndex: index, editSel: 0 };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toggleEdit(state: PanelState, model: PanelModel, size: PanelSize): PanelState {
|
|
138
|
+
const revision = currentRevision(model, state);
|
|
139
|
+
if (!revision?.edits[state.editSel]) return state;
|
|
140
|
+
const key = editKey(revision, state.editSel);
|
|
141
|
+
const expanded = new Set(state.expanded);
|
|
142
|
+
if (expanded.delete(key)) return { ...state, expanded };
|
|
143
|
+
expanded.add(key);
|
|
144
|
+
return revealEdit({ ...state, expanded }, model, size);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toggleAllEdits(state: PanelState, model: PanelModel): PanelState {
|
|
148
|
+
const revision = currentRevision(model, state);
|
|
149
|
+
if (!revision) return state;
|
|
150
|
+
const keys = revision.edits.map((_edit, index) => editKey(revision, index));
|
|
151
|
+
const collapse = keys.length > 0 && keys.every((key) => state.expanded.has(key));
|
|
152
|
+
const expanded = new Set(state.expanded);
|
|
153
|
+
for (const key of keys) {
|
|
154
|
+
if (collapse) expanded.delete(key);
|
|
155
|
+
else expanded.add(key);
|
|
156
|
+
}
|
|
157
|
+
return { ...state, expanded };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Enter/Space on a setting: next choice, or a text prompt. */
|
|
161
|
+
function changeSetting(state: PanelState, model: PanelModel): ReduceResult {
|
|
162
|
+
const row = model.settings.rows[clampSetting(model, state.settingSel)];
|
|
163
|
+
if (!row) return { state };
|
|
164
|
+
const cleared = { ...state, message: undefined };
|
|
165
|
+
if (row.choices && row.choices.length > 0) {
|
|
166
|
+
const index = row.choices.indexOf(row.value);
|
|
167
|
+
return { state: cleared, effect: { kind: "apply", setting: row.key, value: row.choices[(index + 1) % row.choices.length]! } };
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
state: cleared,
|
|
171
|
+
effect: { kind: "prompt", setting: row.key, current: row.value, ...(row.placeholder ? { placeholder: row.placeholder } : {}) },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function jump(state: PanelState, model: PanelModel, toEnd: boolean): PanelState {
|
|
176
|
+
switch (state.page) {
|
|
177
|
+
case "overview":
|
|
178
|
+
return { ...state, markerSel: toEnd ? undefined : model.timeline.markers.length === 0 ? undefined : 0 };
|
|
179
|
+
case "edits":
|
|
180
|
+
return {
|
|
181
|
+
...state,
|
|
182
|
+
editSel: toEnd ? Math.max(0, (currentRevision(model, state)?.edits.length ?? 1) - 1) : 0,
|
|
183
|
+
scroll: toEnd ? Number.MAX_SAFE_INTEGER : 0,
|
|
184
|
+
};
|
|
185
|
+
case "settings":
|
|
186
|
+
return { ...state, settingSel: toEnd ? Math.max(0, model.settings.rows.length - 1) : 0, scroll: toEnd ? Number.MAX_SAFE_INTEGER : 0 };
|
|
187
|
+
case "input":
|
|
188
|
+
return { ...state, scroll: toEnd ? Number.MAX_SAFE_INTEGER : 0 };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function step(state: PanelState, key: Key, model: PanelModel, size: PanelSize): ReduceResult {
|
|
193
|
+
const page = state.page;
|
|
194
|
+
if (page === "settings" && (key === "up" || key === "down")) return { state: moveSelection(state, model, size, key === "up" ? -1 : 1) };
|
|
195
|
+
if (page === "settings" && (key === "enter" || key === "space")) return changeSetting(state, model);
|
|
196
|
+
if (key === "q" || key === "escape") return { state, effect: { kind: "close" } };
|
|
197
|
+
if (key === "r") return { state, effect: { kind: "reload" } };
|
|
198
|
+
if (page === "overview" && key === "enter") return { state: openMarker(state, model) };
|
|
199
|
+
if (page === "overview" && (key === "left" || key === "right")) return { state: moveMarker(state, model, key === "left" ? -1 : 1) };
|
|
200
|
+
if (page === "overview" && key === "z") {
|
|
201
|
+
const width = contentWidth(size);
|
|
202
|
+
const zooms = availableTimelineZooms(model.timeline, width);
|
|
203
|
+
const next = zooms[(zooms.indexOf(effectiveZoom(model, state, width)) + 1) % zooms.length]!;
|
|
204
|
+
return { state: { ...state, zoom: next } };
|
|
205
|
+
}
|
|
206
|
+
if (page === "edits" && (key === "left" || key === "right")) {
|
|
207
|
+
if (model.revisions.length === 0) return { state };
|
|
208
|
+
const index = Math.max(0, Math.min(model.revisions.length - 1, revisionIndex(model, state) + (key === "left" ? -1 : 1)));
|
|
209
|
+
return { state: { ...state, revisionIndex: index, editSel: 0, scroll: 0 } };
|
|
210
|
+
}
|
|
211
|
+
const pageIndex = PAGES.indexOf(page);
|
|
212
|
+
if (key === "tab") return { state: withPage(state, PAGES[(pageIndex + 1) % PAGES.length]!) };
|
|
213
|
+
if (key === "shift+tab") return { state: withPage(state, PAGES[(pageIndex - 1 + PAGES.length) % PAGES.length]!) };
|
|
214
|
+
if (key === "enter" || key === "space") return { state: page === "edits" ? toggleEdit(state, model, size) : state };
|
|
215
|
+
if (page === "edits" && key === "a") return { state: toggleAllEdits(state, model) };
|
|
216
|
+
if (key === "up" || key === "down") return { state: moveSelection(state, model, size, key === "up" ? -1 : 1) };
|
|
217
|
+
if (key === "pageup" || key === "pagedown") {
|
|
218
|
+
const viewport = viewportHeight(size);
|
|
219
|
+
const sign = key === "pageup" ? -1 : 1;
|
|
220
|
+
if (page === "edits") return { state: moveSelection(state, model, size, sign * Math.max(1, viewport - 2)) };
|
|
221
|
+
return { state: { ...state, scroll: Math.max(0, state.scroll + sign * viewport) } };
|
|
222
|
+
}
|
|
223
|
+
if (key === "home" || key === "g") return { state: jump(state, model, false) };
|
|
224
|
+
if (key === "end" || key === "G") return { state: jump(state, model, true) };
|
|
225
|
+
if (/^[1-4]$/.test(key)) return { state: withPage(state, PAGES[Number(key) - 1]!) };
|
|
226
|
+
return { state };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Applies one key. The result's scroll is clamped to the page. */
|
|
230
|
+
export function reduce(state: PanelState, key: Key, model: PanelModel, size: PanelSize): ReduceResult {
|
|
231
|
+
const result = step(state, ALIASES[key] ?? key, model, size);
|
|
232
|
+
const limit = maxScroll(model, result.state, size);
|
|
233
|
+
if (result.state.scroll > limit) result.state = { ...result.state, scroll: limit };
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Styled lines for the `/clm` panel: spans carry theme tones instead of ANSI escapes, so
|
|
2
|
+
// the pure renderers can be tested as plain text and the TUI adapter maps each tone to
|
|
3
|
+
// the host theme. Written for this package.
|
|
4
|
+
|
|
5
|
+
export type Tone =
|
|
6
|
+
| "text"
|
|
7
|
+
| "muted"
|
|
8
|
+
| "dim"
|
|
9
|
+
| "accent"
|
|
10
|
+
| "edit"
|
|
11
|
+
| "warning"
|
|
12
|
+
| "error"
|
|
13
|
+
| "success"
|
|
14
|
+
| "diffAdd"
|
|
15
|
+
| "diffDel"
|
|
16
|
+
| "diffAddEm"
|
|
17
|
+
| "diffDelEm"
|
|
18
|
+
| "border";
|
|
19
|
+
|
|
20
|
+
export interface Span {
|
|
21
|
+
text: string;
|
|
22
|
+
tone?: Tone;
|
|
23
|
+
bold?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Line = Span[];
|
|
27
|
+
|
|
28
|
+
export function span(text: string, tone?: Tone, bold?: boolean): Span {
|
|
29
|
+
const result: Span = { text };
|
|
30
|
+
if (tone !== undefined) result.tone = tone;
|
|
31
|
+
if (bold) result.bold = true;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The text of a line without styles. */
|
|
36
|
+
export function plain(line: readonly Span[]): string {
|
|
37
|
+
return line.map((part) => part.text).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Terminal cells of one code point: 0 for combining marks, 2 for East Asian wide and
|
|
42
|
+
* emoji ranges, 1 otherwise. An approximation; it covers the scripts a diff is likely to show.
|
|
43
|
+
*/
|
|
44
|
+
export function charWidth(char: string): number {
|
|
45
|
+
const code = char.codePointAt(0) ?? 0;
|
|
46
|
+
if (code === 0) return 0;
|
|
47
|
+
if ((code >= 0x0300 && code <= 0x036f) || (code >= 0x200b && code <= 0x200f) || code === 0xfe0f) return 0;
|
|
48
|
+
if (
|
|
49
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
50
|
+
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
|
51
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
52
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
53
|
+
(code >= 0xfe30 && code <= 0xfe4f) ||
|
|
54
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
55
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
56
|
+
(code >= 0x1f300 && code <= 0x1faff) ||
|
|
57
|
+
(code >= 0x20000 && code <= 0x3fffd)
|
|
58
|
+
) return 2;
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function textWidth(text: string): number {
|
|
63
|
+
let width = 0;
|
|
64
|
+
for (const char of text) width += charWidth(char);
|
|
65
|
+
return width;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function lineWidth(line: readonly Span[]): number {
|
|
69
|
+
return line.reduce((total, part) => total + textWidth(part.text), 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sameStyle(a: Span, b: Span): boolean {
|
|
73
|
+
return a.tone === b.tone && Boolean(a.bold) === Boolean(b.bold);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Drops empty spans and merges neighbours with the same style. */
|
|
77
|
+
export function normalize(line: readonly Span[]): Line {
|
|
78
|
+
const result: Line = [];
|
|
79
|
+
for (const part of line) {
|
|
80
|
+
if (part.text === "") continue;
|
|
81
|
+
const last = result.at(-1);
|
|
82
|
+
if (last && sameStyle(last, part)) last.text += part.text;
|
|
83
|
+
else result.push({ ...part });
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Gives every span without a tone the given tone. */
|
|
89
|
+
export function withTone(line: readonly Span[], tone: Tone): Line {
|
|
90
|
+
return line.map((part) => (part.tone === undefined ? { ...part, tone } : { ...part }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Cuts a line to `width` cells, ending with `ellipsis` (styled like the cut span) when it was cut. */
|
|
94
|
+
export function truncate(line: readonly Span[], width: number, ellipsis = "…"): Line {
|
|
95
|
+
if (width <= 0) return [];
|
|
96
|
+
if (lineWidth(line) <= width) return line.map((part) => ({ ...part }));
|
|
97
|
+
const target = Math.max(0, width - textWidth(ellipsis));
|
|
98
|
+
const result: Line = [];
|
|
99
|
+
let used = 0;
|
|
100
|
+
let lastStyle: Span | undefined;
|
|
101
|
+
for (const part of line) {
|
|
102
|
+
let text = "";
|
|
103
|
+
for (const char of part.text) {
|
|
104
|
+
const cells = charWidth(char);
|
|
105
|
+
if (used + cells > target) break;
|
|
106
|
+
text += char;
|
|
107
|
+
used += cells;
|
|
108
|
+
}
|
|
109
|
+
lastStyle = part;
|
|
110
|
+
if (text) result.push({ ...part, text });
|
|
111
|
+
if (text.length < part.text.length) break;
|
|
112
|
+
}
|
|
113
|
+
if (ellipsis && textWidth(ellipsis) <= width) {
|
|
114
|
+
result.push({ ...(lastStyle ?? {}), text: ellipsis });
|
|
115
|
+
}
|
|
116
|
+
return normalize(result);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Pads a line with plain spaces to `width` cells (no-op when already wider). */
|
|
120
|
+
export function padEnd(line: readonly Span[], width: number): Line {
|
|
121
|
+
const missing = width - lineWidth(line);
|
|
122
|
+
const result = line.map((part) => ({ ...part }));
|
|
123
|
+
if (missing > 0) result.push({ text: " ".repeat(missing) });
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Fits a line to exactly `width` cells: truncated with an ellipsis, then padded. */
|
|
128
|
+
export function fit(line: readonly Span[], width: number): Line {
|
|
129
|
+
return padEnd(truncate(line, width), width);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Removes trailing whitespace. */
|
|
133
|
+
export function trimEnd(line: readonly Span[]): Line {
|
|
134
|
+
const result = line.map((part) => ({ ...part }));
|
|
135
|
+
while (result.length > 0) {
|
|
136
|
+
const last = result.at(-1)!;
|
|
137
|
+
last.text = last.text.replace(/\s+$/u, "");
|
|
138
|
+
if (last.text !== "") break;
|
|
139
|
+
result.pop();
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function concat(...lines: ReadonlyArray<readonly Span[]>): Line {
|
|
145
|
+
return normalize(lines.flat());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface Cell {
|
|
149
|
+
char: string;
|
|
150
|
+
width: number;
|
|
151
|
+
style: Span;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toCells(line: readonly Span[]): Cell[] {
|
|
155
|
+
const cells: Cell[] = [];
|
|
156
|
+
for (const part of line) {
|
|
157
|
+
for (const char of part.text) cells.push({ char, width: charWidth(char), style: part });
|
|
158
|
+
}
|
|
159
|
+
return cells;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fromCells(cells: readonly Cell[]): Line {
|
|
163
|
+
return normalize(cells.map((cell) => {
|
|
164
|
+
const out: Span = { text: cell.char };
|
|
165
|
+
if (cell.style.tone !== undefined) out.tone = cell.style.tone;
|
|
166
|
+
if (cell.style.bold) out.bold = true;
|
|
167
|
+
return out;
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cellsWidth(cells: readonly Cell[]): number {
|
|
172
|
+
return cells.reduce((total, cell) => total + cell.width, 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Splits a line at newline characters, keeping styles. */
|
|
176
|
+
export function splitNewlines(line: readonly Span[]): Line[] {
|
|
177
|
+
const rows: Line[] = [[]];
|
|
178
|
+
for (const part of line) {
|
|
179
|
+
const pieces = part.text.split(/\r\n?|\n/);
|
|
180
|
+
pieces.forEach((piece, index) => {
|
|
181
|
+
if (index > 0) rows.push([]);
|
|
182
|
+
if (piece) rows.at(-1)!.push({ ...part, text: piece });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return rows;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Word-wraps a styled line to `width` cells. Breaks at whitespace when it can and inside a
|
|
190
|
+
* word when the word alone is wider than the row. Leading whitespace of the first row is
|
|
191
|
+
* kept (indentation); whitespace at a break is dropped. Newlines start a new row. Always
|
|
192
|
+
* returns at least one row.
|
|
193
|
+
*/
|
|
194
|
+
export function wrap(line: readonly Span[], width: number): Line[] {
|
|
195
|
+
if (width <= 0) return [[]];
|
|
196
|
+
const rows: Line[] = [];
|
|
197
|
+
for (const physical of splitNewlines(line)) rows.push(...wrapOne(physical, width));
|
|
198
|
+
return rows.length > 0 ? rows : [[]];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function wrapOne(line: readonly Span[], width: number): Line[] {
|
|
202
|
+
// A cell wider than the row (a wide character at width 1) is shown as "?".
|
|
203
|
+
const cells = toCells(line).map((cell) => (cell.width > width ? { ...cell, char: "?", width: 1 } : cell));
|
|
204
|
+
if (cells.length === 0) return [[]];
|
|
205
|
+
// Tokens: runs of whitespace or of non-whitespace.
|
|
206
|
+
const tokens: Cell[][] = [];
|
|
207
|
+
for (const cell of cells) {
|
|
208
|
+
const space = /\s/u.test(cell.char);
|
|
209
|
+
const last = tokens.at(-1);
|
|
210
|
+
if (last && /\s/u.test(last[0]!.char) === space) last.push(cell);
|
|
211
|
+
else tokens.push([cell]);
|
|
212
|
+
}
|
|
213
|
+
const rows: Cell[][] = [];
|
|
214
|
+
let row: Cell[] = [];
|
|
215
|
+
let used = 0;
|
|
216
|
+
const flush = () => {
|
|
217
|
+
while (row.length > 0 && /\s/u.test(row.at(-1)!.char)) used -= row.pop()!.width;
|
|
218
|
+
rows.push(row);
|
|
219
|
+
row = [];
|
|
220
|
+
used = 0;
|
|
221
|
+
};
|
|
222
|
+
for (const token of tokens) {
|
|
223
|
+
const tokenWidth = cellsWidth(token);
|
|
224
|
+
const space = /\s/u.test(token[0]!.char);
|
|
225
|
+
if (space) {
|
|
226
|
+
if (row.length === 0 && rows.length > 0) continue; // no leading space after a break
|
|
227
|
+
if (row.length === 0 && tokenWidth > width) {
|
|
228
|
+
// Indentation wider than the row: keep what fits rather than an empty row.
|
|
229
|
+
for (const cell of token) {
|
|
230
|
+
if (used + cell.width > width) break;
|
|
231
|
+
row.push(cell);
|
|
232
|
+
used += cell.width;
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (used + tokenWidth <= width) {
|
|
237
|
+
row.push(...token);
|
|
238
|
+
used += tokenWidth;
|
|
239
|
+
} else {
|
|
240
|
+
flush();
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (used + tokenWidth <= width) {
|
|
245
|
+
row.push(...token);
|
|
246
|
+
used += tokenWidth;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (tokenWidth <= width && row.length > 0) {
|
|
250
|
+
flush();
|
|
251
|
+
row.push(...token);
|
|
252
|
+
used = tokenWidth;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// Hard break: the word is wider than a row.
|
|
256
|
+
for (const cell of token) {
|
|
257
|
+
if (used + cell.width > width && row.length > 0) flush();
|
|
258
|
+
row.push(cell);
|
|
259
|
+
used += cell.width;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (row.length > 0 || rows.length === 0) flush();
|
|
263
|
+
return rows.map(fromCells);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Wraps plain text in one tone. */
|
|
267
|
+
export function wrapText(text: string, width: number, tone?: Tone, bold?: boolean): Line[] {
|
|
268
|
+
return wrap([span(text, tone, bold)], width);
|
|
269
|
+
}
|