@cplieger/web-terminal-ui 3.0.0 → 3.0.2
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/css/03-v3.css +46 -15
- package/package.json +1 -1
- package/src/features/context-menu.ts +28 -7
- package/src/features/tabs.ts +85 -11
package/css/03-v3.css
CHANGED
|
@@ -233,7 +233,7 @@
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
.wt-tab-label {
|
|
236
|
-
max-width:
|
|
236
|
+
max-width: 20ch;
|
|
237
237
|
overflow: hidden;
|
|
238
238
|
text-overflow: ellipsis;
|
|
239
239
|
}
|
|
@@ -262,11 +262,19 @@
|
|
|
262
262
|
border-radius: var(--radius-inner);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
/*
|
|
266
|
-
rows. The
|
|
267
|
-
|
|
268
|
-
:
|
|
269
|
-
|
|
265
|
+
/* Push the terminal surface below the fixed tab strip so it does not overlap
|
|
266
|
+
the first rows. The tabs feature marks the surface (.wt-with-topbar) and keeps
|
|
267
|
+
the measured strip height in --wt-tabbar-h. The surface is position:fixed
|
|
268
|
+
inset:0, so a top offset is what clears it. Desktop only: the strip is hidden
|
|
269
|
+
on a coarse pointer, where the switcher docks to the bottom instead, so the
|
|
270
|
+
surface stays full-height there. (The prior `.wt-tab-bar ~ .term` /
|
|
271
|
+
`:has(> .wt-tab-bar) .term` rule never matched: the bar lives inside a
|
|
272
|
+
display:contents region, so it is neither a DOM sibling nor an ancestor of
|
|
273
|
+
.term.) */
|
|
274
|
+
@media (pointer: fine) {
|
|
275
|
+
.term.wt-with-topbar {
|
|
276
|
+
top: var(--wt-tabbar-h, 40px);
|
|
277
|
+
}
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
/* ===== Activity dots (activityMonitor via tabs) =====
|
|
@@ -278,36 +286,59 @@
|
|
|
278
286
|
height: 9px;
|
|
279
287
|
border-radius: 50%;
|
|
280
288
|
flex: none;
|
|
281
|
-
|
|
289
|
+
/* Default / idle (a new session with no turn yet): a hollow grey dot, so
|
|
290
|
+
"nothing has happened" reads as an outline rather than a filled state.
|
|
291
|
+
The fill (solid vs hollow) is the non-hue cue that separates the idle state
|
|
292
|
+
from the filled done/working/input states (WCAG 1.4.1). */
|
|
293
|
+
background: transparent;
|
|
294
|
+
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
282
295
|
}
|
|
283
296
|
|
|
297
|
+
.wt-status-dot[data-status="idle"] {
|
|
298
|
+
background: transparent;
|
|
299
|
+
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* Agent working: solid pulsing purple, matching Kiro IDE's "thinking" color
|
|
303
|
+
(#a78bfa). Motion is the primary non-hue cue. Server-side this is driven by
|
|
304
|
+
the OSC 9;4 progress signal, so it tracks agent activity, not the user typing
|
|
305
|
+
at the prompt. */
|
|
284
306
|
.wt-status-dot[data-status="working"] {
|
|
285
|
-
background:
|
|
307
|
+
background: #a78bfa;
|
|
308
|
+
border-color: #a78bfa;
|
|
286
309
|
animation: wt-pulse 1.2s ease-in-out infinite;
|
|
287
310
|
}
|
|
288
311
|
|
|
289
|
-
|
|
290
|
-
|
|
312
|
+
/* Agent done / ready: solid green, matching Kiro IDE's "Ready" (#22c55e), no
|
|
313
|
+
motion. The filled dot distinguishes it from the hollow idle without hue. */
|
|
314
|
+
.wt-status-dot[data-status="done"] {
|
|
315
|
+
background: #22c55e;
|
|
316
|
+
border-color: #22c55e;
|
|
291
317
|
}
|
|
292
318
|
|
|
319
|
+
/* User action required (permission / approval): solid amber matching Kiro IDE's
|
|
320
|
+
"waiting-approval" (#fbbf24), plus a ring so it reads as "needs you" via shape
|
|
321
|
+
as well as hue. It persists until you act on or switch into the tab. */
|
|
293
322
|
.wt-status-dot[data-status="input"] {
|
|
294
|
-
background:
|
|
295
|
-
|
|
296
|
-
box-shadow: 0 0 0 2px oklch(
|
|
323
|
+
background: #fbbf24;
|
|
324
|
+
border-color: #fbbf24;
|
|
325
|
+
box-shadow: 0 0 0 2px oklch(85% 0.16 85deg / 40%);
|
|
297
326
|
}
|
|
298
327
|
|
|
328
|
+
/* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
|
|
299
329
|
.wt-status-dot[data-status="exited"] {
|
|
300
330
|
background: transparent;
|
|
301
|
-
border:
|
|
331
|
+
border: 1.5px solid color-mix(in oklch, var(--text) 30%, transparent);
|
|
332
|
+
opacity: 0.5;
|
|
302
333
|
}
|
|
303
334
|
|
|
304
|
-
/* Reduced-motion (or animations feature off): drop the pulse, keep the color. */
|
|
305
335
|
@keyframes wt-pulse {
|
|
306
336
|
50% {
|
|
307
337
|
opacity: 0.4;
|
|
308
338
|
}
|
|
309
339
|
}
|
|
310
340
|
|
|
341
|
+
/* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
|
|
311
342
|
@media (prefers-reduced-motion: reduce) {
|
|
312
343
|
.wt-status-dot[data-status="working"] {
|
|
313
344
|
animation: none;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -18,6 +18,10 @@ import type { ClipboardApi } from "./clipboard.js";
|
|
|
18
18
|
const LONG_PRESS_MS = 500;
|
|
19
19
|
const LONG_PRESS_MOVE_PX = 10;
|
|
20
20
|
const EDGE_MARGIN = 8;
|
|
21
|
+
// When the menu can't fit below the anchor (a long-press near the bottom edge or
|
|
22
|
+
// above the keyboard), it opens above the point. This gap lifts it clear of the
|
|
23
|
+
// fingertip so the touch does not cover the menu items.
|
|
24
|
+
const MENU_GAP = 16;
|
|
21
25
|
|
|
22
26
|
export interface ContextMenuOptions {
|
|
23
27
|
/** The clipboard feature value, so the menu can offer Copy/Paste through its
|
|
@@ -91,16 +95,33 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
91
95
|
if (menu.childElementCount === 0) {
|
|
92
96
|
return; // nothing to offer
|
|
93
97
|
}
|
|
94
|
-
// Make visible (so it has measurable size) then
|
|
95
|
-
// viewport. position:fixed => x/y are viewport coordinates
|
|
98
|
+
// Make visible (so it has measurable size), then place it within the
|
|
99
|
+
// VISIBLE viewport. position:fixed => x/y are viewport coordinates; the
|
|
100
|
+
// visual viewport (when present) is the area above the on-screen
|
|
101
|
+
// keyboard, so clamping to it keeps the menu off the keyboard too.
|
|
96
102
|
menu.classList.add("visible");
|
|
103
|
+
const vv = window.visualViewport;
|
|
104
|
+
const viewLeft = vv ? vv.offsetLeft : 0;
|
|
105
|
+
const viewTop = vv ? vv.offsetTop : 0;
|
|
106
|
+
const viewWidth = vv ? vv.width : window.innerWidth;
|
|
107
|
+
const viewHeight = vv ? vv.height : window.innerHeight;
|
|
108
|
+
const menuW = menu.offsetWidth;
|
|
109
|
+
const menuH = menu.offsetHeight;
|
|
97
110
|
const left = Math.max(
|
|
98
|
-
EDGE_MARGIN,
|
|
99
|
-
Math.min(x,
|
|
111
|
+
viewLeft + EDGE_MARGIN,
|
|
112
|
+
Math.min(x, viewLeft + viewWidth - menuW - EDGE_MARGIN),
|
|
100
113
|
);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
// Open just below the point; but if that would overflow the visible
|
|
115
|
+
// bottom (a long-press near the bottom edge, where the user naturally
|
|
116
|
+
// taps), flip above the finger so the menu is neither clipped off-screen
|
|
117
|
+
// nor hidden under the fingertip.
|
|
118
|
+
let top = y;
|
|
119
|
+
if (y + menuH + EDGE_MARGIN > viewTop + viewHeight) {
|
|
120
|
+
top = y - menuH - MENU_GAP;
|
|
121
|
+
}
|
|
122
|
+
top = Math.max(
|
|
123
|
+
viewTop + EDGE_MARGIN,
|
|
124
|
+
Math.min(top, viewTop + viewHeight - menuH - EDGE_MARGIN),
|
|
104
125
|
);
|
|
105
126
|
menu.style.left = `${String(left)}px`;
|
|
106
127
|
menu.style.top = `${String(top)}px`;
|
package/src/features/tabs.ts
CHANGED
|
@@ -32,7 +32,16 @@ interface SessionInfo {
|
|
|
32
32
|
|
|
33
33
|
interface Tab {
|
|
34
34
|
id: string;
|
|
35
|
+
/** The raw server title (the OSC 0/2 window title the process set), possibly
|
|
36
|
+
* empty before the process sets one. The displayed label is derived from it
|
|
37
|
+
* with a numbered fallback and de-duplication (see relabelAll). */
|
|
35
38
|
title: string;
|
|
39
|
+
/** A stable per-session number for the "Tab N" fallback when the process has
|
|
40
|
+
* set no title. Monotonic across the page so a tab's number never changes
|
|
41
|
+
* under it when a sibling closes. */
|
|
42
|
+
num: number;
|
|
43
|
+
/** The computed, de-duplicated label actually shown in the chrome. */
|
|
44
|
+
display: string;
|
|
36
45
|
createdAt: string;
|
|
37
46
|
store: LineStore;
|
|
38
47
|
el: HTMLElement;
|
|
@@ -152,8 +161,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
152
161
|
bar.className = "wt-tab-bar";
|
|
153
162
|
bar.setAttribute("role", "tablist");
|
|
154
163
|
slot.appendChild(bar);
|
|
164
|
+
// The "+" button is the last flex item INSIDE the strip. Appending it to
|
|
165
|
+
// the slot (a sibling of the fixed .wt-tab-bar) flowed it out of the strip
|
|
166
|
+
// to the root, so it never appeared; inside the flex bar it renders at the
|
|
167
|
+
// end of the tabs (addTabChrome inserts each tab before it).
|
|
155
168
|
const newBtn = fromHTML(NEW_HTML);
|
|
156
|
-
|
|
169
|
+
bar.appendChild(newBtn);
|
|
170
|
+
|
|
171
|
+
// Push the terminal surface below the fixed strip on desktop so the bar
|
|
172
|
+
// does not overlap the first rows. The surface is position:fixed inset:0,
|
|
173
|
+
// so a top offset (gated to a fine pointer in CSS, since the strip is
|
|
174
|
+
// hidden on a coarse pointer where the switcher docks to the bottom
|
|
175
|
+
// instead) is what clears it. A ResizeObserver keeps the offset in step
|
|
176
|
+
// with the real strip height rather than a hard-coded guess.
|
|
177
|
+
const surface = ctx.surface();
|
|
178
|
+
surface.classList.add("wt-with-topbar");
|
|
179
|
+
const barResize = new ResizeObserver(() => {
|
|
180
|
+
surface.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
|
|
181
|
+
});
|
|
182
|
+
barResize.observe(bar);
|
|
157
183
|
|
|
158
184
|
// --- Mobile bottom-switcher (bottom-switcher region) ---
|
|
159
185
|
const switcher = fromHTML(SWITCHER_HTML);
|
|
@@ -190,6 +216,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
190
216
|
let overviewOpen = false;
|
|
191
217
|
let lastFocus: HTMLElement | null = null;
|
|
192
218
|
let hintShown = false;
|
|
219
|
+
let tabSeq = 0; // monotonic source for the "Tab N" fallback number
|
|
220
|
+
|
|
221
|
+
// labelFor is a tab's base label before de-duplication: the server title
|
|
222
|
+
// (the OSC 0/2 window title the process set, e.g. kiro-cli's
|
|
223
|
+
// "kiro: <first message>" when chat.terminalTitle is on) when present,
|
|
224
|
+
// else a stable "Tab N". This is the fix for every tab reading "terminal":
|
|
225
|
+
// a real title flows through, and untitled tabs are numbered, not generic.
|
|
226
|
+
function labelFor(tab: Tab): string {
|
|
227
|
+
return tab.title.trim() || `Tab ${String(tab.num)}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// relabelAll recomputes every tab's display label with de-duplication:
|
|
231
|
+
// when two tabs resolve to the same base label (fresh vibecli tabs share
|
|
232
|
+
// the cwd-derived title until their first message gives each a distinct
|
|
233
|
+
// one), the second and later get a " (k)" suffix in creation order, so the
|
|
234
|
+
// strip never shows two identical labels.
|
|
235
|
+
function relabelAll(): void {
|
|
236
|
+
const counts = new Map<string, number>();
|
|
237
|
+
for (const t of tabList) {
|
|
238
|
+
const base = labelFor(t);
|
|
239
|
+
counts.set(base, (counts.get(base) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
const seen = new Map<string, number>();
|
|
242
|
+
for (const t of tabList) {
|
|
243
|
+
const base = labelFor(t);
|
|
244
|
+
let display = base;
|
|
245
|
+
if ((counts.get(base) ?? 0) > 1) {
|
|
246
|
+
const k = (seen.get(base) ?? 0) + 1;
|
|
247
|
+
seen.set(base, k);
|
|
248
|
+
if (k > 1) {
|
|
249
|
+
display = `${base} (${String(k)})`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
t.display = display;
|
|
253
|
+
t.label.textContent = display;
|
|
254
|
+
t.aria.setLabel(display);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
193
257
|
|
|
194
258
|
function focusInput(): void {
|
|
195
259
|
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
@@ -210,7 +274,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
210
274
|
function syncMobile(): void {
|
|
211
275
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
212
276
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
213
|
-
swLabel.textContent = active ? active.
|
|
277
|
+
swLabel.textContent = active ? active.display : "";
|
|
214
278
|
swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
|
|
215
279
|
swPos.textContent =
|
|
216
280
|
tabList.length > 1 ? `${String(idx + 1)} / ${String(tabList.length)}` : "";
|
|
@@ -240,7 +304,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
240
304
|
const rselect = pick(row, ".wt-sheet-select");
|
|
241
305
|
const rclose = pick(row, ".wt-sheet-close");
|
|
242
306
|
rdot.dataset["status"] = t.dot.dataset["status"] ?? "idle";
|
|
243
|
-
rlabel.textContent = t.
|
|
307
|
+
rlabel.textContent = t.display;
|
|
244
308
|
if (t.id === activeId) {
|
|
245
309
|
row.classList.add("wt-sheet-row-active");
|
|
246
310
|
rselect.setAttribute("aria-current", "true");
|
|
@@ -261,6 +325,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
261
325
|
|
|
262
326
|
// syncChrome refreshes every surface after any state change. Idempotent.
|
|
263
327
|
function syncChrome(): void {
|
|
328
|
+
relabelAll();
|
|
264
329
|
paintActive();
|
|
265
330
|
syncMobile();
|
|
266
331
|
if (overviewOpen) {
|
|
@@ -303,16 +368,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
303
368
|
if (!label || !dot || !close) {
|
|
304
369
|
throw new Error("web-terminal-ui: tab chrome missing parts");
|
|
305
370
|
}
|
|
306
|
-
const title = info.title || "terminal";
|
|
307
|
-
label.textContent = title;
|
|
308
371
|
dot.dataset["status"] = info.status || "idle";
|
|
309
372
|
const aria = tablist.registerTab(el);
|
|
310
|
-
|
|
311
|
-
bar.
|
|
373
|
+
// Insert before the "+" button so it stays the last item in the strip.
|
|
374
|
+
bar.insertBefore(el, newBtn);
|
|
312
375
|
|
|
313
376
|
const tab: Tab = {
|
|
314
377
|
id: info.id,
|
|
315
|
-
title,
|
|
378
|
+
title: info.title,
|
|
379
|
+
num: ++tabSeq,
|
|
380
|
+
display: "",
|
|
316
381
|
createdAt: info.createdAt,
|
|
317
382
|
store: new LineStore(),
|
|
318
383
|
el,
|
|
@@ -322,6 +387,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
322
387
|
scrollTop: 0,
|
|
323
388
|
following: true,
|
|
324
389
|
};
|
|
390
|
+
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
391
|
+
// once the tab is in tabList and syncChrome runs).
|
|
392
|
+
tab.display = labelFor(tab);
|
|
393
|
+
label.textContent = tab.display;
|
|
394
|
+
aria.setLabel(tab.display);
|
|
325
395
|
el.addEventListener("click", (e) => {
|
|
326
396
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
327
397
|
return; // handled by the close button
|
|
@@ -496,10 +566,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
496
566
|
return;
|
|
497
567
|
}
|
|
498
568
|
t.dot.dataset["status"] = status || "idle";
|
|
499
|
-
|
|
569
|
+
// Record the raw server title; the displayed label (fallback + de-dup)
|
|
570
|
+
// is recomputed by relabelAll via syncChrome, which the callers run
|
|
571
|
+
// right after applyStatus.
|
|
572
|
+
if (title !== undefined) {
|
|
500
573
|
t.title = title;
|
|
501
|
-
t.label.textContent = title;
|
|
502
|
-
t.aria.setLabel(title);
|
|
503
574
|
}
|
|
504
575
|
}
|
|
505
576
|
|
|
@@ -658,6 +729,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
658
729
|
if (pollTimer !== null) {
|
|
659
730
|
clearInterval(pollTimer);
|
|
660
731
|
}
|
|
732
|
+
barResize.disconnect();
|
|
733
|
+
surface.classList.remove("wt-with-topbar");
|
|
734
|
+
surface.style.removeProperty("--wt-tabbar-h");
|
|
661
735
|
clearCatchup();
|
|
662
736
|
closeOverview();
|
|
663
737
|
for (const t of tabList) {
|