@hraness/dawg 0.0.0-stage → 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 +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
package/tui/render.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String-returning facade over the cell-buffer highway, kept for the demo
|
|
3
|
+
* path, adapters, and tests that want a whole frame as text.
|
|
4
|
+
*
|
|
5
|
+
* The score shape is deliberately structural. The session and score packages
|
|
6
|
+
* can adapt their richer values to `TrackScoreSnapshot` without making the TUI
|
|
7
|
+
* depend on either package.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { paintHighway, resolveBeat } from "./highway.ts";
|
|
11
|
+
import { CellBuffer, encodeBuffer } from "./screen.ts";
|
|
12
|
+
import { truncate } from "./text.ts";
|
|
13
|
+
import {
|
|
14
|
+
detectTerminalCapabilities,
|
|
15
|
+
effectiveTheme,
|
|
16
|
+
onBackground,
|
|
17
|
+
semanticColor,
|
|
18
|
+
type TerminalCapabilities,
|
|
19
|
+
type ThemeName,
|
|
20
|
+
} from "./theme.ts";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
detectTerminalCapabilities,
|
|
24
|
+
semanticColor,
|
|
25
|
+
type ColorDepth,
|
|
26
|
+
type SemanticRole,
|
|
27
|
+
type TerminalCapabilities,
|
|
28
|
+
} from "./theme.ts";
|
|
29
|
+
export {
|
|
30
|
+
drumLaneProjection,
|
|
31
|
+
explicitLaneProjection,
|
|
32
|
+
pitchProjection,
|
|
33
|
+
projectionFor,
|
|
34
|
+
type LaneProjection,
|
|
35
|
+
type NoteSnapshot,
|
|
36
|
+
type TrackScoreSnapshot,
|
|
37
|
+
} from "./highway.ts";
|
|
38
|
+
import type { TrackScoreSnapshot } from "./highway.ts";
|
|
39
|
+
|
|
40
|
+
export interface HighwayRenderOptions {
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
clock?: () => number;
|
|
44
|
+
capabilities?: TerminalCapabilities;
|
|
45
|
+
reducedMotion?: boolean;
|
|
46
|
+
theme?: ThemeName;
|
|
47
|
+
/** Number of beats visible above the hit line. */
|
|
48
|
+
lookaheadBeats?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stripAnsi(value: string): string {
|
|
52
|
+
const csi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g");
|
|
53
|
+
return value.replace(csi, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a header row plus the highway as a deterministic frame. `clock` is
|
|
58
|
+
* injected so tests can freeze a hit flash or a sustain at an exact phase.
|
|
59
|
+
*/
|
|
60
|
+
export function renderHighway(
|
|
61
|
+
score: TrackScoreSnapshot,
|
|
62
|
+
options: HighwayRenderOptions,
|
|
63
|
+
): string {
|
|
64
|
+
const width = Math.max(1, Math.floor(options.width));
|
|
65
|
+
const height = Math.max(1, Math.floor(options.height));
|
|
66
|
+
const capabilities = options.capabilities ?? detectTerminalCapabilities();
|
|
67
|
+
const theme = effectiveTheme(options.theme ?? "default", capabilities);
|
|
68
|
+
const buffer = new CellBuffer(width, height, theme.roles.canvas);
|
|
69
|
+
if (width < 24 || height < 6) {
|
|
70
|
+
const text = truncate("resize terminal", width);
|
|
71
|
+
buffer.text(
|
|
72
|
+
Math.max(0, Math.floor((width - text.length) / 2)),
|
|
73
|
+
Math.floor(height / 2),
|
|
74
|
+
text,
|
|
75
|
+
theme.roles.warning,
|
|
76
|
+
);
|
|
77
|
+
return encodeBuffer(buffer, capabilities);
|
|
78
|
+
}
|
|
79
|
+
const nowMs = (options.clock ?? Date.now)();
|
|
80
|
+
const name = score.trackName ?? score.trackId ?? "track";
|
|
81
|
+
const unicode = capabilities.unicode;
|
|
82
|
+
const transport = score.playing
|
|
83
|
+
? unicode
|
|
84
|
+
? "▶"
|
|
85
|
+
: ">"
|
|
86
|
+
: unicode
|
|
87
|
+
? "⏸"
|
|
88
|
+
: "||";
|
|
89
|
+
const parts = [
|
|
90
|
+
name,
|
|
91
|
+
`${transport} ${score.bpm ?? 120} BPM`,
|
|
92
|
+
score.key,
|
|
93
|
+
score.revision === undefined ? undefined : `rev ${score.revision}`,
|
|
94
|
+
score.activity,
|
|
95
|
+
].filter((part): part is string => Boolean(part));
|
|
96
|
+
buffer.fill(0, 0, width, 1, theme.roles.panel);
|
|
97
|
+
buffer.text(
|
|
98
|
+
1,
|
|
99
|
+
0,
|
|
100
|
+
truncate(parts.join(" · "), width - 2, unicode ? "…" : "~"),
|
|
101
|
+
onBackground(theme.roles.text, theme.roles.panel),
|
|
102
|
+
);
|
|
103
|
+
paintHighway(
|
|
104
|
+
buffer,
|
|
105
|
+
{ x: 0, y: 1, width, height: height - 1 },
|
|
106
|
+
score,
|
|
107
|
+
resolveBeat(score, nowMs),
|
|
108
|
+
{
|
|
109
|
+
theme,
|
|
110
|
+
capabilities,
|
|
111
|
+
reducedMotion: options.reducedMotion ?? false,
|
|
112
|
+
...(options.lookaheadBeats === undefined
|
|
113
|
+
? {}
|
|
114
|
+
: { lookaheadBeats: options.lookaheadBeats }),
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
return encodeBuffer(buffer, capabilities);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Aliases kept for adapters that call the surface a piano roll. */
|
|
121
|
+
export const renderPianoRoll = renderHighway;
|
|
122
|
+
export const renderTrack = renderHighway;
|
|
123
|
+
export const render = renderHighway;
|
|
124
|
+
export const colorize = semanticColor;
|
package/tui/screen.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retained cell buffer and differential screen writer.
|
|
3
|
+
*
|
|
4
|
+
* Components paint into a `CellBuffer` with semantic `Style` objects. The
|
|
5
|
+
* `ScreenWriter` keeps the previously emitted buffer and only rewrites rows
|
|
6
|
+
* whose cells changed, so an idle highway costs a handful of bytes per frame
|
|
7
|
+
* and a slow terminal never sees a full-screen flood.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { graphemes, graphemeWidth } from "./text.ts";
|
|
11
|
+
import { styleSgr, type Style, type TerminalCapabilities } from "./theme.ts";
|
|
12
|
+
|
|
13
|
+
export interface Cell {
|
|
14
|
+
/** Grapheme text; "" marks the trailing half of a wide character. */
|
|
15
|
+
ch: string;
|
|
16
|
+
style: Style | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY: Style = {};
|
|
20
|
+
|
|
21
|
+
export class CellBuffer {
|
|
22
|
+
readonly width: number;
|
|
23
|
+
readonly height: number;
|
|
24
|
+
private readonly cells: Cell[];
|
|
25
|
+
|
|
26
|
+
constructor(width: number, height: number, fill: Style = EMPTY) {
|
|
27
|
+
this.width = Math.max(0, Math.floor(width));
|
|
28
|
+
this.height = Math.max(0, Math.floor(height));
|
|
29
|
+
this.cells = Array.from({ length: this.width * this.height }, () => ({
|
|
30
|
+
ch: " ",
|
|
31
|
+
style: fill,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get(x: number, y: number): Cell | undefined {
|
|
36
|
+
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return undefined;
|
|
37
|
+
return this.cells[y * this.width + x];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Set one single-width cell, repairing any wide character it overlaps. */
|
|
41
|
+
set(x: number, y: number, ch: string, style: Style | undefined): void {
|
|
42
|
+
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
|
43
|
+
this.clearWide(x, y);
|
|
44
|
+
const cell = this.cells[y * this.width + x]!;
|
|
45
|
+
cell.ch = ch;
|
|
46
|
+
cell.style = style;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Change only the style of a cell (used for glows and backgrounds). */
|
|
50
|
+
restyle(x: number, y: number, style: Style | undefined): void {
|
|
51
|
+
const cell = this.get(x, y);
|
|
52
|
+
if (cell) cell.style = style;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fill(
|
|
56
|
+
x: number,
|
|
57
|
+
y: number,
|
|
58
|
+
width: number,
|
|
59
|
+
height: number,
|
|
60
|
+
style: Style | undefined,
|
|
61
|
+
ch = " ",
|
|
62
|
+
): void {
|
|
63
|
+
for (let row = y; row < y + height; row += 1)
|
|
64
|
+
for (let column = x; column < x + width; column += 1)
|
|
65
|
+
this.set(column, row, ch, style);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write text starting at (x, y), clipped to `maxWidth` cells. Wide
|
|
70
|
+
* graphemes occupy two cells; one that would straddle the clip edge is
|
|
71
|
+
* replaced by a space so columns never shift. Returns cells written.
|
|
72
|
+
*/
|
|
73
|
+
text(
|
|
74
|
+
x: number,
|
|
75
|
+
y: number,
|
|
76
|
+
value: string,
|
|
77
|
+
style: Style | undefined,
|
|
78
|
+
maxWidth = this.width - x,
|
|
79
|
+
): number {
|
|
80
|
+
let column = x;
|
|
81
|
+
const limit = Math.min(this.width, x + Math.max(0, maxWidth));
|
|
82
|
+
for (const cluster of graphemes(value)) {
|
|
83
|
+
if (cluster.width === 0) continue;
|
|
84
|
+
if (column + cluster.width > limit) {
|
|
85
|
+
if (cluster.width === 2 && column < limit)
|
|
86
|
+
this.set(column, y, " ", style);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
this.set(column, y, cluster.text, style);
|
|
90
|
+
if (cluster.width === 2) {
|
|
91
|
+
this.set(column + 1, y, "", style);
|
|
92
|
+
}
|
|
93
|
+
column += cluster.width;
|
|
94
|
+
}
|
|
95
|
+
return column - x;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Plain text rows (wide-character continuations omitted). */
|
|
99
|
+
lines(): string[] {
|
|
100
|
+
const rows: string[] = [];
|
|
101
|
+
for (let y = 0; y < this.height; y += 1) {
|
|
102
|
+
let row = "";
|
|
103
|
+
for (let x = 0; x < this.width; x += 1)
|
|
104
|
+
row += this.cells[y * this.width + x]!.ch;
|
|
105
|
+
rows.push(row);
|
|
106
|
+
}
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
rowEquals(other: CellBuffer, y: number): boolean {
|
|
111
|
+
if (other.width !== this.width) return false;
|
|
112
|
+
const offset = y * this.width;
|
|
113
|
+
for (let x = 0; x < this.width; x += 1) {
|
|
114
|
+
const left = this.cells[offset + x]!;
|
|
115
|
+
const right = other.cells[offset + x]!;
|
|
116
|
+
if (left.ch !== right.ch || !sameStyle(left.style, right.style))
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private clearWide(x: number, y: number): void {
|
|
123
|
+
const offset = y * this.width;
|
|
124
|
+
const cell = this.cells[offset + x]!;
|
|
125
|
+
if (cell.ch === "" && x > 0) {
|
|
126
|
+
const lead = this.cells[offset + x - 1]!;
|
|
127
|
+
if (graphemeWidth(lead.ch) === 2) lead.ch = " ";
|
|
128
|
+
} else if (graphemeWidth(cell.ch) === 2 && x + 1 < this.width) {
|
|
129
|
+
const trail = this.cells[offset + x + 1]!;
|
|
130
|
+
if (trail.ch === "") trail.ch = " ";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sameStyle(
|
|
136
|
+
left: Style | undefined,
|
|
137
|
+
right: Style | undefined,
|
|
138
|
+
): boolean {
|
|
139
|
+
if (left === right) return true;
|
|
140
|
+
if (!left || !right) return false;
|
|
141
|
+
return (
|
|
142
|
+
left.bold === right.bold &&
|
|
143
|
+
left.dim === right.dim &&
|
|
144
|
+
left.italic === right.italic &&
|
|
145
|
+
left.underline === right.underline &&
|
|
146
|
+
left.reverse === right.reverse &&
|
|
147
|
+
sameColor(left.fg, right.fg) &&
|
|
148
|
+
sameColor(left.bg, right.bg)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sameColor(left: Style["fg"], right: Style["fg"]): boolean {
|
|
153
|
+
if (left === right) return true;
|
|
154
|
+
if (!left || !right) return false;
|
|
155
|
+
return left.r === right.r && left.g === right.g && left.b === right.b;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Serialise one row with minimal SGR changes. */
|
|
159
|
+
export function encodeRow(
|
|
160
|
+
buffer: CellBuffer,
|
|
161
|
+
y: number,
|
|
162
|
+
capabilities: TerminalCapabilities,
|
|
163
|
+
): string {
|
|
164
|
+
let out = "";
|
|
165
|
+
let current: Style | undefined | null = null;
|
|
166
|
+
for (let x = 0; x < buffer.width; x += 1) {
|
|
167
|
+
const cell = buffer.get(x, y)!;
|
|
168
|
+
if (cell.ch === "") continue;
|
|
169
|
+
if (current === null || !sameStyle(current, cell.style)) {
|
|
170
|
+
out += styleSgr(cell.style ?? EMPTY, capabilities);
|
|
171
|
+
current = cell.style;
|
|
172
|
+
}
|
|
173
|
+
out += cell.ch;
|
|
174
|
+
}
|
|
175
|
+
if (capabilities.attributes !== false) out += "\u001b[0m";
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Render a buffer as plain-or-styled text lines (for demo and snapshots). */
|
|
180
|
+
export function encodeBuffer(
|
|
181
|
+
buffer: CellBuffer,
|
|
182
|
+
capabilities: TerminalCapabilities,
|
|
183
|
+
): string {
|
|
184
|
+
const rows: string[] = [];
|
|
185
|
+
for (let y = 0; y < buffer.height; y += 1)
|
|
186
|
+
rows.push(encodeRow(buffer, y, capabilities));
|
|
187
|
+
return rows.join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface CursorPosition {
|
|
191
|
+
x: number;
|
|
192
|
+
y: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Differential writer: emits cursor-addressed rewrites for changed rows only.
|
|
197
|
+
* Output is wrapped in synchronized-update markers, which capable terminals
|
|
198
|
+
* use to avoid tearing and others ignore.
|
|
199
|
+
*/
|
|
200
|
+
export class ScreenWriter {
|
|
201
|
+
private previous: CellBuffer | undefined;
|
|
202
|
+
private lastCursor: string | undefined;
|
|
203
|
+
/** Bytes emitted by the last `frame` call; used by tests and diagnostics. */
|
|
204
|
+
lastBytes = 0;
|
|
205
|
+
/** Rows rewritten by the last `frame` call. */
|
|
206
|
+
lastRows = 0;
|
|
207
|
+
|
|
208
|
+
constructor(private capabilities: TerminalCapabilities) {}
|
|
209
|
+
|
|
210
|
+
setCapabilities(capabilities: TerminalCapabilities): void {
|
|
211
|
+
this.capabilities = capabilities;
|
|
212
|
+
this.invalidate();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Force the next frame to repaint every row (resize, theme change). */
|
|
216
|
+
invalidate(): void {
|
|
217
|
+
this.previous = undefined;
|
|
218
|
+
this.lastCursor = undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
frame(buffer: CellBuffer, cursor?: CursorPosition): string {
|
|
222
|
+
const full =
|
|
223
|
+
!this.previous ||
|
|
224
|
+
this.previous.width !== buffer.width ||
|
|
225
|
+
this.previous.height !== buffer.height;
|
|
226
|
+
let body = "";
|
|
227
|
+
let rows = 0;
|
|
228
|
+
if (full) body += "\u001b[0m\u001b[2J";
|
|
229
|
+
for (let y = 0; y < buffer.height; y += 1) {
|
|
230
|
+
if (!full && this.previous!.rowEquals(buffer, y)) continue;
|
|
231
|
+
body += `\u001b[${y + 1};1H${encodeRow(buffer, y, this.capabilities)}`;
|
|
232
|
+
rows += 1;
|
|
233
|
+
}
|
|
234
|
+
const cursorKey = cursor ? `${cursor.x},${cursor.y}` : "hidden";
|
|
235
|
+
let out = "";
|
|
236
|
+
if (rows > 0 || cursorKey !== this.lastCursor) {
|
|
237
|
+
out = `\u001b[?2026h\u001b[?25l${body}`;
|
|
238
|
+
if (cursor) out += `\u001b[${cursor.y + 1};${cursor.x + 1}H\u001b[?25h`;
|
|
239
|
+
out += "\u001b[?2026l";
|
|
240
|
+
}
|
|
241
|
+
this.previous = buffer;
|
|
242
|
+
this.lastCursor = cursorKey;
|
|
243
|
+
this.lastBytes = out.length;
|
|
244
|
+
this.lastRows = rows;
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
}
|
package/tui/text.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grapheme and display-width helpers shared by the prompt editor and the cell
|
|
3
|
+
* buffer. Offsets exposed to callers stay in Unicode code points so the prompt
|
|
4
|
+
* model's cursor contract does not change; widths are terminal cells.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const segmenter =
|
|
8
|
+
typeof Intl !== "undefined" && "Segmenter" in Intl
|
|
9
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
10
|
+
: undefined;
|
|
11
|
+
|
|
12
|
+
export interface Grapheme {
|
|
13
|
+
/** The grapheme cluster text. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Offset of the cluster in code points. */
|
|
16
|
+
start: number;
|
|
17
|
+
/** Number of code points in the cluster. */
|
|
18
|
+
length: number;
|
|
19
|
+
/** Display width in terminal cells (0, 1, or 2). */
|
|
20
|
+
width: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function graphemeWidth(cluster: string): number {
|
|
24
|
+
if (cluster.length === 0) return 0;
|
|
25
|
+
if (cluster === "\t") return 1;
|
|
26
|
+
const code = cluster.codePointAt(0) ?? 0;
|
|
27
|
+
// Control characters never reach the screen as glyphs.
|
|
28
|
+
if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
|
|
29
|
+
const width = Bun.stringWidth(cluster, { ambiguousIsNarrow: true });
|
|
30
|
+
return Math.max(0, Math.min(2, width));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Segment text into grapheme clusters with code-point offsets and widths. */
|
|
34
|
+
export function graphemes(text: string): Grapheme[] {
|
|
35
|
+
const result: Grapheme[] = [];
|
|
36
|
+
let start = 0;
|
|
37
|
+
const clusters = segmenter
|
|
38
|
+
? Array.from(segmenter.segment(text), (part) => part.segment)
|
|
39
|
+
: Array.from(text);
|
|
40
|
+
for (const cluster of clusters) {
|
|
41
|
+
const length = Array.from(cluster).length;
|
|
42
|
+
result.push({
|
|
43
|
+
text: cluster,
|
|
44
|
+
start,
|
|
45
|
+
length,
|
|
46
|
+
width: graphemeWidth(cluster),
|
|
47
|
+
});
|
|
48
|
+
start += length;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function displayWidth(text: string): number {
|
|
54
|
+
let width = 0;
|
|
55
|
+
for (const cluster of graphemes(text)) width += cluster.width;
|
|
56
|
+
return width;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Truncate to a display width, appending an ellipsis when content is cut. */
|
|
60
|
+
export function truncate(text: string, width: number, ellipsis = "…"): string {
|
|
61
|
+
if (width <= 0) return "";
|
|
62
|
+
if (displayWidth(text) <= width) return text;
|
|
63
|
+
const room = Math.max(0, width - displayWidth(ellipsis));
|
|
64
|
+
let used = 0;
|
|
65
|
+
let out = "";
|
|
66
|
+
for (const cluster of graphemes(text)) {
|
|
67
|
+
if (used + cluster.width > room) break;
|
|
68
|
+
out += cluster.text;
|
|
69
|
+
used += cluster.width;
|
|
70
|
+
}
|
|
71
|
+
return room === 0 ? ellipsis.slice(0, width) : `${out}${ellipsis}`;
|
|
72
|
+
}
|