@toddzheng024/dscode-bundle 0.7.12 → 0.7.14
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/THIRD_PARTY_NOTICES.md +3 -3
- package/cordis.patch.yml +6 -2
- package/package.json +7 -5
- package/plugins/code-review/index.mjs +5 -2
- package/plugins/compaction/threshold.mjs +12 -0
- package/plugins/credentials/index.mjs +13 -0
- package/plugins/grok/adapter.mjs +114 -0
- package/plugins/grok/auth.mjs +76 -0
- package/plugins/grok/billing.mjs +146 -0
- package/plugins/grok/index.mjs +118 -0
- package/plugins/grok/models.mjs +129 -0
- package/plugins/grok/status.mjs +28 -0
- package/plugins/grok/wire.mjs +268 -0
- package/plugins/i18n/messages.mjs +6 -6
- package/plugins/providers/catalog.mjs +2 -0
- package/plugins/session-metrics/view.mjs +34 -3
- package/vendor/compaction-basic/index.js +91 -1
- package/vendor/persistent/index.js +10 -9
- package/vendor/terminal/index.js +5 -0
- package/vendor/tui/lib/app.mjs +6711 -0
- package/vendor/tui/lib/approval.mjs +122 -0
- package/vendor/tui/lib/attachments.mjs +218 -0
- package/vendor/tui/lib/authorization-panel.mjs +242 -0
- package/vendor/tui/lib/authorization.mjs +111 -0
- package/vendor/tui/lib/commands.mjs +123 -0
- package/vendor/tui/lib/dscode/chat.mjs +84 -0
- package/vendor/tui/lib/dscode/flags.mjs +32 -0
- package/vendor/tui/lib/dscode/model-search.mjs +68 -0
- package/vendor/tui/lib/dscode/paste.mjs +87 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
- package/vendor/tui/lib/dscode/welcome.mjs +100 -0
- package/vendor/tui/lib/editor-keys.mjs +346 -0
- package/vendor/tui/lib/editor.mjs +50 -0
- package/vendor/tui/lib/fork.mjs +24 -0
- package/vendor/tui/lib/git-workflow.mjs +278 -0
- package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
- package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
- package/vendor/tui/lib/index.mjs +2200 -0
- package/vendor/tui/lib/input-split.mjs +170 -0
- package/vendor/tui/lib/internals.mjs +90 -0
- package/vendor/tui/lib/invariant.mjs +22 -0
- package/vendor/tui/lib/kernel-panels.mjs +1265 -0
- package/vendor/tui/lib/keyboard.mjs +283 -0
- package/vendor/tui/lib/language-panel.mjs +41 -0
- package/vendor/tui/lib/locales/en.mjs +495 -0
- package/vendor/tui/lib/locales/zh.mjs +495 -0
- package/vendor/tui/lib/mentions.mjs +150 -0
- package/vendor/tui/lib/model-capabilities.mjs +245 -0
- package/vendor/tui/lib/models.mjs +215 -0
- package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
- package/vendor/tui/lib/permissions.mjs +45 -0
- package/vendor/tui/lib/plugin-inventory.mjs +27 -0
- package/vendor/tui/lib/presets.mjs +46 -0
- package/vendor/tui/lib/provider-settings.mjs +600 -0
- package/vendor/tui/lib/questions.mjs +116 -0
- package/vendor/tui/lib/rainbow.mjs +181 -0
- package/vendor/tui/lib/render/animations.mjs +665 -0
- package/vendor/tui/lib/render/editor.mjs +437 -0
- package/vendor/tui/lib/render/export.mjs +119 -0
- package/vendor/tui/lib/render/fuzzy.mjs +79 -0
- package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
- package/vendor/tui/lib/render/inspector.mjs +93 -0
- package/vendor/tui/lib/render/lines.mjs +538 -0
- package/vendor/tui/lib/render/markdown.mjs +592 -0
- package/vendor/tui/lib/render/projection.mjs +1864 -0
- package/vendor/tui/lib/render/status.mjs +591 -0
- package/vendor/tui/lib/render/text.mjs +157 -0
- package/vendor/tui/lib/render/tool-detail.mjs +187 -0
- package/vendor/tui/lib/render/tool-preview.mjs +79 -0
- package/vendor/tui/lib/render/usage.mjs +336 -0
- package/vendor/tui/lib/render/width.mjs +191 -0
- package/vendor/tui/lib/session-directory.mjs +329 -0
- package/vendor/tui/lib/session-query.mjs +184 -0
- package/vendor/tui/lib/session-switch.mjs +51 -0
- package/vendor/tui/lib/settings-file.mjs +75 -0
- package/vendor/tui/lib/skills.mjs +119 -0
- package/vendor/tui/lib/startup.mjs +111 -0
- package/vendor/tui/lib/store.mjs +129 -0
- package/vendor/tui/lib/subagents.mjs +200 -0
- package/vendor/tui/lib/terminal-title.mjs +186 -0
- package/vendor/tui/lib/theme-panel.mjs +60 -0
- package/vendor/tui/lib/theme.mjs +379 -0
- package/vendor/tui/lib/update-panel.mjs +239 -0
- package/vendor/tui/lib/update.mjs +101 -0
- package/vendor/tui/lib/version.mjs +105 -0
- package/vendor/tui/lib/whale-glyph.mjs +20 -0
- package/vendor/tui/LICENSE +0 -21
- package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
- package/vendor/tui/dscode-email/cli.mjs +0 -32
- package/vendor/tui/dscode-email/contacts.mjs +0 -36
- package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
- package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
- package/vendor/tui/dscode-email/gmail.mjs +0 -194
- package/vendor/tui/dscode-email/imap.mjs +0 -136
- package/vendor/tui/dscode-email/inbox.d.mts +0 -25
- package/vendor/tui/dscode-email/inbox.mjs +0 -76
- package/vendor/tui/dscode-email/smtp.mjs +0 -69
- package/vendor/tui/dscode-email/store.mjs +0 -32
- package/vendor/tui/dscode-providers/catalog.mjs +0 -136
- package/vendor/tui/dscode-providers/effort.mjs +0 -35
- package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
- package/vendor/tui/index.mjs +0 -44441
- package/vendor/tui/invariant.mjs +0 -21
- package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
- package/vendor/tui/session-query.mjs +0 -150
- package/vendor/tui/startup.mjs +0 -109
- package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
- package/vendor/tui/types/app.d.ts +0 -348
- package/vendor/tui/types/approval.d.ts +0 -59
- package/vendor/tui/types/attachments.d.ts +0 -52
- package/vendor/tui/types/authorization-panel.d.ts +0 -22
- package/vendor/tui/types/authorization.d.ts +0 -36
- package/vendor/tui/types/commands.d.ts +0 -52
- package/vendor/tui/types/editor-keys.d.ts +0 -105
- package/vendor/tui/types/editor.d.ts +0 -6
- package/vendor/tui/types/fork.d.ts +0 -8
- package/vendor/tui/types/git-workflow.d.ts +0 -121
- package/vendor/tui/types/index.d.ts +0 -223
- package/vendor/tui/types/input-split.d.ts +0 -54
- package/vendor/tui/types/internals.d.ts +0 -26
- package/vendor/tui/types/invariant.d.ts +0 -15
- package/vendor/tui/types/kernel-panels.d.ts +0 -245
- package/vendor/tui/types/keyboard.d.ts +0 -80
- package/vendor/tui/types/language-panel.d.ts +0 -12
- package/vendor/tui/types/locales/en.d.ts +0 -450
- package/vendor/tui/types/locales/zh.d.ts +0 -9
- package/vendor/tui/types/mentions.d.ts +0 -85
- package/vendor/tui/types/model-capabilities.d.ts +0 -82
- package/vendor/tui/types/models.d.ts +0 -133
- package/vendor/tui/types/permissions.d.ts +0 -27
- package/vendor/tui/types/plugin-inventory.d.ts +0 -11
- package/vendor/tui/types/presets.d.ts +0 -22
- package/vendor/tui/types/provider-settings.d.ts +0 -239
- package/vendor/tui/types/questions.d.ts +0 -54
- package/vendor/tui/types/rainbow.d.ts +0 -69
- package/vendor/tui/types/render/animations.d.ts +0 -307
- package/vendor/tui/types/render/editor.d.ts +0 -163
- package/vendor/tui/types/render/export.d.ts +0 -9
- package/vendor/tui/types/render/fuzzy.d.ts +0 -21
- package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
- package/vendor/tui/types/render/inspector.d.ts +0 -62
- package/vendor/tui/types/render/lines.d.ts +0 -86
- package/vendor/tui/types/render/markdown.d.ts +0 -29
- package/vendor/tui/types/render/projection.d.ts +0 -587
- package/vendor/tui/types/render/status.d.ts +0 -196
- package/vendor/tui/types/render/text.d.ts +0 -64
- package/vendor/tui/types/render/tool-detail.d.ts +0 -94
- package/vendor/tui/types/render/tool-preview.d.ts +0 -28
- package/vendor/tui/types/render/usage.d.ts +0 -113
- package/vendor/tui/types/render/width.d.ts +0 -29
- package/vendor/tui/types/session-directory.d.ts +0 -190
- package/vendor/tui/types/session-query.d.ts +0 -92
- package/vendor/tui/types/session-switch.d.ts +0 -17
- package/vendor/tui/types/settings-file.d.ts +0 -41
- package/vendor/tui/types/skills.d.ts +0 -47
- package/vendor/tui/types/startup.d.ts +0 -65
- package/vendor/tui/types/store.d.ts +0 -58
- package/vendor/tui/types/subagents.d.ts +0 -70
- package/vendor/tui/types/terminal-title.d.ts +0 -66
- package/vendor/tui/types/theme-panel.d.ts +0 -24
- package/vendor/tui/types/theme.d.ts +0 -434
- package/vendor/tui/types/update-panel.d.ts +0 -49
- package/vendor/tui/types/update.d.ts +0 -75
- package/vendor/tui/types/version.d.ts +0 -19
- package/vendor/tui/types/whale-glyph.d.ts +0 -6
- /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
- /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/index.mjs +0 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure composer editor model with Codex textarea semantics: a grapheme
|
|
3
|
+
* cursor over a column-safe multiline layout, word/piece motion, single-entry
|
|
4
|
+
* kill + yank, and the shell-recall boundary gate that keeps Up/Down usable
|
|
5
|
+
* inside a multiline draft.
|
|
6
|
+
*
|
|
7
|
+
* The model is intentionally string-offset based (UTF-16 indices clamped to
|
|
8
|
+
* grapheme boundaries) so the React state stays two primitives
|
|
9
|
+
* (value, cursor) and every operation here stays pure and testable.
|
|
10
|
+
*
|
|
11
|
+
* Word motion follows Codex's piece semantics: whitespace separates runs,
|
|
12
|
+
* punctuation runs stay atomic, and each Han grapheme is its own boundary.
|
|
13
|
+
*
|
|
14
|
+
* @module @deepseek-ai/dsh-code/render/editor
|
|
15
|
+
*/
|
|
16
|
+
import { visibleColumns } from './markdown.mjs';
|
|
17
|
+
const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
18
|
+
? new Intl.Segmenter('en', { granularity: 'grapheme' })
|
|
19
|
+
: undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Split text into grapheme clusters. Falls back to code points when
|
|
22
|
+
* Intl.Segmenter is unavailable; the fallback still keeps surrogate pairs
|
|
23
|
+
* (emoji) atomic so the cursor can never split one.
|
|
24
|
+
*/
|
|
25
|
+
export function splitGraphemes(text) {
|
|
26
|
+
if (text === '')
|
|
27
|
+
return [];
|
|
28
|
+
const spans = [];
|
|
29
|
+
if (segmenter !== undefined) {
|
|
30
|
+
for (const piece of segmenter.segment(text)) {
|
|
31
|
+
spans.push({
|
|
32
|
+
text: piece.segment,
|
|
33
|
+
start: piece.index,
|
|
34
|
+
end: piece.index + piece.segment.length,
|
|
35
|
+
width: visibleColumns(piece.segment),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return spans;
|
|
39
|
+
}
|
|
40
|
+
let start = 0;
|
|
41
|
+
for (const char of text) {
|
|
42
|
+
spans.push({ text: char, start, end: start + char.length, width: visibleColumns(char) });
|
|
43
|
+
start += char.length;
|
|
44
|
+
}
|
|
45
|
+
return spans;
|
|
46
|
+
}
|
|
47
|
+
/** Round one UTF-16 offset down to the grapheme boundary at or before it. */
|
|
48
|
+
function floorBoundary(spans, offset) {
|
|
49
|
+
for (let index = spans.length - 1; index >= 0; index -= 1) {
|
|
50
|
+
const span = spans[index];
|
|
51
|
+
if (span.end <= offset)
|
|
52
|
+
return span.end;
|
|
53
|
+
if (span.start < offset)
|
|
54
|
+
return span.start;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
/** Clamp a cursor offset to the nearest grapheme boundary (surrogates, ZWJ, marks stay whole). */
|
|
59
|
+
export function clampCursor(value, offset) {
|
|
60
|
+
if (value === '')
|
|
61
|
+
return 0;
|
|
62
|
+
const target = Math.max(0, Math.min(value.length, Math.floor(offset)));
|
|
63
|
+
if (target === 0 || target === value.length)
|
|
64
|
+
return target;
|
|
65
|
+
const spans = splitGraphemes(value);
|
|
66
|
+
const down = floorBoundary(spans, target);
|
|
67
|
+
if (down === target)
|
|
68
|
+
return target;
|
|
69
|
+
const up = spans.find(span => span.start >= target)?.start ?? value.length;
|
|
70
|
+
return up - target < target - down ? up : down;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Delete the final grapheme cluster (append-only drafts without a cursor).
|
|
74
|
+
* Surrogate pairs and multi-codepoint emoji stay whole instead of leaving a
|
|
75
|
+
* lone trailing code unit behind.
|
|
76
|
+
*/
|
|
77
|
+
export function deleteLastGrapheme(text) {
|
|
78
|
+
if (text === '')
|
|
79
|
+
return '';
|
|
80
|
+
const spans = splitGraphemes(text);
|
|
81
|
+
return text.slice(0, spans[spans.length - 1].start);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Step the cursor by whole graphemes (negative steps left). The cursor is
|
|
85
|
+
* assumed to sit on a boundary; any drift is clamped first.
|
|
86
|
+
*/
|
|
87
|
+
export function moveCursorBy(value, offset, delta) {
|
|
88
|
+
if (delta === 0 || value === '')
|
|
89
|
+
return clampCursor(value, offset);
|
|
90
|
+
const spans = splitGraphemes(value);
|
|
91
|
+
const boundaries = [0];
|
|
92
|
+
for (const span of spans)
|
|
93
|
+
boundaries.push(span.end);
|
|
94
|
+
const current = boundaries.indexOf(clampCursor(value, offset));
|
|
95
|
+
if (current === -1)
|
|
96
|
+
return clampCursor(value, offset);
|
|
97
|
+
const next = Math.max(0, Math.min(boundaries.length - 1, current + delta));
|
|
98
|
+
return boundaries[next];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Normalize text entering the draft: CRLF/CR become LF, tabs become two
|
|
102
|
+
* spaces (terminal tab stops are contextual and cannot join a deterministic
|
|
103
|
+
* row budget), and every other C0 control byte plus DEL is REMOVED — the
|
|
104
|
+
* draft is data, so a stray ESC (Windows Terminal file drops) disappears
|
|
105
|
+
* instead of rendering as literal backslash-x-1-b text. Newlines survive.
|
|
106
|
+
*/
|
|
107
|
+
export function sanitizeDraftText(text) {
|
|
108
|
+
return text
|
|
109
|
+
.replaceAll('\r\n', '\n')
|
|
110
|
+
.replaceAll('\r', '\n')
|
|
111
|
+
// The draft is DATA, not display: strip C0 control bytes (the stray ESC
|
|
112
|
+
// that rides Windows Terminal file drops) and DEL instead of escaping
|
|
113
|
+
// them into visible "\x1b" text. Newlines survive; tabs widen.
|
|
114
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '')
|
|
115
|
+
.replaceAll('\t', ' ');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Hard-wrap the draft into column-safe physical rows. Wide graphemes never
|
|
119
|
+
* split across rows (a grapheme that does not fit flushes the row first) and
|
|
120
|
+
* explicit newlines end their row without occupying a cell.
|
|
121
|
+
*/
|
|
122
|
+
export function editorModel(value, columns) {
|
|
123
|
+
const width = Math.max(1, Math.floor(columns));
|
|
124
|
+
const spans = splitGraphemes(value);
|
|
125
|
+
const rows = [];
|
|
126
|
+
let text = '';
|
|
127
|
+
let start = 0;
|
|
128
|
+
let used = 0;
|
|
129
|
+
const offsets = [0];
|
|
130
|
+
const rowColumns = [0];
|
|
131
|
+
const rowCuts = [0];
|
|
132
|
+
const flush = (end) => {
|
|
133
|
+
rows.push({ text, start, end, offsets: [...offsets], columns: [...rowColumns], cuts: [...rowCuts] });
|
|
134
|
+
text = '';
|
|
135
|
+
used = 0;
|
|
136
|
+
offsets.length = 0;
|
|
137
|
+
rowColumns.length = 0;
|
|
138
|
+
rowCuts.length = 0;
|
|
139
|
+
};
|
|
140
|
+
for (const span of spans) {
|
|
141
|
+
if (span.text === '\n') {
|
|
142
|
+
flush(span.start);
|
|
143
|
+
start = span.end;
|
|
144
|
+
offsets.push(span.end);
|
|
145
|
+
rowColumns.push(0);
|
|
146
|
+
rowCuts.push(0);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (used > 0 && used + span.width > width) {
|
|
150
|
+
flush(span.start);
|
|
151
|
+
start = span.start;
|
|
152
|
+
offsets.push(span.start);
|
|
153
|
+
rowColumns.push(0);
|
|
154
|
+
rowCuts.push(0);
|
|
155
|
+
}
|
|
156
|
+
text += span.text;
|
|
157
|
+
used += span.width;
|
|
158
|
+
offsets.push(span.end);
|
|
159
|
+
rowColumns.push(used);
|
|
160
|
+
rowCuts.push(text.length);
|
|
161
|
+
}
|
|
162
|
+
if (text !== '' || rows.length === 0)
|
|
163
|
+
flush(value.length);
|
|
164
|
+
else if (offsets.length > 0) {
|
|
165
|
+
// Trailing newline leaves one pending empty boundary row.
|
|
166
|
+
rows.push({ text: '', start, end: value.length, offsets: [...offsets], columns: [...rowColumns], cuts: [...rowCuts] });
|
|
167
|
+
}
|
|
168
|
+
return { rows, length: value.length };
|
|
169
|
+
}
|
|
170
|
+
/** Split one row around the authoritative caret; every other row stays whole. */
|
|
171
|
+
export function editorRowParts(row, rowIndex, caretRow, cursor, caretEnabled = true) {
|
|
172
|
+
if (!caretEnabled || rowIndex !== caretRow)
|
|
173
|
+
return { before: '', caret: '', after: row.text, hasCaret: false };
|
|
174
|
+
const at = row.offsets.indexOf(cursor);
|
|
175
|
+
if (at < 0)
|
|
176
|
+
return { before: '', caret: '', after: row.text, hasCaret: false };
|
|
177
|
+
const before = at > 0 ? row.text.slice(0, row.cuts[at]) : '';
|
|
178
|
+
const caret = at < row.cuts.length - 1 ? row.text.slice(row.cuts[at], row.cuts[at + 1]) : ' ';
|
|
179
|
+
const after = at < row.cuts.length - 1 ? row.text.slice(row.cuts[at + 1]) : '';
|
|
180
|
+
return { before, caret, after, hasCaret: true };
|
|
181
|
+
}
|
|
182
|
+
/** Map a cursor offset to its caret site on the wrapped rows. */
|
|
183
|
+
export function caretSite(model, offset) {
|
|
184
|
+
const target = Math.max(0, Math.min(model.length, offset));
|
|
185
|
+
for (let index = model.rows.length - 1; index >= 0; index -= 1) {
|
|
186
|
+
const row = model.rows[index];
|
|
187
|
+
const at = row.offsets.indexOf(target);
|
|
188
|
+
if (at >= 0)
|
|
189
|
+
return { row: index, column: row.columns[at] };
|
|
190
|
+
}
|
|
191
|
+
const last = model.rows[model.rows.length - 1];
|
|
192
|
+
return { row: model.rows.length - 1, column: last === undefined ? 0 : last.columns[last.columns.length - 1] };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Move the caret across physical rows keeping a preferred display column
|
|
196
|
+
* (Codex `preferred_col`): horizontal moves reset the preference, vertical
|
|
197
|
+
* moves reuse it, clamped to each row's width.
|
|
198
|
+
*/
|
|
199
|
+
export function moveCursorVertically(model, offset, preferredColumn, delta) {
|
|
200
|
+
const site = caretSite(model, offset);
|
|
201
|
+
const target = site.row + delta;
|
|
202
|
+
if (delta === 0)
|
|
203
|
+
return offset;
|
|
204
|
+
if (target < 0)
|
|
205
|
+
return 0;
|
|
206
|
+
if (target >= model.rows.length)
|
|
207
|
+
return model.length;
|
|
208
|
+
const row = model.rows[target];
|
|
209
|
+
const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]));
|
|
210
|
+
let best = 0;
|
|
211
|
+
for (let index = 1; index < row.columns.length; index += 1) {
|
|
212
|
+
if (row.columns[index] <= wanted)
|
|
213
|
+
best = index;
|
|
214
|
+
else
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
return row.offsets[best];
|
|
218
|
+
}
|
|
219
|
+
/** The start/end offsets of the logical line containing the cursor. */
|
|
220
|
+
export function lineBounds(value, offset) {
|
|
221
|
+
const target = Math.max(0, Math.min(value.length, offset));
|
|
222
|
+
const start = value.lastIndexOf('\n', Math.max(0, target - 1)) + 1;
|
|
223
|
+
const end = value.indexOf('\n', target);
|
|
224
|
+
return { start, end: end === -1 ? value.length : end };
|
|
225
|
+
}
|
|
226
|
+
/** Codex WORD_SEPARATORS: punctuation runs are their own word pieces. */
|
|
227
|
+
const WORD_SEPARATORS = new Set('`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?');
|
|
228
|
+
const HAN_GRAPHEME = /^\p{Script=Han}(?:\p{Mark}|\uFE0F)*$/u;
|
|
229
|
+
const UNICODE_PUNCTUATION = /^\p{P}+$/u;
|
|
230
|
+
function classifyGrapheme(text) {
|
|
231
|
+
if (/^\s$/u.test(text))
|
|
232
|
+
return 'space';
|
|
233
|
+
return WORD_SEPARATORS.has(text) || UNICODE_PUNCTUATION.test(text) ? 'punct' : 'word';
|
|
234
|
+
}
|
|
235
|
+
/** Maximal same-class runs; Han graphemes deliberately stay one run each. */
|
|
236
|
+
function pieceRuns(value) {
|
|
237
|
+
const runs = [];
|
|
238
|
+
let current;
|
|
239
|
+
for (const span of splitGraphemes(value)) {
|
|
240
|
+
const klass = span.text === '\n' ? 'space' : classifyGrapheme(span.text);
|
|
241
|
+
const atomic = klass === 'word' && HAN_GRAPHEME.test(span.text);
|
|
242
|
+
if (current !== undefined && current.class === klass && !current.atomic && !atomic) {
|
|
243
|
+
current.end = span.end;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
current = { start: span.start, end: span.end, class: klass, atomic };
|
|
247
|
+
runs.push(current);
|
|
248
|
+
}
|
|
249
|
+
return runs;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Codex `beginning_of_previous_word`: skip whitespace left, then land on the
|
|
253
|
+
* START of the trailing non-space piece (extending over separator pieces).
|
|
254
|
+
*/
|
|
255
|
+
export function moveWordLeft(value, offset) {
|
|
256
|
+
const cursor = clampCursor(value, offset);
|
|
257
|
+
const runs = pieceRuns(value);
|
|
258
|
+
// The last run with content before the cursor includes the current word's
|
|
259
|
+
// left-hand fragment, instead of skipping the whole containing run.
|
|
260
|
+
let index = runs.findLastIndex(run => run.start < cursor);
|
|
261
|
+
if (index < 0)
|
|
262
|
+
return 0;
|
|
263
|
+
if (runs[index].class === 'space') {
|
|
264
|
+
index -= 1;
|
|
265
|
+
if (index < 0)
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
let target = index;
|
|
269
|
+
while (target > 0 && runs[target].class === 'punct' && runs[target - 1].class === 'punct')
|
|
270
|
+
target -= 1;
|
|
271
|
+
return runs[target].start;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Codex `end_of_next_word`: skip whitespace right, then land on the END of
|
|
275
|
+
* the leading non-space piece (extending over separator pieces).
|
|
276
|
+
*/
|
|
277
|
+
export function moveWordRight(value, offset) {
|
|
278
|
+
const cursor = clampCursor(value, offset);
|
|
279
|
+
const runs = pieceRuns(value);
|
|
280
|
+
// The first run with content after the cursor includes the current word's
|
|
281
|
+
// right-hand fragment, instead of jumping straight to the following word.
|
|
282
|
+
let index = runs.findIndex(run => run.end > cursor);
|
|
283
|
+
if (index >= runs.length)
|
|
284
|
+
return value.length;
|
|
285
|
+
if (runs[index].class === 'space') {
|
|
286
|
+
index += 1;
|
|
287
|
+
if (index >= runs.length)
|
|
288
|
+
return value.length;
|
|
289
|
+
}
|
|
290
|
+
let target = index;
|
|
291
|
+
while (target < runs.length - 1 && runs[target].class === 'punct' && runs[target + 1].class === 'punct')
|
|
292
|
+
target += 1;
|
|
293
|
+
return runs[target].end;
|
|
294
|
+
}
|
|
295
|
+
function replaceRange(value, cursor, start, end, killed) {
|
|
296
|
+
const from = Math.min(start, end);
|
|
297
|
+
const to = Math.max(start, end);
|
|
298
|
+
if (from >= to)
|
|
299
|
+
return { value, cursor, killed: undefined };
|
|
300
|
+
const text = value.slice(from, to);
|
|
301
|
+
return {
|
|
302
|
+
value: value.slice(0, from) + value.slice(to),
|
|
303
|
+
cursor: Math.max(0, cursor - Math.max(0, Math.min(cursor, to) - from)),
|
|
304
|
+
killed: killed ? text : undefined,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/** Delete the grapheme cluster before the cursor. */
|
|
308
|
+
export function deleteBackward(value, cursor) {
|
|
309
|
+
if (cursor === 0)
|
|
310
|
+
return { value, cursor, killed: undefined };
|
|
311
|
+
const spans = splitGraphemes(value.slice(0, cursor));
|
|
312
|
+
const start = spans.length === 0 ? 0 : spans[spans.length - 1].start;
|
|
313
|
+
return replaceRange(value, cursor, start, cursor, false);
|
|
314
|
+
}
|
|
315
|
+
/** Delete the grapheme cluster at the cursor. */
|
|
316
|
+
export function deleteForward(value, cursor) {
|
|
317
|
+
if (cursor >= value.length)
|
|
318
|
+
return { value, cursor, killed: undefined };
|
|
319
|
+
const span = splitGraphemes(value).find(candidate => candidate.start >= cursor);
|
|
320
|
+
return replaceRange(value, cursor, cursor, span === undefined ? value.length : span.end, false);
|
|
321
|
+
}
|
|
322
|
+
/** Delete back to the start of the previous word (fills the kill buffer). */
|
|
323
|
+
export function deleteWordBackward(value, cursor) {
|
|
324
|
+
const start = moveWordLeft(value, cursor);
|
|
325
|
+
return replaceRange(value, cursor, start, cursor, true);
|
|
326
|
+
}
|
|
327
|
+
/** Delete forward to the end of the next word (fills the kill buffer). */
|
|
328
|
+
export function deleteWordForward(value, cursor) {
|
|
329
|
+
const end = moveWordRight(value, cursor);
|
|
330
|
+
return replaceRange(value, cursor, cursor, end, true);
|
|
331
|
+
}
|
|
332
|
+
/** Ctrl+U: kill from the line start to the cursor; at BOL, kill the newline. */
|
|
333
|
+
export function killToLineStart(value, cursor) {
|
|
334
|
+
const bounds = lineBounds(value, cursor);
|
|
335
|
+
if (cursor > bounds.start)
|
|
336
|
+
return replaceRange(value, cursor, bounds.start, cursor, true);
|
|
337
|
+
if (bounds.start > 0)
|
|
338
|
+
return replaceRange(value, cursor, bounds.start - 1, bounds.start, true);
|
|
339
|
+
return { value, cursor, killed: undefined };
|
|
340
|
+
}
|
|
341
|
+
/** Ctrl+K: kill from the cursor to the line end; at EOL, kill the newline. */
|
|
342
|
+
export function killToLineEnd(value, cursor) {
|
|
343
|
+
const bounds = lineBounds(value, cursor);
|
|
344
|
+
if (cursor < bounds.end)
|
|
345
|
+
return replaceRange(value, cursor, cursor, bounds.end, true);
|
|
346
|
+
if (bounds.end < value.length)
|
|
347
|
+
return replaceRange(value, cursor, bounds.end, bounds.end + 1, true);
|
|
348
|
+
return { value, cursor, killed: undefined };
|
|
349
|
+
}
|
|
350
|
+
/** Insert sanitized text at the cursor. */
|
|
351
|
+
export function insertText(value, cursor, text) {
|
|
352
|
+
const safe = sanitizeDraftText(text);
|
|
353
|
+
if (safe === '')
|
|
354
|
+
return { value, cursor, killed: undefined };
|
|
355
|
+
return { value: value.slice(0, cursor) + safe + value.slice(cursor), cursor: cursor + safe.length, killed: undefined };
|
|
356
|
+
}
|
|
357
|
+
/** Ctrl+A: current logical line start, then the previous line start on repeat. */
|
|
358
|
+
export function moveToLineStart(value, cursor, crossOnRepeat) {
|
|
359
|
+
const site = clampCursor(value, cursor);
|
|
360
|
+
const bounds = lineBounds(value, site);
|
|
361
|
+
if (!crossOnRepeat || site !== bounds.start || bounds.start === 0)
|
|
362
|
+
return bounds.start;
|
|
363
|
+
return lineBounds(value, bounds.start - 1).start;
|
|
364
|
+
}
|
|
365
|
+
/** Ctrl+E: current logical line end, then the next line end on repeat. */
|
|
366
|
+
export function moveToLineEnd(value, cursor, crossOnRepeat) {
|
|
367
|
+
const site = clampCursor(value, cursor);
|
|
368
|
+
const bounds = lineBounds(value, site);
|
|
369
|
+
if (!crossOnRepeat || site !== bounds.end || bounds.end >= value.length)
|
|
370
|
+
return bounds.end;
|
|
371
|
+
return lineBounds(value, bounds.end + 1).end;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Remap a captured range when all intervening edits are wholly before or
|
|
375
|
+
* wholly after it. An edit overlapping either boundary invalidates the
|
|
376
|
+
* anchor instead of guessing and inserting content at a surprising place.
|
|
377
|
+
*/
|
|
378
|
+
export function remapStableRange(original, current, range) {
|
|
379
|
+
const start = Math.max(0, Math.min(original.length, range.start));
|
|
380
|
+
const end = Math.max(start, Math.min(original.length, range.end));
|
|
381
|
+
if (original === current)
|
|
382
|
+
return { start, end };
|
|
383
|
+
let prefix = 0;
|
|
384
|
+
const shared = Math.min(original.length, current.length);
|
|
385
|
+
while (prefix < shared && original[prefix] === current[prefix])
|
|
386
|
+
prefix += 1;
|
|
387
|
+
let suffix = 0;
|
|
388
|
+
while (suffix < original.length - prefix
|
|
389
|
+
&& suffix < current.length - prefix
|
|
390
|
+
&& original[original.length - 1 - suffix] === current[current.length - 1 - suffix])
|
|
391
|
+
suffix += 1;
|
|
392
|
+
const oldChangedEnd = original.length - suffix;
|
|
393
|
+
if (prefix >= end)
|
|
394
|
+
return { start, end };
|
|
395
|
+
if (oldChangedEnd <= start) {
|
|
396
|
+
const delta = current.length - original.length;
|
|
397
|
+
return { start: start + delta, end: end + delta };
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
/** Replace a current range while preserving a cursor moved after capture. */
|
|
402
|
+
export function replaceRangePreservingCursor(value, cursor, range, replacement) {
|
|
403
|
+
const start = Math.max(0, Math.min(value.length, range.start));
|
|
404
|
+
const end = Math.max(start, Math.min(value.length, range.end));
|
|
405
|
+
const site = clampCursor(value, cursor);
|
|
406
|
+
const nextCursor = site <= start
|
|
407
|
+
? site
|
|
408
|
+
: site >= end
|
|
409
|
+
? site + replacement.length - (end - start)
|
|
410
|
+
: start + replacement.length;
|
|
411
|
+
return {
|
|
412
|
+
value: value.slice(0, start) + replacement + value.slice(end),
|
|
413
|
+
cursor: nextCursor,
|
|
414
|
+
killed: undefined,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Composer editor row budget: the editor itself never grows past this many
|
|
419
|
+
* physical rows; deeper drafts scroll internally to keep the caret visible.
|
|
420
|
+
* Short terminals collapse toward one row so the live transcript keeps room.
|
|
421
|
+
*/
|
|
422
|
+
export function composerMaxRows(terminalRows) {
|
|
423
|
+
return Math.max(1, Math.min(6, Math.floor((Math.max(1, terminalRows) - 10) / 3)));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* History navigation starts with Up on an empty draft, or continues from an
|
|
427
|
+
* unchanged recalled entry whenever the caret sits on either text edge
|
|
428
|
+
* (start or end) - moving the caret into the interior returns the keys to
|
|
429
|
+
* ordinary editing until an edge is reached again.
|
|
430
|
+
*/
|
|
431
|
+
export function shouldRecallNavigate(value, cursor, lastRecalled, direction) {
|
|
432
|
+
if (value === '')
|
|
433
|
+
return direction < 0;
|
|
434
|
+
if (lastRecalled !== value)
|
|
435
|
+
return false;
|
|
436
|
+
return cursor === 0 || cursor === value.length;
|
|
437
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown export of one transcript view: the /export command's pure
|
|
3
|
+
* formatter. Deterministic and side-effect free — the runner owns the file
|
|
4
|
+
* write, so tests drive the builder with folded views directly.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-code/render/export
|
|
7
|
+
*/
|
|
8
|
+
import { assertNever } from '@deepseek-ai/dsh-util-values';
|
|
9
|
+
import { fileLabels, imageLabels } from './projection.mjs';
|
|
10
|
+
import { t } from '../i18n.mjs';
|
|
11
|
+
/**
|
|
12
|
+
* Render the transcript as a standalone markdown document.
|
|
13
|
+
* @param view - the folded transcript view to export.
|
|
14
|
+
* @param sessionId - the full session identity for the header.
|
|
15
|
+
* @returns the complete markdown text.
|
|
16
|
+
*/
|
|
17
|
+
/** Both user and queued rows export the same attachment label block. */
|
|
18
|
+
const attachmentLabels = (entry) => [imageLabels(entry.images), fileLabels(entry.files)].filter(label => label !== '').join('\n');
|
|
19
|
+
export function buildExportMarkdown(view, sessionId) {
|
|
20
|
+
const out = [
|
|
21
|
+
view.title === ''
|
|
22
|
+
? `# dsh session ${sessionId}`
|
|
23
|
+
: `# ${view.title}`,
|
|
24
|
+
`> session ${sessionId}`,
|
|
25
|
+
'',
|
|
26
|
+
];
|
|
27
|
+
// The effective system prompt (v3 surface nodes) heads the export in a
|
|
28
|
+
// collapsed block: visible when audited, out of the way when scrolled.
|
|
29
|
+
if (view.systemPrompt !== '') {
|
|
30
|
+
out.push('<details><summary>system prompt</summary>', '', view.systemPrompt, '', '</details>', '');
|
|
31
|
+
}
|
|
32
|
+
for (const entry of view.entries) {
|
|
33
|
+
switch (entry.kind) {
|
|
34
|
+
case 'user':
|
|
35
|
+
if (entry.notice) {
|
|
36
|
+
out.push(`> ⤷ context: ${entry.text}`, '');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const attachments = attachmentLabels(entry);
|
|
40
|
+
// How the prompt was delivered is part of the record: a queued or
|
|
41
|
+
// steered message reads differently from one typed into an idle
|
|
42
|
+
// composer. Ordinary prompts keep the bare heading.
|
|
43
|
+
const marker = entry.delivery === undefined ? '' : ` (${t(entry.delivery === 'queued' ? 'entry.delivery.queued' : 'entry.delivery.steered')})`;
|
|
44
|
+
out.push(`## user${marker}`, '', entry.text, ...(attachments === '' ? [] : [attachments]), '');
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
case 'assistant':
|
|
48
|
+
if (entry.reasoning !== '') {
|
|
49
|
+
out.push('<details><summary>thinking</summary>', '', entry.reasoning, '', '</details>', '');
|
|
50
|
+
}
|
|
51
|
+
out.push('## assistant', '', entry.text, '');
|
|
52
|
+
break;
|
|
53
|
+
case 'tool':
|
|
54
|
+
out.push(`### tool \`${entry.name}\``, '');
|
|
55
|
+
if (entry.preview !== '')
|
|
56
|
+
out.push(`- args: ${entry.preview}`);
|
|
57
|
+
if (entry.subs.length > 0) {
|
|
58
|
+
for (const sub of entry.subs) {
|
|
59
|
+
const label = sub.preview === '' ? sub.name : `${sub.name} ${sub.preview}`;
|
|
60
|
+
out.push(`- dispatch: ${sub.state === 'error' ? 'error' : sub.state === 'running' ? 'running' : 'ok'} · ${label}${sub.summary === '' ? '' : ` · ${sub.summary}`}`);
|
|
61
|
+
}
|
|
62
|
+
if (entry.subsDropped > 0)
|
|
63
|
+
out.push(`- dispatch: … ${entry.subsDropped} earlier dispatch${entry.subsDropped === 1 ? '' : 'es'}`);
|
|
64
|
+
}
|
|
65
|
+
if (entry.summary !== '')
|
|
66
|
+
out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`);
|
|
67
|
+
out.push('');
|
|
68
|
+
break;
|
|
69
|
+
case 'command':
|
|
70
|
+
out.push(`### /${entry.name}${entry.args === '' ? '' : ` ${entry.args}`}`, '');
|
|
71
|
+
if (entry.summary !== '')
|
|
72
|
+
out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`);
|
|
73
|
+
out.push('');
|
|
74
|
+
break;
|
|
75
|
+
case 'error':
|
|
76
|
+
out.push(`> ⨯ ${entry.text}`, '');
|
|
77
|
+
break;
|
|
78
|
+
case 'turn-marker':
|
|
79
|
+
out.push(`> ${entry.text}`, '');
|
|
80
|
+
break;
|
|
81
|
+
case 'compaction':
|
|
82
|
+
out.push(entry.ok
|
|
83
|
+
? `> compacted ~${entry.tokens} tokens`
|
|
84
|
+
: `> compaction failed: ${entry.error}`, '');
|
|
85
|
+
break;
|
|
86
|
+
case 'retry':
|
|
87
|
+
out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, '');
|
|
88
|
+
break;
|
|
89
|
+
case 'files':
|
|
90
|
+
out.push(`> files changed: ${entry.paths.join(', ')}`, '');
|
|
91
|
+
break;
|
|
92
|
+
case 'workflow':
|
|
93
|
+
out.push(`### workflow \`${entry.name}\` (${entry.state})`, '');
|
|
94
|
+
for (const member of entry.members) {
|
|
95
|
+
const phase = member.phase === '' ? '' : ` [${member.phase}]`;
|
|
96
|
+
out.push(`- ${member.outcome}: ${member.label}${phase}`);
|
|
97
|
+
}
|
|
98
|
+
if (entry.membersDropped > 0)
|
|
99
|
+
out.push(`- … ${entry.membersDropped} earlier member${entry.membersDropped === 1 ? '' : 's'}`);
|
|
100
|
+
out.push('');
|
|
101
|
+
break;
|
|
102
|
+
case 'pending':
|
|
103
|
+
// Codex PendingSteer: queued prompts export like ordinary user rows.
|
|
104
|
+
out.push('## user', '', entry.text, ...(attachmentLabels(entry) === '' ? [] : [attachmentLabels(entry)]), '');
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
assertNever(entry, 'transcript entry kind');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (view.streaming !== '')
|
|
111
|
+
out.push('## assistant (streaming)', '', view.streaming, '');
|
|
112
|
+
const { stats } = view;
|
|
113
|
+
out.push('---', '');
|
|
114
|
+
out.push(`- model: ${view.model === '' ? '(none yet)' : view.model}`);
|
|
115
|
+
out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`);
|
|
116
|
+
out.push(`- tokens: ↑${stats.usage.uncachedInputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens} · cache write ${stats.usage.cacheWriteTokens}`);
|
|
117
|
+
out.push(`- todos: ${view.todos.length}`);
|
|
118
|
+
return out.join('\n');
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fuzzy ranking for `/` and `@` menu candidates: the query must be a
|
|
3
|
+
* case-insensitive ordered subsequence of the candidate name. Prefix hits
|
|
4
|
+
* rank first, then the strongest alignment score, then the source order of
|
|
5
|
+
* the input. Ported from the upstream web client's ui-primitives
|
|
6
|
+
* (rank-by-name.ts) so the terminal matches the web menu's discovery feel;
|
|
7
|
+
* the algorithm is unchanged, only the module's home moved.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/render/fuzzy
|
|
10
|
+
*/
|
|
11
|
+
/** Extra weight for name starts and separator boundaries. */
|
|
12
|
+
function boundaryBonus(name, index) {
|
|
13
|
+
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Score the strongest ordered-subsequence alignment in O(name × query).
|
|
17
|
+
* Boundary and adjacent matches earn weight; skipped and leading characters
|
|
18
|
+
* cost weight. Undefined when the query is not a subsequence of the name.
|
|
19
|
+
*/
|
|
20
|
+
function alignmentScore(name, query) {
|
|
21
|
+
if (query.length > name.length)
|
|
22
|
+
return undefined;
|
|
23
|
+
const noMatch = Number.NEGATIVE_INFINITY;
|
|
24
|
+
let previous = Array(name.length).fill(noMatch);
|
|
25
|
+
for (let index = 0; index < name.length; index++) {
|
|
26
|
+
if (name.charAt(index) === query.charAt(0))
|
|
27
|
+
previous[index] = 1 + boundaryBonus(name, index) - index;
|
|
28
|
+
}
|
|
29
|
+
for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
|
|
30
|
+
const current = Array(name.length).fill(noMatch);
|
|
31
|
+
// Sweep the previous row once: `left` is its score one character back
|
|
32
|
+
// (the adjacent continuation), `leftLeft` two back (the earliest gapped one).
|
|
33
|
+
let left = noMatch;
|
|
34
|
+
let leftLeft = noMatch;
|
|
35
|
+
let bestGapped = noMatch;
|
|
36
|
+
for (const [index, prior] of previous.entries()) {
|
|
37
|
+
if (leftLeft !== noMatch)
|
|
38
|
+
bestGapped = Math.max(bestGapped, leftLeft + index - 2);
|
|
39
|
+
if (name.charAt(index) === query.charAt(queryIndex)) {
|
|
40
|
+
const bonus = 1 + boundaryBonus(name, index);
|
|
41
|
+
let score = noMatch;
|
|
42
|
+
if (left !== noMatch)
|
|
43
|
+
score = left + bonus + 4;
|
|
44
|
+
if (bestGapped !== noMatch)
|
|
45
|
+
score = Math.max(score, bestGapped + bonus + 1 - index);
|
|
46
|
+
current[index] = score;
|
|
47
|
+
}
|
|
48
|
+
leftLeft = left;
|
|
49
|
+
left = prior;
|
|
50
|
+
}
|
|
51
|
+
previous = current;
|
|
52
|
+
}
|
|
53
|
+
let best = noMatch;
|
|
54
|
+
for (const score of previous)
|
|
55
|
+
best = Math.max(best, score);
|
|
56
|
+
return best === noMatch ? undefined : best;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rank named items by a menu query.
|
|
60
|
+
* @param items - candidates in source order (the caller's composition order
|
|
61
|
+
* is the final tie-breaker, e.g. local commands before registry entries).
|
|
62
|
+
* @param rawQuery - the text typed after the trigger, matched case-insensitively.
|
|
63
|
+
* @returns the matching items: prefix hits first, then by alignment score,
|
|
64
|
+
* then in source order. The input list itself for an empty query.
|
|
65
|
+
*/
|
|
66
|
+
export function rankByName(items, rawQuery) {
|
|
67
|
+
const query = rawQuery.toLowerCase();
|
|
68
|
+
if (query === '')
|
|
69
|
+
return items;
|
|
70
|
+
const ranked = [];
|
|
71
|
+
items.forEach((item, index) => {
|
|
72
|
+
const name = item.name.toLowerCase();
|
|
73
|
+
const score = alignmentScore(name, query);
|
|
74
|
+
if (score !== undefined)
|
|
75
|
+
ranked.push({ item, index, prefix: name.startsWith(query), score });
|
|
76
|
+
});
|
|
77
|
+
ranked.sort((left, right) => Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index);
|
|
78
|
+
return ranked.map(match => match.item);
|
|
79
|
+
}
|