sh3-core 0.15.0 → 0.15.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/dist/actions/ctx-actions.svelte.test.js +111 -0
- package/dist/actions/dispatcher.svelte.js +23 -2
- package/dist/actions/dispatcher.test.js +33 -0
- package/dist/actions/listActionsFromEntries.test.js +78 -0
- package/dist/actions/listActive.d.ts +2 -1
- package/dist/actions/listActive.js +43 -17
- package/dist/actions/listeners.d.ts +16 -0
- package/dist/actions/listeners.js +68 -14
- package/dist/actions/programmatic-dispatch.svelte.test.d.ts +1 -0
- package/dist/actions/programmatic-dispatch.svelte.test.js +98 -0
- package/dist/actions/types.d.ts +37 -0
- package/dist/api.d.ts +1 -1
- package/dist/app/store/verbs.js +4 -0
- package/dist/app-appearance/appearanceShard.svelte.js +19 -6
- package/dist/app-appearance/appearanceState.svelte.js +3 -3
- package/dist/host.js +2 -1
- package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
- package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
- package/dist/layouts-shard/LayoutsSection.svelte +142 -0
- package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
- package/dist/layouts-shard/filter.d.ts +3 -0
- package/dist/layouts-shard/filter.js +66 -0
- package/dist/layouts-shard/filter.test.d.ts +1 -0
- package/dist/layouts-shard/filter.test.js +123 -0
- package/dist/layouts-shard/index.d.ts +1 -0
- package/dist/layouts-shard/index.js +1 -0
- package/dist/layouts-shard/layoutsApi.d.ts +12 -0
- package/dist/layouts-shard/layoutsApi.js +41 -0
- package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
- package/dist/layouts-shard/layoutsApi.test.js +74 -0
- package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
- package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
- package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
- package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
- package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
- package/dist/layouts-shard/layoutsState.svelte.js +50 -0
- package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
- package/dist/layouts-shard/layoutsState.test.js +43 -0
- package/dist/layouts-shard/types.d.ts +21 -0
- package/dist/layouts-shard/types.js +6 -0
- package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
- package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
- package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
- package/dist/overlays/EntityAppearanceModal.test.js +57 -0
- package/dist/overlays/FloatFrame.svelte +149 -8
- package/dist/overlays/FloatFrame.svelte.d.ts +1 -1
- package/dist/overlays/FloatLayer.svelte +2 -2
- package/dist/overlays/float.d.ts +38 -1
- package/dist/overlays/float.js +82 -0
- package/dist/overlays/float.test.js +394 -0
- package/dist/overlays/floatMaximized.svelte.d.ts +4 -0
- package/dist/overlays/floatMaximized.svelte.js +30 -0
- package/dist/runtime/runVerb-shell.test.d.ts +1 -0
- package/dist/runtime/runVerb-shell.test.js +231 -0
- package/dist/sh3core-shard/ShellHome.svelte +3 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.d.ts +7 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +23 -0
- package/dist/shards/activate-runtime.test.js +24 -2
- package/dist/shards/activate.svelte.js +18 -4
- package/dist/shards/types.d.ts +44 -4
- package/dist/shell-shard/CommandLine.svelte +143 -0
- package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
- package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
- package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
- package/dist/shell-shard/InputLine.svelte +17 -40
- package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
- package/dist/shell-shard/ScrollbackView.svelte +10 -3
- package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
- package/dist/shell-shard/Terminal.svelte +94 -22
- package/dist/shell-shard/buffer-store.d.ts +15 -0
- package/dist/shell-shard/buffer-store.js +124 -0
- package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
- package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
- package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
- package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
- package/dist/shell-shard/contract.d.ts +7 -0
- package/dist/shell-shard/dispatch-custom.test.js +3 -1
- package/dist/shell-shard/dispatch-gating.test.js +6 -2
- package/dist/shell-shard/dispatch-invoke.test.js +10 -8
- package/dist/shell-shard/dispatch.d.ts +7 -2
- package/dist/shell-shard/dispatch.js +23 -27
- package/dist/shell-shard/display-cwd.d.ts +1 -0
- package/dist/shell-shard/display-cwd.js +27 -0
- package/dist/shell-shard/display-cwd.test.d.ts +1 -0
- package/dist/shell-shard/display-cwd.test.js +29 -0
- package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
- package/dist/shell-shard/manifest.js +2 -1
- package/dist/shell-shard/manifest.test.d.ts +1 -0
- package/dist/shell-shard/manifest.test.js +8 -0
- package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
- package/dist/shell-shard/mode-buffer.svelte.js +19 -0
- package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
- package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
- package/dist/shell-shard/modes/builtin.js +2 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/protocol.d.ts +12 -6
- package/dist/shell-shard/replay.d.ts +3 -0
- package/dist/shell-shard/replay.js +44 -0
- package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
- package/dist/shell-shard/replay.svelte.test.js +47 -0
- package/dist/shell-shard/rich-registry.d.ts +5 -0
- package/dist/shell-shard/rich-registry.js +25 -0
- package/dist/shell-shard/rich-registry.test.d.ts +1 -0
- package/dist/shell-shard/rich-registry.test.js +31 -0
- package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
- package/dist/shell-shard/scrollback.svelte.js +23 -0
- package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
- package/dist/shell-shard/scrollback.svelte.test.js +51 -0
- package/dist/shell-shard/session-client.svelte.d.ts +18 -2
- package/dist/shell-shard/session-client.svelte.js +21 -4
- package/dist/shell-shard/shellApi.d.ts +2 -1
- package/dist/shell-shard/shellApi.js +32 -3
- package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
- package/dist/shell-shard/shellApi.svelte.test.js +59 -0
- package/dist/shell-shard/shellShard.svelte.js +11 -1
- package/dist/shell-shard/terminal-dispatch.test.js +3 -1
- package/dist/shell-shard/verbs/apps.js +9 -0
- package/dist/shell-shard/verbs/env.js +4 -0
- package/dist/shell-shard/verbs/help.js +9 -1
- package/dist/shell-shard/verbs/help.svelte.test.d.ts +1 -0
- package/dist/shell-shard/verbs/help.svelte.test.js +53 -0
- package/dist/shell-shard/verbs/history.js +8 -1
- package/dist/shell-shard/verbs/index.js +0 -8
- package/dist/shell-shard/verbs/shards.js +5 -0
- package/dist/shell-shard/verbs/views.js +9 -0
- package/dist/shell-shard/verbs/zones.js +9 -0
- package/dist/verbs/types.d.ts +9 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
- package/dist/shell-shard/verbs/cat.d.ts +0 -2
- package/dist/shell-shard/verbs/cat.js +0 -34
- package/dist/shell-shard/verbs/cd.test.js +0 -56
- package/dist/shell-shard/verbs/ls.d.ts +0 -2
- package/dist/shell-shard/verbs/ls.js +0 -29
- package/dist/shell-shard/verbs/ls.test.js +0 -49
- package/dist/shell-shard/verbs/session.d.ts +0 -4
- package/dist/shell-shard/verbs/session.js +0 -97
- /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
- /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy, untrack } from 'svelte';
|
|
3
|
-
import {
|
|
3
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
4
|
+
import { BufferStore } from './buffer-store';
|
|
5
|
+
import { applyReplayEvents } from './replay';
|
|
6
|
+
import { shortenCwd } from './display-cwd';
|
|
4
7
|
import ScrollbackView from './ScrollbackView.svelte';
|
|
5
8
|
import InputLine from './InputLine.svelte';
|
|
6
9
|
import { SessionClient } from './session-client.svelte';
|
|
@@ -33,7 +36,24 @@
|
|
|
33
36
|
}
|
|
34
37
|
let { shell, wsUrl, userId, role, contributions }: Props = $props();
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
// Per-mode buffer map. Each ModeBuffer bundles a Scrollback + history +
|
|
40
|
+
// locked flag and is materialized lazily on first switch into that mode.
|
|
41
|
+
// ScrollbackView, InputLine, and dispatch all read from the active mode's
|
|
42
|
+
// buffer; switching mode rebinds without losing the previous buffer.
|
|
43
|
+
// BufferStore hydrates from the workspace zone on first access of each
|
|
44
|
+
// mode's buffer, restoring scrollback + history across page reloads (SH8).
|
|
45
|
+
const bufferStore = untrack(() => new BufferStore());
|
|
46
|
+
const buffers = new Map<string, ModeBuffer>();
|
|
47
|
+
function getBuffer(modeId: string): ModeBuffer {
|
|
48
|
+
let b = buffers.get(modeId);
|
|
49
|
+
if (!b) {
|
|
50
|
+
b = new ModeBuffer(modeId);
|
|
51
|
+
bufferStore.hydrate(b);
|
|
52
|
+
buffers.set(modeId, b);
|
|
53
|
+
}
|
|
54
|
+
return b;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
const resolver = new VerbRegistry();
|
|
38
58
|
const fs = new TenantFsClient();
|
|
39
59
|
|
|
@@ -65,6 +85,7 @@
|
|
|
65
85
|
requiresRole: d.requiresRole,
|
|
66
86
|
transport: 'custom',
|
|
67
87
|
autoRelocate: d.autoRelocate ?? false,
|
|
88
|
+
showCwd: d.showCwd ?? false,
|
|
68
89
|
};
|
|
69
90
|
}
|
|
70
91
|
|
|
@@ -95,6 +116,39 @@
|
|
|
95
116
|
}),
|
|
96
117
|
);
|
|
97
118
|
|
|
119
|
+
// Active-mode buffer derivation. Reads `mode.id` reactively so that
|
|
120
|
+
// mode-switch flips ScrollbackView and InputLine bindings in lockstep.
|
|
121
|
+
const currentBuffer = $derived(getBuffer(mode.id));
|
|
122
|
+
|
|
123
|
+
// Mirror the server's per-mode history bundle into each ModeBuffer.
|
|
124
|
+
// The bundle arrives on attach and re-arrives on each reconnect. We
|
|
125
|
+
// only overwrite a buffer's history when the server has more lines
|
|
126
|
+
// than the client — preserves optimistic appends made between submits
|
|
127
|
+
// and the server's authoritative reply.
|
|
128
|
+
$effect(() => {
|
|
129
|
+
for (const [modeId, lines] of Object.entries(session.historyByMode)) {
|
|
130
|
+
const buf = getBuffer(modeId);
|
|
131
|
+
if (buf.history.length < lines.length) {
|
|
132
|
+
buf.history = [...lines];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Persist the active buffer on every meaningful change. The flush is
|
|
138
|
+
// debounced 200ms inside BufferStore, so a stream of stdout chunks
|
|
139
|
+
// results in a single zone write per quiescent moment. The bash
|
|
140
|
+
// buffer additionally stamps session.lastSeq so a page reload can ask
|
|
141
|
+
// the server for replay events newer than the persisted snapshot.
|
|
142
|
+
$effect(() => {
|
|
143
|
+
void currentBuffer.scrollback.entries.length;
|
|
144
|
+
void currentBuffer.history.length;
|
|
145
|
+
if (currentBuffer.modeId === 'bash') {
|
|
146
|
+
bufferStore.flush(currentBuffer, session.lastSeq);
|
|
147
|
+
} else {
|
|
148
|
+
bufferStore.flush(currentBuffer);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
98
152
|
function setMode(id: string): void {
|
|
99
153
|
const next = visibleModes.find((m) => m.id === id);
|
|
100
154
|
if (!next) return;
|
|
@@ -104,9 +158,6 @@
|
|
|
104
158
|
cancelDispatch();
|
|
105
159
|
mode = next;
|
|
106
160
|
writeLastMode(userId, id);
|
|
107
|
-
if (next.transport !== 'ws') {
|
|
108
|
-
scrollback.push({ kind: 'status', text: 'mode switch: reload to take effect for server-shell changes', level: 'info', ts: Date.now() });
|
|
109
|
-
}
|
|
110
161
|
}
|
|
111
162
|
|
|
112
163
|
// If the active mode disappears (shard unloaded), fall back to sh3 — or
|
|
@@ -118,7 +169,7 @@
|
|
|
118
169
|
const lostId = mode.id;
|
|
119
170
|
mode = fallback;
|
|
120
171
|
writeLastMode(userId, fallback.id);
|
|
121
|
-
scrollback.push({
|
|
172
|
+
getBuffer(fallback.id).scrollback.push({
|
|
122
173
|
kind: 'status',
|
|
123
174
|
text: `mode '${lostId}' is no longer available — switched to '${fallback.id}'`,
|
|
124
175
|
level: 'warn',
|
|
@@ -143,6 +194,7 @@
|
|
|
143
194
|
return true;
|
|
144
195
|
},
|
|
145
196
|
listModes: () => visibleModes.map((m) => ({ id: m.id, label: m.label })),
|
|
197
|
+
getMode: () => ({ id: mode.id, label: mode.label }),
|
|
146
198
|
}));
|
|
147
199
|
|
|
148
200
|
// wsUrl is a prop read at construction only. untrack prevents Svelte 5's
|
|
@@ -181,7 +233,7 @@
|
|
|
181
233
|
mode: () => mode,
|
|
182
234
|
role: () => role,
|
|
183
235
|
resolver,
|
|
184
|
-
|
|
236
|
+
buffer: () => currentBuffer,
|
|
185
237
|
session,
|
|
186
238
|
shell: shellWithModes,
|
|
187
239
|
fs,
|
|
@@ -190,8 +242,6 @@
|
|
|
190
242
|
customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
|
|
191
243
|
}));
|
|
192
244
|
|
|
193
|
-
let locked = $state(false);
|
|
194
|
-
|
|
195
245
|
// ---------------------------------------------------------------------------
|
|
196
246
|
// Auto-relocate: track the focused shard and update session.cwd when focus
|
|
197
247
|
// changes to a shard whose documents directory exists. focusLocked and
|
|
@@ -265,18 +315,30 @@
|
|
|
265
315
|
});
|
|
266
316
|
|
|
267
317
|
function handleServerMessage(msg: ServerMessage) {
|
|
318
|
+
if (msg.t === 'replay') {
|
|
319
|
+
// Server's ring-buffer events between the last snapshot and now.
|
|
320
|
+
// Fold into the bash buffer using the same routing as live events.
|
|
321
|
+
applyReplayEvents(getBuffer('bash'), msg.events);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
268
324
|
if (msg.t !== 'event') return;
|
|
269
325
|
const e = msg.event;
|
|
326
|
+
// WS events always route to the bash buffer regardless of the active
|
|
327
|
+
// mode, so output keeps accumulating while the user is viewing sh3 or
|
|
328
|
+
// a custom mode. The bash buffer's locked flag tracks the running
|
|
329
|
+
// process; switching to a non-bash buffer naturally unlocks the input
|
|
330
|
+
// bar because each ModeBuffer carries its own locked state.
|
|
331
|
+
const bashBuf = getBuffer('bash');
|
|
270
332
|
switch (e.kind) {
|
|
271
333
|
case 'prompt':
|
|
272
|
-
scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
|
|
273
|
-
locked = true;
|
|
334
|
+
bashBuf.scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
|
|
335
|
+
bashBuf.locked = true;
|
|
274
336
|
break;
|
|
275
337
|
case 'stdout':
|
|
276
|
-
scrollback.push({ kind: 'text', stream: 'stdout', chunks: [e.data], ts: e.ts });
|
|
338
|
+
bashBuf.scrollback.push({ kind: 'text', stream: 'stdout', chunks: [e.data], ts: e.ts });
|
|
277
339
|
break;
|
|
278
340
|
case 'stderr':
|
|
279
|
-
scrollback.push({ kind: 'text', stream: 'stderr', chunks: [e.data], ts: e.ts });
|
|
341
|
+
bashBuf.scrollback.push({ kind: 'text', stream: 'stderr', chunks: [e.data], ts: e.ts });
|
|
280
342
|
break;
|
|
281
343
|
case 'exit':
|
|
282
344
|
// Match real-shell UX: stay silent on clean exit. Only surface
|
|
@@ -284,7 +346,7 @@
|
|
|
284
346
|
// processes (SIGINT, spawn errors, etc.). `code === null` with
|
|
285
347
|
// no signal happens on clean close too — treat as success.
|
|
286
348
|
if (e.signal || (e.code !== null && e.code !== 0)) {
|
|
287
|
-
scrollback.push({
|
|
349
|
+
bashBuf.scrollback.push({
|
|
288
350
|
kind: 'status',
|
|
289
351
|
text: e.signal
|
|
290
352
|
? `shell: process exited (${e.signal})`
|
|
@@ -293,10 +355,10 @@
|
|
|
293
355
|
ts: e.ts,
|
|
294
356
|
});
|
|
295
357
|
}
|
|
296
|
-
locked = false;
|
|
358
|
+
bashBuf.locked = false;
|
|
297
359
|
break;
|
|
298
360
|
case 'status':
|
|
299
|
-
scrollback.push({ kind: 'status', text: e.text, level: e.level, ts: e.ts });
|
|
361
|
+
bashBuf.scrollback.push({ kind: 'status', text: e.text, level: e.level, ts: e.ts });
|
|
300
362
|
break;
|
|
301
363
|
}
|
|
302
364
|
}
|
|
@@ -305,9 +367,18 @@
|
|
|
305
367
|
|
|
306
368
|
onMount(() => {
|
|
307
369
|
unsub = session.onMessage(handleServerMessage);
|
|
308
|
-
|
|
309
|
-
|
|
370
|
+
// Pre-warm the bash buffer + seed lastSeq so the WS hello replays
|
|
371
|
+
// only events newer than the persisted snapshot. This closes the
|
|
372
|
+
// gap between last flush and reconnect on page reload.
|
|
373
|
+
getBuffer('bash');
|
|
374
|
+
const persistedSeq = bufferStore.readLastSeq('bash');
|
|
375
|
+
if (persistedSeq !== undefined) {
|
|
376
|
+
session.lastSeq = persistedSeq;
|
|
310
377
|
}
|
|
378
|
+
// Always-connect: shell-shard is admin-gated, idle WS cost is
|
|
379
|
+
// negligible, and non-bash modes still need to log mode-tagged
|
|
380
|
+
// history server-side via history-log messages.
|
|
381
|
+
session.connect();
|
|
311
382
|
});
|
|
312
383
|
|
|
313
384
|
onDestroy(() => {
|
|
@@ -327,11 +398,12 @@
|
|
|
327
398
|
'target-shard': { target: targetShard },
|
|
328
399
|
}}
|
|
329
400
|
/>
|
|
330
|
-
<ScrollbackView {scrollback} />
|
|
401
|
+
<ScrollbackView showPromptCwd={mode.showCwd || false} scrollback={currentBuffer.scrollback} />
|
|
331
402
|
<InputLine
|
|
332
|
-
cwd={session.cwd}
|
|
333
|
-
{
|
|
334
|
-
|
|
403
|
+
cwd={shortenCwd(session.cwd, session.tenantRoot)}
|
|
404
|
+
showCwd={mode.showCwd !== false}
|
|
405
|
+
locked={currentBuffer.locked}
|
|
406
|
+
history={currentBuffer.history}
|
|
335
407
|
{session}
|
|
336
408
|
onSubmit={dispatch}
|
|
337
409
|
/>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
2
|
+
export declare class BufferStore {
|
|
3
|
+
private timers;
|
|
4
|
+
/** Restore a ModeBuffer's contents from the workspace zone, if any. */
|
|
5
|
+
hydrate(buffer: ModeBuffer): void;
|
|
6
|
+
/** Schedule a debounced write of the buffer's current state. */
|
|
7
|
+
flush(buffer: ModeBuffer, lastSeq?: number): void;
|
|
8
|
+
/** Force a synchronous write, bypassing debounce. Used on shard deactivate. */
|
|
9
|
+
forceFlush(buffer: ModeBuffer, lastSeq?: number): void;
|
|
10
|
+
/** Read the persisted lastSeq for a mode, if any. */
|
|
11
|
+
readLastSeq(modeId: string): number | undefined;
|
|
12
|
+
private write;
|
|
13
|
+
private toPersisted;
|
|
14
|
+
private toLive;
|
|
15
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* BufferStore — serialize ModeBuffer state into the shell-shard's
|
|
3
|
+
* workspace zone with a 200ms debounce, cap at 500 entries per mode,
|
|
4
|
+
* and gracefully degrade non-serializable rich entries.
|
|
5
|
+
*
|
|
6
|
+
* Hydrate fills a fresh ModeBuffer from a previously-persisted snapshot.
|
|
7
|
+
* Flush schedules a debounced write of the buffer's current state. The
|
|
8
|
+
* bash buffer additionally carries a `lastSeq` cursor so the WS hello
|
|
9
|
+
* after reload can ask the server for replay events newer than the
|
|
10
|
+
* persisted snapshot.
|
|
11
|
+
*/
|
|
12
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
13
|
+
import { readBuffer, writeBuffer, } from './buffer-zone-state.svelte';
|
|
14
|
+
const PERSIST_CAP = 500;
|
|
15
|
+
const DEBOUNCE_MS = 200;
|
|
16
|
+
export class BufferStore {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.timers = new Map();
|
|
19
|
+
}
|
|
20
|
+
/** Restore a ModeBuffer's contents from the workspace zone, if any. */
|
|
21
|
+
hydrate(buffer) {
|
|
22
|
+
const persisted = readBuffer(buffer.modeId);
|
|
23
|
+
if (!persisted)
|
|
24
|
+
return;
|
|
25
|
+
const live = [];
|
|
26
|
+
for (const p of persisted.entries) {
|
|
27
|
+
live.push(this.toLive(p));
|
|
28
|
+
}
|
|
29
|
+
// restore() splices in place AND bumps the module-scoped id counter past
|
|
30
|
+
// the restored max — without that bump, the next push() after a reload
|
|
31
|
+
// would mint an id colliding with a hydrated key and crash the keyed
|
|
32
|
+
// {#each} in ScrollbackView.
|
|
33
|
+
buffer.scrollback.restore(live);
|
|
34
|
+
buffer.history = [...persisted.history];
|
|
35
|
+
}
|
|
36
|
+
/** Schedule a debounced write of the buffer's current state. */
|
|
37
|
+
flush(buffer, lastSeq) {
|
|
38
|
+
const existing = this.timers.get(buffer.modeId);
|
|
39
|
+
if (existing)
|
|
40
|
+
clearTimeout(existing);
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
this.timers.delete(buffer.modeId);
|
|
43
|
+
this.write(buffer, lastSeq);
|
|
44
|
+
}, DEBOUNCE_MS);
|
|
45
|
+
this.timers.set(buffer.modeId, timer);
|
|
46
|
+
}
|
|
47
|
+
/** Force a synchronous write, bypassing debounce. Used on shard deactivate. */
|
|
48
|
+
forceFlush(buffer, lastSeq) {
|
|
49
|
+
const existing = this.timers.get(buffer.modeId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
clearTimeout(existing);
|
|
52
|
+
this.timers.delete(buffer.modeId);
|
|
53
|
+
}
|
|
54
|
+
this.write(buffer, lastSeq);
|
|
55
|
+
}
|
|
56
|
+
/** Read the persisted lastSeq for a mode, if any. */
|
|
57
|
+
readLastSeq(modeId) {
|
|
58
|
+
var _a;
|
|
59
|
+
return (_a = readBuffer(modeId)) === null || _a === void 0 ? void 0 : _a.lastSeq;
|
|
60
|
+
}
|
|
61
|
+
write(buffer, lastSeq) {
|
|
62
|
+
const entries = buffer.scrollback.entries.slice(-PERSIST_CAP).map((e) => this.toPersisted(e));
|
|
63
|
+
const persisted = Object.assign({ entries, history: [...buffer.history] }, (lastSeq !== undefined ? { lastSeq } : {}));
|
|
64
|
+
writeBuffer(buffer.modeId, persisted);
|
|
65
|
+
}
|
|
66
|
+
toPersisted(e) {
|
|
67
|
+
if (e.kind === 'rich') {
|
|
68
|
+
const key = e.componentKey;
|
|
69
|
+
if (!key) {
|
|
70
|
+
return {
|
|
71
|
+
id: e.id,
|
|
72
|
+
kind: 'status',
|
|
73
|
+
text: '<rich entry: not restorable (no componentKey)>',
|
|
74
|
+
level: 'info',
|
|
75
|
+
ts: e.ts,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const propsJson = JSON.stringify(e.props);
|
|
80
|
+
return {
|
|
81
|
+
id: e.id,
|
|
82
|
+
kind: 'rich',
|
|
83
|
+
componentKey: key,
|
|
84
|
+
props: JSON.parse(propsJson),
|
|
85
|
+
ts: e.ts,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
catch (_a) {
|
|
89
|
+
return {
|
|
90
|
+
id: e.id,
|
|
91
|
+
kind: 'status',
|
|
92
|
+
text: `<rich entry: ${key} not restorable>`,
|
|
93
|
+
level: 'info',
|
|
94
|
+
ts: e.ts,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (e.kind === 'prompt')
|
|
99
|
+
return { id: e.id, kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts };
|
|
100
|
+
if (e.kind === 'text')
|
|
101
|
+
return { id: e.id, kind: 'text', stream: e.stream, chunks: [...e.chunks], ts: e.ts };
|
|
102
|
+
return { id: e.id, kind: 'status', text: e.text, level: e.level, ts: e.ts };
|
|
103
|
+
}
|
|
104
|
+
toLive(p) {
|
|
105
|
+
if (p.kind === 'rich') {
|
|
106
|
+
// Component is recovered at render-time via lookupRichComponent in
|
|
107
|
+
// ScrollbackView. The live entry's `component` field is unused after
|
|
108
|
+
// hydration; pass an empty placeholder to satisfy the type.
|
|
109
|
+
return {
|
|
110
|
+
id: p.id,
|
|
111
|
+
kind: 'rich',
|
|
112
|
+
componentKey: p.componentKey,
|
|
113
|
+
component: undefined,
|
|
114
|
+
props: p.props,
|
|
115
|
+
ts: p.ts,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (p.kind === 'prompt')
|
|
119
|
+
return { id: p.id, kind: 'prompt', cwd: p.cwd, line: p.line, ts: p.ts };
|
|
120
|
+
if (p.kind === 'text')
|
|
121
|
+
return { id: p.id, kind: 'text', stream: p.stream, chunks: [...p.chunks], ts: p.ts };
|
|
122
|
+
return { id: p.id, kind: 'status', text: p.text, level: p.level, ts: p.ts };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { __resetForTests as resetZone, readBuffer, writeBuffer, } from './buffer-zone-state.svelte';
|
|
3
|
+
import { BufferStore } from './buffer-store';
|
|
4
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
5
|
+
describe('BufferStore', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
resetZone();
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
it('hydrates an empty buffer when zone has nothing', () => {
|
|
11
|
+
const store = new BufferStore();
|
|
12
|
+
const buf = new ModeBuffer('sh3');
|
|
13
|
+
store.hydrate(buf);
|
|
14
|
+
expect(buf.scrollback.entries).toEqual([]);
|
|
15
|
+
expect(buf.history).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
it('persists a serializable buffer after debounce', () => {
|
|
18
|
+
const store = new BufferStore();
|
|
19
|
+
const buf = new ModeBuffer('sh3');
|
|
20
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'ls', ts: 1 });
|
|
21
|
+
buf.history.push('ls');
|
|
22
|
+
store.flush(buf);
|
|
23
|
+
vi.advanceTimersByTime(200);
|
|
24
|
+
const persisted = readBuffer('sh3');
|
|
25
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(1);
|
|
26
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.history).toEqual(['ls']);
|
|
27
|
+
});
|
|
28
|
+
it('coalesces back-to-back flushes within debounce window', () => {
|
|
29
|
+
const store = new BufferStore();
|
|
30
|
+
const buf = new ModeBuffer('sh3');
|
|
31
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'a', ts: 1 });
|
|
32
|
+
store.flush(buf);
|
|
33
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'b', ts: 2 });
|
|
34
|
+
store.flush(buf);
|
|
35
|
+
vi.advanceTimersByTime(200);
|
|
36
|
+
const persisted = readBuffer('sh3');
|
|
37
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(2);
|
|
38
|
+
});
|
|
39
|
+
it('caps persisted entries at 500 (most recent)', () => {
|
|
40
|
+
const store = new BufferStore();
|
|
41
|
+
const buf = new ModeBuffer('sh3');
|
|
42
|
+
for (let i = 0; i < 600; i++) {
|
|
43
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: `cmd${i}`, ts: i });
|
|
44
|
+
}
|
|
45
|
+
store.flush(buf);
|
|
46
|
+
vi.advanceTimersByTime(200);
|
|
47
|
+
const persisted = readBuffer('sh3');
|
|
48
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(500);
|
|
49
|
+
const first = persisted.entries[0];
|
|
50
|
+
expect(first.kind === 'prompt' && first.line).toBe('cmd100');
|
|
51
|
+
});
|
|
52
|
+
it('hydrates a previously persisted buffer', () => {
|
|
53
|
+
writeBuffer('sh3', {
|
|
54
|
+
entries: [{ id: 'e1', kind: 'prompt', cwd: '/', line: 'restored', ts: 1 }],
|
|
55
|
+
history: ['restored'],
|
|
56
|
+
});
|
|
57
|
+
const store = new BufferStore();
|
|
58
|
+
const buf = new ModeBuffer('sh3');
|
|
59
|
+
store.hydrate(buf);
|
|
60
|
+
expect(buf.scrollback.entries).toHaveLength(1);
|
|
61
|
+
expect(buf.history).toEqual(['restored']);
|
|
62
|
+
});
|
|
63
|
+
it('hydrate prevents id collisions on subsequent pushes', () => {
|
|
64
|
+
// Persisted id well above any test-time mkId() counter so the bug
|
|
65
|
+
// would reproduce (duplicate keys) if hydrate did not bump the
|
|
66
|
+
// module-scoped id sequence past the restored max.
|
|
67
|
+
writeBuffer('sh3', {
|
|
68
|
+
entries: [{ id: 'e9999', kind: 'prompt', cwd: '/', line: 'restored', ts: 1 }],
|
|
69
|
+
history: [],
|
|
70
|
+
});
|
|
71
|
+
const store = new BufferStore();
|
|
72
|
+
const buf = new ModeBuffer('sh3');
|
|
73
|
+
store.hydrate(buf);
|
|
74
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'next', ts: 2 });
|
|
75
|
+
const ids = buf.scrollback.entries.map((e) => e.id);
|
|
76
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
77
|
+
expect(Number(ids[1].slice(1))).toBeGreaterThan(9999);
|
|
78
|
+
});
|
|
79
|
+
it('non-serializable rich props degrade to status entry on persist', () => {
|
|
80
|
+
const store = new BufferStore();
|
|
81
|
+
const buf = new ModeBuffer('sh3');
|
|
82
|
+
const circular = {};
|
|
83
|
+
circular.self = circular;
|
|
84
|
+
buf.scrollback.push({
|
|
85
|
+
kind: 'rich',
|
|
86
|
+
componentKey: 'apps-table',
|
|
87
|
+
component: {},
|
|
88
|
+
props: circular,
|
|
89
|
+
ts: 1,
|
|
90
|
+
});
|
|
91
|
+
store.flush(buf);
|
|
92
|
+
vi.advanceTimersByTime(200);
|
|
93
|
+
const persisted = readBuffer('sh3');
|
|
94
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(1);
|
|
95
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries[0].kind).toBe('status');
|
|
96
|
+
});
|
|
97
|
+
it('persists lastSeq when provided', () => {
|
|
98
|
+
var _a;
|
|
99
|
+
const store = new BufferStore();
|
|
100
|
+
const buf = new ModeBuffer('bash');
|
|
101
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'ls', ts: 1 });
|
|
102
|
+
store.flush(buf, 42);
|
|
103
|
+
vi.advanceTimersByTime(200);
|
|
104
|
+
expect((_a = readBuffer('bash')) === null || _a === void 0 ? void 0 : _a.lastSeq).toBe(42);
|
|
105
|
+
expect(store.readLastSeq('bash')).toBe(42);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StateZones } from '../state/zones.svelte';
|
|
2
|
+
export interface PersistedEntryBase {
|
|
3
|
+
id: string;
|
|
4
|
+
ts: number;
|
|
5
|
+
}
|
|
6
|
+
export type PersistedEntry = (PersistedEntryBase & {
|
|
7
|
+
kind: 'prompt';
|
|
8
|
+
cwd: string;
|
|
9
|
+
line: string;
|
|
10
|
+
}) | (PersistedEntryBase & {
|
|
11
|
+
kind: 'text';
|
|
12
|
+
stream: 'stdout' | 'stderr';
|
|
13
|
+
chunks: string[];
|
|
14
|
+
}) | (PersistedEntryBase & {
|
|
15
|
+
kind: 'rich';
|
|
16
|
+
componentKey: string;
|
|
17
|
+
props: unknown;
|
|
18
|
+
}) | (PersistedEntryBase & {
|
|
19
|
+
kind: 'status';
|
|
20
|
+
text: string;
|
|
21
|
+
level: 'info' | 'warn' | 'error';
|
|
22
|
+
});
|
|
23
|
+
export interface PersistedBuffer {
|
|
24
|
+
entries: PersistedEntry[];
|
|
25
|
+
history: string[];
|
|
26
|
+
/** Only meaningful for the bash buffer — server's seq cursor at last flush. */
|
|
27
|
+
lastSeq?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ShellWorkspaceZoneSchema {
|
|
30
|
+
workspace: {
|
|
31
|
+
buffers: Record<string, PersistedBuffer>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function __bindZone(s: StateZones<ShellWorkspaceZoneSchema>): void;
|
|
35
|
+
export declare function __unbindZone(): void;
|
|
36
|
+
export declare function readBuffer(modeId: string): PersistedBuffer | undefined;
|
|
37
|
+
export declare function writeBuffer(modeId: string, buf: PersistedBuffer): void;
|
|
38
|
+
export declare function __resetForTests(): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reactive proxy bound at activate-time to the shell-shard's workspace
|
|
3
|
+
* zone. Mirrors the layouts-shard pattern (layoutsState.svelte.ts) so
|
|
4
|
+
* tests can swap the live proxy for an in-memory shim.
|
|
5
|
+
*
|
|
6
|
+
* Holds the per-mode buffer snapshots that BufferStore reads/writes.
|
|
7
|
+
*/
|
|
8
|
+
let zoneState = null;
|
|
9
|
+
export function __bindZone(s) {
|
|
10
|
+
zoneState = s;
|
|
11
|
+
}
|
|
12
|
+
export function __unbindZone() {
|
|
13
|
+
zoneState = null;
|
|
14
|
+
}
|
|
15
|
+
export function readBuffer(modeId) {
|
|
16
|
+
return zoneState === null || zoneState === void 0 ? void 0 : zoneState.workspace.buffers[modeId];
|
|
17
|
+
}
|
|
18
|
+
export function writeBuffer(modeId, buf) {
|
|
19
|
+
if (!zoneState)
|
|
20
|
+
return;
|
|
21
|
+
zoneState.workspace.buffers = Object.assign(Object.assign({}, zoneState.workspace.buffers), { [modeId]: buf });
|
|
22
|
+
}
|
|
23
|
+
export function __resetForTests() {
|
|
24
|
+
zoneState = {
|
|
25
|
+
ephemeral: {},
|
|
26
|
+
session: {},
|
|
27
|
+
workspace: { buffers: {} },
|
|
28
|
+
user: {},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
__resetForTests();
|
|
@@ -17,6 +17,13 @@ export interface ShellModeDescriptor {
|
|
|
17
17
|
runsOn: ShellModeRunsOn;
|
|
18
18
|
/** Whether the shell auto-relocates cwd when a shard takes focus. */
|
|
19
19
|
autoRelocate?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the input prompt shows the current working directory in front
|
|
22
|
+
* of the cursor. Defaults to false for custom modes — most LLM/agent
|
|
23
|
+
* shells don't have a meaningful cwd to display. Builtin sh3 and bash
|
|
24
|
+
* always show it.
|
|
25
|
+
*/
|
|
26
|
+
showCwd?: boolean;
|
|
20
27
|
/** Brain: receives input and pushes output. */
|
|
21
28
|
dispatch: ShellModeDispatchHandler;
|
|
22
29
|
/** Optional lifecycle hook fired when the mode is selected. */
|
|
@@ -3,6 +3,8 @@ import { makeDispatch } from './dispatch';
|
|
|
3
3
|
function makeStubDeps(mode, customMode) {
|
|
4
4
|
const pushed = [];
|
|
5
5
|
const scrollback = { push: (e) => pushed.push(e) };
|
|
6
|
+
const history = [];
|
|
7
|
+
const buffer = { scrollback, history };
|
|
6
8
|
const session = {
|
|
7
9
|
history: { push: vi.fn() },
|
|
8
10
|
send: () => { },
|
|
@@ -18,7 +20,7 @@ function makeStubDeps(mode, customMode) {
|
|
|
18
20
|
mode: () => mode,
|
|
19
21
|
role: () => 'user',
|
|
20
22
|
resolver,
|
|
21
|
-
|
|
23
|
+
buffer: () => buffer,
|
|
22
24
|
session,
|
|
23
25
|
shell,
|
|
24
26
|
fs,
|
|
@@ -13,6 +13,8 @@ function scaffold(mode) {
|
|
|
13
13
|
const sent = [];
|
|
14
14
|
const pushed = [];
|
|
15
15
|
const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
|
|
16
|
+
const history = [];
|
|
17
|
+
const buffer = { scrollback, history };
|
|
16
18
|
const session = { history: { push: vi.fn() }, send: (m) => sent.push(m), cwd: '/', connected: true, connect: vi.fn() };
|
|
17
19
|
const fs = {};
|
|
18
20
|
const shell = {};
|
|
@@ -32,7 +34,7 @@ function scaffold(mode) {
|
|
|
32
34
|
mode: () => mode,
|
|
33
35
|
role: () => (mode.requiresRole === 'admin' ? 'admin' : 'user'),
|
|
34
36
|
resolver,
|
|
35
|
-
|
|
37
|
+
buffer: () => buffer,
|
|
36
38
|
session,
|
|
37
39
|
shell,
|
|
38
40
|
fs,
|
|
@@ -47,7 +49,9 @@ describe('dispatch — mode-gated verb resolution', () => {
|
|
|
47
49
|
it('sh3 mode resolves sh3-domain verbs locally', async () => {
|
|
48
50
|
const { dispatch, sent } = scaffold(sh3Mode);
|
|
49
51
|
await dispatch('apps');
|
|
50
|
-
|
|
52
|
+
// sh3 verbs run locally; the only WS traffic is the per-mode
|
|
53
|
+
// history-log entry tagged 'sh3'.
|
|
54
|
+
expect(sent).toEqual([{ t: 'history-log', line: 'apps', mode: 'sh3' }]);
|
|
51
55
|
});
|
|
52
56
|
it('bash mode forwards sh3-domain verbs to ws', async () => {
|
|
53
57
|
const { dispatch, sent } = scaffold(bashMode);
|
|
@@ -13,6 +13,8 @@ function scaffold(opts) {
|
|
|
13
13
|
const pushed = [];
|
|
14
14
|
const connectSpy = vi.fn();
|
|
15
15
|
const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
|
|
16
|
+
const history = [];
|
|
17
|
+
const buffer = { scrollback, history };
|
|
16
18
|
const session = {
|
|
17
19
|
history: { push: vi.fn() },
|
|
18
20
|
send: (m) => sent.push(m),
|
|
@@ -34,7 +36,7 @@ function scaffold(opts) {
|
|
|
34
36
|
mode: () => opts.current,
|
|
35
37
|
role: () => opts.role,
|
|
36
38
|
resolver,
|
|
37
|
-
|
|
39
|
+
buffer: () => buffer,
|
|
38
40
|
session,
|
|
39
41
|
shell,
|
|
40
42
|
fs,
|
|
@@ -88,7 +90,7 @@ describe('output.invoke — sh3 target', () => {
|
|
|
88
90
|
});
|
|
89
91
|
});
|
|
90
92
|
describe('output.invoke — bash target', () => {
|
|
91
|
-
it('admin invocation
|
|
93
|
+
it('admin invocation forwards a bash submit', async () => {
|
|
92
94
|
const customs = [{
|
|
93
95
|
id: 'gemini',
|
|
94
96
|
label: 'Gemini',
|
|
@@ -97,16 +99,15 @@ describe('output.invoke — bash target', () => {
|
|
|
97
99
|
await output.invoke('bash', 'ls');
|
|
98
100
|
},
|
|
99
101
|
}];
|
|
100
|
-
const { dispatch, sent
|
|
102
|
+
const { dispatch, sent } = scaffold({
|
|
101
103
|
current: customMode('gemini'),
|
|
102
104
|
role: 'admin',
|
|
103
105
|
customs,
|
|
104
106
|
});
|
|
105
107
|
await dispatch('hello');
|
|
106
|
-
expect(
|
|
107
|
-
expect(sent.some((m) => m.t === 'submit' && m.line === 'ls')).toBe(true);
|
|
108
|
+
expect(sent.some((m) => m.t === 'submit' && m.line === 'ls' && m.mode === 'bash')).toBe(true);
|
|
108
109
|
});
|
|
109
|
-
it('
|
|
110
|
+
it('multiple invokes each forward a bash submit', async () => {
|
|
110
111
|
const customs = [{
|
|
111
112
|
id: 'gemini',
|
|
112
113
|
label: 'Gemini',
|
|
@@ -116,13 +117,14 @@ describe('output.invoke — bash target', () => {
|
|
|
116
117
|
await output.invoke('bash', 'pwd');
|
|
117
118
|
},
|
|
118
119
|
}];
|
|
119
|
-
const { dispatch,
|
|
120
|
+
const { dispatch, sent } = scaffold({
|
|
120
121
|
current: customMode('gemini'),
|
|
121
122
|
role: 'admin',
|
|
122
123
|
customs,
|
|
123
124
|
});
|
|
124
125
|
await dispatch('hello');
|
|
125
|
-
|
|
126
|
+
const submits = sent.filter((m) => m.t === 'submit');
|
|
127
|
+
expect(submits.map((m) => m.line)).toEqual(['ls', 'pwd']);
|
|
126
128
|
});
|
|
127
129
|
it('non-admin invoking bash throws', async () => {
|
|
128
130
|
let caught;
|