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.
Files changed (141) hide show
  1. package/dist/actions/ctx-actions.svelte.test.js +111 -0
  2. package/dist/actions/dispatcher.svelte.js +23 -2
  3. package/dist/actions/dispatcher.test.js +33 -0
  4. package/dist/actions/listActionsFromEntries.test.js +78 -0
  5. package/dist/actions/listActive.d.ts +2 -1
  6. package/dist/actions/listActive.js +43 -17
  7. package/dist/actions/listeners.d.ts +16 -0
  8. package/dist/actions/listeners.js +68 -14
  9. package/dist/actions/programmatic-dispatch.svelte.test.d.ts +1 -0
  10. package/dist/actions/programmatic-dispatch.svelte.test.js +98 -0
  11. package/dist/actions/types.d.ts +37 -0
  12. package/dist/api.d.ts +1 -1
  13. package/dist/app/store/verbs.js +4 -0
  14. package/dist/app-appearance/appearanceShard.svelte.js +19 -6
  15. package/dist/app-appearance/appearanceState.svelte.js +3 -3
  16. package/dist/host.js +2 -1
  17. package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
  18. package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
  19. package/dist/layouts-shard/LayoutsSection.svelte +142 -0
  20. package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
  21. package/dist/layouts-shard/filter.d.ts +3 -0
  22. package/dist/layouts-shard/filter.js +66 -0
  23. package/dist/layouts-shard/filter.test.d.ts +1 -0
  24. package/dist/layouts-shard/filter.test.js +123 -0
  25. package/dist/layouts-shard/index.d.ts +1 -0
  26. package/dist/layouts-shard/index.js +1 -0
  27. package/dist/layouts-shard/layoutsApi.d.ts +12 -0
  28. package/dist/layouts-shard/layoutsApi.js +41 -0
  29. package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
  30. package/dist/layouts-shard/layoutsApi.test.js +74 -0
  31. package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
  32. package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
  33. package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
  34. package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
  35. package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
  36. package/dist/layouts-shard/layoutsState.svelte.js +50 -0
  37. package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
  38. package/dist/layouts-shard/layoutsState.test.js +43 -0
  39. package/dist/layouts-shard/types.d.ts +21 -0
  40. package/dist/layouts-shard/types.js +6 -0
  41. package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
  42. package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
  43. package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
  44. package/dist/overlays/EntityAppearanceModal.test.js +57 -0
  45. package/dist/overlays/FloatFrame.svelte +149 -8
  46. package/dist/overlays/FloatFrame.svelte.d.ts +1 -1
  47. package/dist/overlays/FloatLayer.svelte +2 -2
  48. package/dist/overlays/float.d.ts +38 -1
  49. package/dist/overlays/float.js +82 -0
  50. package/dist/overlays/float.test.js +394 -0
  51. package/dist/overlays/floatMaximized.svelte.d.ts +4 -0
  52. package/dist/overlays/floatMaximized.svelte.js +30 -0
  53. package/dist/runtime/runVerb-shell.test.d.ts +1 -0
  54. package/dist/runtime/runVerb-shell.test.js +231 -0
  55. package/dist/sh3core-shard/ShellHome.svelte +3 -0
  56. package/dist/sh3core-shard/sh3coreShard.svelte.d.ts +7 -0
  57. package/dist/sh3core-shard/sh3coreShard.svelte.js +23 -0
  58. package/dist/shards/activate-runtime.test.js +24 -2
  59. package/dist/shards/activate.svelte.js +18 -4
  60. package/dist/shards/types.d.ts +44 -4
  61. package/dist/shell-shard/CommandLine.svelte +143 -0
  62. package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
  63. package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
  64. package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
  65. package/dist/shell-shard/InputLine.svelte +17 -40
  66. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  67. package/dist/shell-shard/ScrollbackView.svelte +10 -3
  68. package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
  69. package/dist/shell-shard/Terminal.svelte +94 -22
  70. package/dist/shell-shard/buffer-store.d.ts +15 -0
  71. package/dist/shell-shard/buffer-store.js +124 -0
  72. package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
  74. package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
  75. package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
  76. package/dist/shell-shard/contract.d.ts +7 -0
  77. package/dist/shell-shard/dispatch-custom.test.js +3 -1
  78. package/dist/shell-shard/dispatch-gating.test.js +6 -2
  79. package/dist/shell-shard/dispatch-invoke.test.js +10 -8
  80. package/dist/shell-shard/dispatch.d.ts +7 -2
  81. package/dist/shell-shard/dispatch.js +23 -27
  82. package/dist/shell-shard/display-cwd.d.ts +1 -0
  83. package/dist/shell-shard/display-cwd.js +27 -0
  84. package/dist/shell-shard/display-cwd.test.d.ts +1 -0
  85. package/dist/shell-shard/display-cwd.test.js +29 -0
  86. package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
  87. package/dist/shell-shard/manifest.js +2 -1
  88. package/dist/shell-shard/manifest.test.d.ts +1 -0
  89. package/dist/shell-shard/manifest.test.js +8 -0
  90. package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
  91. package/dist/shell-shard/mode-buffer.svelte.js +19 -0
  92. package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
  93. package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
  94. package/dist/shell-shard/modes/builtin.js +2 -0
  95. package/dist/shell-shard/modes/types.d.ts +8 -0
  96. package/dist/shell-shard/protocol.d.ts +12 -6
  97. package/dist/shell-shard/replay.d.ts +3 -0
  98. package/dist/shell-shard/replay.js +44 -0
  99. package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
  100. package/dist/shell-shard/replay.svelte.test.js +47 -0
  101. package/dist/shell-shard/rich-registry.d.ts +5 -0
  102. package/dist/shell-shard/rich-registry.js +25 -0
  103. package/dist/shell-shard/rich-registry.test.d.ts +1 -0
  104. package/dist/shell-shard/rich-registry.test.js +31 -0
  105. package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
  106. package/dist/shell-shard/scrollback.svelte.js +23 -0
  107. package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
  108. package/dist/shell-shard/scrollback.svelte.test.js +51 -0
  109. package/dist/shell-shard/session-client.svelte.d.ts +18 -2
  110. package/dist/shell-shard/session-client.svelte.js +21 -4
  111. package/dist/shell-shard/shellApi.d.ts +2 -1
  112. package/dist/shell-shard/shellApi.js +32 -3
  113. package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
  114. package/dist/shell-shard/shellApi.svelte.test.js +59 -0
  115. package/dist/shell-shard/shellShard.svelte.js +11 -1
  116. package/dist/shell-shard/terminal-dispatch.test.js +3 -1
  117. package/dist/shell-shard/verbs/apps.js +9 -0
  118. package/dist/shell-shard/verbs/env.js +4 -0
  119. package/dist/shell-shard/verbs/help.js +9 -1
  120. package/dist/shell-shard/verbs/help.svelte.test.d.ts +1 -0
  121. package/dist/shell-shard/verbs/help.svelte.test.js +53 -0
  122. package/dist/shell-shard/verbs/history.js +8 -1
  123. package/dist/shell-shard/verbs/index.js +0 -8
  124. package/dist/shell-shard/verbs/shards.js +5 -0
  125. package/dist/shell-shard/verbs/views.js +9 -0
  126. package/dist/shell-shard/verbs/zones.js +9 -0
  127. package/dist/verbs/types.d.ts +9 -0
  128. package/dist/version.d.ts +1 -1
  129. package/dist/version.js +1 -1
  130. package/package.json +1 -1
  131. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
  132. package/dist/shell-shard/verbs/cat.d.ts +0 -2
  133. package/dist/shell-shard/verbs/cat.js +0 -34
  134. package/dist/shell-shard/verbs/cd.test.js +0 -56
  135. package/dist/shell-shard/verbs/ls.d.ts +0 -2
  136. package/dist/shell-shard/verbs/ls.js +0 -29
  137. package/dist/shell-shard/verbs/ls.test.js +0 -49
  138. package/dist/shell-shard/verbs/session.d.ts +0 -4
  139. package/dist/shell-shard/verbs/session.js +0 -97
  140. /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
  141. /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 { Scrollback } from './scrollback.svelte';
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
- const scrollback = new Scrollback();
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
- scrollback,
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
- if (mode.transport === 'ws') {
309
- session.connect();
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
- {locked}
334
- history={session.history}
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
- scrollback,
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
- scrollback,
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
- expect(sent).toEqual([]);
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
- scrollback,
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 lazy-connects and forwards', async () => {
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, connectSpy } = scaffold({
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(connectSpy).toHaveBeenCalledTimes(1);
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('lazy-connect only fires once across multiple invokes', async () => {
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, connectSpy } = scaffold({
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
- expect(connectSpy).toHaveBeenCalledTimes(1);
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;