@mythicalos/ui-core 0.2.2 → 0.3.1
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/dist/index.d.ts +6 -0
- package/dist/index.js +23 -0
- package/dist/logic/file-explorer.d.ts +374 -0
- package/dist/logic/file-explorer.js +624 -0
- package/dist/logic/popover.d.ts +242 -0
- package/dist/logic/popover.js +303 -0
- package/dist/logic/queue.d.ts +142 -0
- package/dist/logic/queue.js +143 -0
- package/dist/logic/sendbar.d.ts +105 -0
- package/dist/logic/sendbar.js +136 -0
- package/dist/logic/session-card.d.ts +263 -0
- package/dist/logic/session-card.js +443 -0
- package/dist/logic/terminal.d.ts +222 -0
- package/dist/logic/terminal.js +264 -0
- package/package.json +1 -1
- package/src/select/mythical-select.js +3 -2
- package/styles.css +652 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @mythicalos/ui-core — the terminal half of the terminal set (ds/components-terminal, spec v2):
|
|
2
|
+
// the transcript row model, source→view resolution, segmentation, and every user-visible string.
|
|
3
|
+
// Pure — no framework, no DOM.
|
|
4
|
+
//
|
|
5
|
+
// TOKEN RULE 3 (ds/tokens): "Terminal surfaces always use the --my-term-* set, regardless of app
|
|
6
|
+
// theme." The terminal is heritage-dark in BOTH themes. That is enforced in styles.css by the
|
|
7
|
+
// `.my-term` block, which re-points the theme-flipping locals onto the fixed --my-term-* palette
|
|
8
|
+
// for the whole subtree — `TERM_CLASS` is the single class that carries it, so a binding cannot
|
|
9
|
+
// render a terminal that follows the app theme. The send bar and the queue rows are app chrome and
|
|
10
|
+
// DO follow the theme (matching the design card, where both are drawn on the light surface).
|
|
11
|
+
/** The one class that carries the always-heritage-dark surface (token rule 3). */
|
|
12
|
+
export const TERM_CLASS = "my-term";
|
|
13
|
+
/**
|
|
14
|
+
* Every structural class the pane renders. Named here — not typed literally in a binding — so the
|
|
15
|
+
* Preact and React bindings cannot drift, and so the stylesheet guard can enumerate the real
|
|
16
|
+
* surface instead of a hand-copied list.
|
|
17
|
+
*/
|
|
18
|
+
export const TERM_CLASSES = {
|
|
19
|
+
pane: TERM_CLASS,
|
|
20
|
+
titlebar: "my-term__titlebar",
|
|
21
|
+
lights: "my-term__lights",
|
|
22
|
+
light: "my-term__light",
|
|
23
|
+
lightRed: "my-term__light my-term__light--r",
|
|
24
|
+
lightAmber: "my-term__light my-term__light--a",
|
|
25
|
+
lightGreen: "my-term__light my-term__light--g",
|
|
26
|
+
noiseToggle: "my-term__noise",
|
|
27
|
+
staticTitle: "my-term__title",
|
|
28
|
+
titleRight: "my-term__tb-right",
|
|
29
|
+
turn: "my-term__turn",
|
|
30
|
+
turnDot: "my-term__turn-dot",
|
|
31
|
+
idle: "my-term__idle",
|
|
32
|
+
stopKey: "my-term__stop-key",
|
|
33
|
+
body: "my-term__body",
|
|
34
|
+
banner: "my-term__banner",
|
|
35
|
+
bannerDot: "my-term__banner-dot",
|
|
36
|
+
state: "my-term__state",
|
|
37
|
+
label: "my-term__label",
|
|
38
|
+
head: "my-term__head",
|
|
39
|
+
expand: "my-term__expand",
|
|
40
|
+
detail: "my-term__detail",
|
|
41
|
+
more: "my-term__more",
|
|
42
|
+
segment: "my-term__hist",
|
|
43
|
+
segmentCaption: "my-term__hist-cap",
|
|
44
|
+
caret: "my-term__caret",
|
|
45
|
+
};
|
|
46
|
+
export const TERM_ROW_KINDS = [
|
|
47
|
+
"assistant",
|
|
48
|
+
"user",
|
|
49
|
+
"tool",
|
|
50
|
+
"result",
|
|
51
|
+
"system",
|
|
52
|
+
"boundary",
|
|
53
|
+
"local",
|
|
54
|
+
"dim",
|
|
55
|
+
];
|
|
56
|
+
/** Row class derivation — base + kind modifier. */
|
|
57
|
+
export function termRowClass(kind) {
|
|
58
|
+
return `my-term__row my-term__row--${kind}`;
|
|
59
|
+
}
|
|
60
|
+
/** A row is expandable exactly when the caller gave it a body to reveal. */
|
|
61
|
+
export function isExpandable(row) {
|
|
62
|
+
return typeof row.detail === "string" && row.detail.length > 0;
|
|
63
|
+
}
|
|
64
|
+
/** Label on a row's expand affordance, per its current state. */
|
|
65
|
+
export const ROW_EXPAND_LABEL = "(expand)";
|
|
66
|
+
export const ROW_COLLAPSE_LABEL = "(collapse)";
|
|
67
|
+
export function expandLabel(expanded) {
|
|
68
|
+
return expanded ? ROW_COLLAPSE_LABEL : ROW_EXPAND_LABEL;
|
|
69
|
+
}
|
|
70
|
+
/** Stable expand key for a single row — its own id, else its position. */
|
|
71
|
+
export function rowKey(row, index) {
|
|
72
|
+
return row.id ?? String(index);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Dedupe preferred keys into unique ones, preserving the FIRST occurrence verbatim so a caller's
|
|
76
|
+
* own id keeps identifying the same thing. Repeats take a `#n` disambiguator; the loop (rather than
|
|
77
|
+
* a counter) also covers a literal id that collides with a disambiguator already handed out.
|
|
78
|
+
*/
|
|
79
|
+
export function uniqueKeys(preferred) {
|
|
80
|
+
const arr = Array.isArray(preferred) ? preferred : [];
|
|
81
|
+
const used = new Set();
|
|
82
|
+
return arr.map((base) => {
|
|
83
|
+
let key = base;
|
|
84
|
+
for (let n = 1; used.has(key); n++)
|
|
85
|
+
key = `${base}#${n}`;
|
|
86
|
+
used.add(key);
|
|
87
|
+
return key;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Collision-free keys for one list of rows. `id` is honored so expand state survives rows being
|
|
92
|
+
* prepended, but ids are NOT trusted to be unique: a repeated id would otherwise make two rows
|
|
93
|
+
* expand together and hand the renderer duplicate sibling keys.
|
|
94
|
+
*/
|
|
95
|
+
export function rowKeys(rows) {
|
|
96
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
97
|
+
return uniqueKeys(arr.map((row, index) => rowKey(row, index)));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Identity for each foreign segment: its own id where it has one, its position otherwise, deduped.
|
|
101
|
+
* Using the id (not the position) is what keeps a segment's expand state attached to the segment
|
|
102
|
+
* when the list is reordered.
|
|
103
|
+
*/
|
|
104
|
+
export function segmentKeys(segments) {
|
|
105
|
+
const arr = Array.isArray(segments) ? segments : [];
|
|
106
|
+
return uniqueKeys(arr.map((seg, index) => (seg?.id ? seg.id : String(index))));
|
|
107
|
+
}
|
|
108
|
+
/** The row scopes a pane renders. Each is a distinct namespace for expand state. */
|
|
109
|
+
export const ROW_SCOPE = {
|
|
110
|
+
transcript: "t",
|
|
111
|
+
local: "l",
|
|
112
|
+
history: "h",
|
|
113
|
+
};
|
|
114
|
+
/** `%` and `|` are the escape and the delimiter, so both must be escaped inside a part. */
|
|
115
|
+
function escapePart(part) {
|
|
116
|
+
return part.replace(/%/g, "%25").replace(/\|/g, "%7C");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Namespace a list's row keys under a scope so expand state can NEVER leak between the transcript,
|
|
120
|
+
* the client-synthetic local rows and a history segment.
|
|
121
|
+
*
|
|
122
|
+
* A naive `${scope}:${key}` prefix is not enough: a transcript row whose id is literally `local:a`
|
|
123
|
+
* would then share state with the local row `a`. Every part is escaped before being joined, so the
|
|
124
|
+
* mapping from (scope parts, row key) to string is injective — two different tuples cannot produce
|
|
125
|
+
* the same key, whatever ids the caller supplies.
|
|
126
|
+
*/
|
|
127
|
+
export function scopedRowKeys(scope, rows) {
|
|
128
|
+
const prefix = scope.map(escapePart).join("|");
|
|
129
|
+
return rowKeys(rows).map((key) => `${prefix}|${escapePart(key)}`);
|
|
130
|
+
}
|
|
131
|
+
// ── segmentation ──
|
|
132
|
+
/** Index of the LAST `boundary` row, or -1 when the transcript carries none. */
|
|
133
|
+
export function lastBoundaryIndex(rows) {
|
|
134
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
135
|
+
let idx = -1;
|
|
136
|
+
for (let i = 0; i < arr.length; i++) {
|
|
137
|
+
if (arr[i]?.kind === "boundary")
|
|
138
|
+
idx = i;
|
|
139
|
+
}
|
|
140
|
+
return idx;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Only the current wake: from the LAST `boundary` row (that row included, as the "you woke here"
|
|
144
|
+
* header) onward. With NO boundary the whole transcript IS one wake, so every row is returned —
|
|
145
|
+
* segmentation never silently blanks a log that simply carries no markers.
|
|
146
|
+
*/
|
|
147
|
+
export function currentWakeRows(rows) {
|
|
148
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
149
|
+
const idx = lastBoundaryIndex(arr);
|
|
150
|
+
return idx >= 0 ? arr.slice(idx) : arr;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The renderer option is INVERTED relative to the label: the noise FILTER being enabled means noise
|
|
154
|
+
* is HIDDEN, and the title line then reads `noise on` (the filter is on).
|
|
155
|
+
*/
|
|
156
|
+
export function noiseShow(noiseFilterEnabled) {
|
|
157
|
+
return !noiseFilterEnabled;
|
|
158
|
+
}
|
|
159
|
+
/** Drop `noise` rows unless noise is being shown. */
|
|
160
|
+
export function visibleRows(rows, showNoise) {
|
|
161
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
162
|
+
return showNoise ? arr : arr.filter((r) => !r?.noise);
|
|
163
|
+
}
|
|
164
|
+
export const TERM_LOADING_COPY = "Loading transcript…";
|
|
165
|
+
/** ONE neutral string for "the read succeeded and there is no transcript" — it never speculates
|
|
166
|
+
* about lifecycle ("not started yet", "already cleaned up", …). */
|
|
167
|
+
export const TERM_MISSING_COPY = "No transcript is available for this session.";
|
|
168
|
+
export const TERM_UNADDRESSABLE_COPY = "This transcript can't be attributed to this session.";
|
|
169
|
+
export const TERM_FAILED_COPY = "The transcript could not be loaded.";
|
|
170
|
+
/** Stale banner. NO retry/reconnect claim — the design card's "connection lost · retrying (2/5)"
|
|
171
|
+
* promises a recovery loop this component does not run. */
|
|
172
|
+
export const TERM_STALE_COPY = "Transcript disconnected — showing the last received log.";
|
|
173
|
+
/** Rendered when a live log has zero rows. Reachable ONLY from `ready`/`stale` (see
|
|
174
|
+
* `transcriptView`) — a `loading` pane never renders it. */
|
|
175
|
+
export const TERM_NO_EVENTS_COPY = "(no events)";
|
|
176
|
+
/**
|
|
177
|
+
* HONESTY INVARIANT (binding, do not reword): the wake-unavailable banner says exactly that. Never
|
|
178
|
+
* "reconnecting", never "retrying (n/m)", never a spinner or countdown implying an automatic
|
|
179
|
+
* recovery is under way. The component makes no recovery attempt, so it must not look like one.
|
|
180
|
+
*/
|
|
181
|
+
export const TERM_WAKE_UNAVAILABLE_COPY = "wake unavailable";
|
|
182
|
+
/**
|
|
183
|
+
* HONESTY INVARIANT (binding): only `ready`/`stale` render a log; `stale` additionally flags the
|
|
184
|
+
* disconnected banner. `loading` renders the loading copy and NEVER `(no events)` — an unfinished
|
|
185
|
+
* read is not an empty transcript. `missing` / `unaddressable` / `failed` are three separate
|
|
186
|
+
* unavailable states with three separate messages.
|
|
187
|
+
*/
|
|
188
|
+
export function transcriptView(source) {
|
|
189
|
+
switch (source.kind) {
|
|
190
|
+
case "ready":
|
|
191
|
+
return { kind: "log", stale: false };
|
|
192
|
+
case "stale":
|
|
193
|
+
return { kind: "log", stale: true };
|
|
194
|
+
case "loading":
|
|
195
|
+
return { kind: "state", copy: TERM_LOADING_COPY };
|
|
196
|
+
case "missing":
|
|
197
|
+
return { kind: "state", copy: TERM_MISSING_COPY };
|
|
198
|
+
case "unaddressable":
|
|
199
|
+
return { kind: "state", copy: TERM_UNADDRESSABLE_COPY };
|
|
200
|
+
case "failed":
|
|
201
|
+
return { kind: "state", copy: TERM_FAILED_COPY };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** The source's rows when it carries any (`ready`/`stale`), else `[]`. */
|
|
205
|
+
export function sourceRows(source) {
|
|
206
|
+
return source.kind === "ready" || source.kind === "stale" ? source.rows : [];
|
|
207
|
+
}
|
|
208
|
+
// ── title bar ──
|
|
209
|
+
/** `{name} · tail · noise {on|off}`; a nameless transcript falls back to `transcript`. */
|
|
210
|
+
export function termTitleText(name, noiseFilterEnabled) {
|
|
211
|
+
const trimmed = name?.trim();
|
|
212
|
+
return `${trimmed ? trimmed : "transcript"} · tail · noise ${noiseFilterEnabled ? "on" : "off"}`;
|
|
213
|
+
}
|
|
214
|
+
export const TURN_IN_FLIGHT_COPY = "turn in flight";
|
|
215
|
+
export const TURN_IDLE_COPY = "idle · no turn in flight";
|
|
216
|
+
/**
|
|
217
|
+
* HONESTY INVARIANT: the turn caption rides supplied truth only. `undefined` (the caller does not
|
|
218
|
+
* know) renders NOTHING — the pane never guesses a turn state, and in particular never shows "idle"
|
|
219
|
+
* merely because it was told nothing.
|
|
220
|
+
*/
|
|
221
|
+
export function turnCaption(turnInFlight) {
|
|
222
|
+
if (turnInFlight === true)
|
|
223
|
+
return TURN_IN_FLIGHT_COPY;
|
|
224
|
+
if (turnInFlight === false)
|
|
225
|
+
return TURN_IDLE_COPY;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* NEUTRAL history-toggle copy. Transcript files append generations in FILE order, so a foreign
|
|
230
|
+
* group can include NEWER sessions when the selected one is an older retained session — "earlier
|
|
231
|
+
* sessions" would be untruthful. The copy therefore only states that other sessions share this
|
|
232
|
+
* transcript.
|
|
233
|
+
*/
|
|
234
|
+
export function historyToggleLabel(show, count) {
|
|
235
|
+
return show ? "↓ hide other sessions" : `↑ show other sessions in this transcript (${count})`;
|
|
236
|
+
}
|
|
237
|
+
/** Caption for one foreign transcript segment (gap: never blended into the selected log). */
|
|
238
|
+
export function historySegmentCaption(id) {
|
|
239
|
+
return `session ${id ? id : "—"}`;
|
|
240
|
+
}
|
|
241
|
+
// ── keyboard ──
|
|
242
|
+
/**
|
|
243
|
+
* Focused-terminal Ctrl-C → stop-turn. Requires: `c` + ctrl, NOT a key-repeat (holding Ctrl-C must
|
|
244
|
+
* not fire a burst), NO active text selection (so copy still works), and the pane focused.
|
|
245
|
+
*/
|
|
246
|
+
export function shouldStopOnKey(e, selectionText, paneHasFocus) {
|
|
247
|
+
return !!e && e.key === "c" && e.ctrlKey === true && e.repeat !== true && selectionText.length === 0 && paneHasFocus;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* DEVIATION FROM THE DESIGN CARD (deliberate, carried over from the shipped implementation this was
|
|
251
|
+
* extracted from): the card draws a light-surface "Stop turn" button beside the send bar. The
|
|
252
|
+
* shipped pane instead puts the stop control in the terminal's own title bar, right of the turn
|
|
253
|
+
* indicator, labelled `◼ stop ^C`. Two reasons it stays that way: the control belongs to the
|
|
254
|
+
* transcript it interrupts (the send bar can be disabled while a turn is still running), and the
|
|
255
|
+
* card's own caption already documents the `^C` keybinding, which the label surfaces. The colour
|
|
256
|
+
* treatment follows token rule 3 — inside the terminal the control wears the --my-term-* palette,
|
|
257
|
+
* not the app's danger red, which flips with the app theme.
|
|
258
|
+
*/
|
|
259
|
+
export const TERM_STOP_LABEL = "◼ stop";
|
|
260
|
+
export const TERM_STOP_KEY_HINT = "^C";
|
|
261
|
+
/** Stop control class; `is-prominent` is a VISUAL weight only, never a state claim. */
|
|
262
|
+
export function stopButtonClass(prominent) {
|
|
263
|
+
return prominent ? "my-term__stop is-prominent" : "my-term__stop";
|
|
264
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mythicalos/ui-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "mythicalOS framework-agnostic UI core — the pure logic (class derivation, poll scheduling, dialog/toast/gauge helpers), the token-driven component stylesheet, and the <mythical-select> form-associated web component. Zero framework runtime; the Preact and React bindings derive identical output from this package. Apache-2.0.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -225,8 +225,9 @@ button:focus-visible{outline:2px solid var(--my-accent,#0F6B66);outline-offset:2
|
|
|
225
225
|
.car{flex:none;color:var(--my-muted,#5A5F6A);transition:transform var(--my-t-fast,120ms ease)}
|
|
226
226
|
:host([data-open]) .car{transform:rotate(180deg)}
|
|
227
227
|
/* Pinned to BOTH edges with border-box sizing: under the old
|
|
228
|
-
|
|
229
|
-
OUTSIDE the 100%, so the popup rendered 14px wider than its own control.
|
|
228
|
+
min-width:100% + content-box, the 6px padding and 1px border were added
|
|
229
|
+
OUTSIDE the 100%, so the popup rendered 14px wider than its own control.
|
|
230
|
+
(No backticks in this comment — the whole sheet is a JS template literal.) */
|
|
230
231
|
#pop{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:30;width:100%;
|
|
231
232
|
box-sizing:border-box;max-height:280px;
|
|
232
233
|
overflow:auto;overscroll-behavior:contain;padding:6px;background:var(--my-surface,#fff);
|