sh3-core 0.15.1 → 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 (128) 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-appearance/appearanceShard.svelte.js +19 -6
  14. package/dist/app-appearance/appearanceState.svelte.js +3 -3
  15. package/dist/host.js +2 -1
  16. package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
  17. package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
  18. package/dist/layouts-shard/LayoutsSection.svelte +142 -0
  19. package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
  20. package/dist/layouts-shard/filter.d.ts +3 -0
  21. package/dist/layouts-shard/filter.js +66 -0
  22. package/dist/layouts-shard/filter.test.d.ts +1 -0
  23. package/dist/layouts-shard/filter.test.js +123 -0
  24. package/dist/layouts-shard/index.d.ts +1 -0
  25. package/dist/layouts-shard/index.js +1 -0
  26. package/dist/layouts-shard/layoutsApi.d.ts +12 -0
  27. package/dist/layouts-shard/layoutsApi.js +41 -0
  28. package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
  29. package/dist/layouts-shard/layoutsApi.test.js +74 -0
  30. package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
  31. package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
  32. package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
  33. package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
  34. package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
  35. package/dist/layouts-shard/layoutsState.svelte.js +50 -0
  36. package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
  37. package/dist/layouts-shard/layoutsState.test.js +43 -0
  38. package/dist/layouts-shard/types.d.ts +21 -0
  39. package/dist/layouts-shard/types.js +6 -0
  40. package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
  41. package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
  42. package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
  43. package/dist/overlays/EntityAppearanceModal.test.js +57 -0
  44. package/dist/overlays/FloatFrame.svelte +17 -0
  45. package/dist/overlays/float.d.ts +17 -1
  46. package/dist/overlays/float.js +16 -0
  47. package/dist/overlays/float.test.js +35 -0
  48. package/dist/sh3core-shard/ShellHome.svelte +3 -0
  49. package/dist/shards/activate.svelte.js +11 -2
  50. package/dist/shards/types.d.ts +33 -1
  51. package/dist/shell-shard/CommandLine.svelte +143 -0
  52. package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
  53. package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
  54. package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
  55. package/dist/shell-shard/InputLine.svelte +17 -40
  56. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  57. package/dist/shell-shard/ScrollbackView.svelte +10 -3
  58. package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
  59. package/dist/shell-shard/Terminal.svelte +93 -22
  60. package/dist/shell-shard/buffer-store.d.ts +15 -0
  61. package/dist/shell-shard/buffer-store.js +124 -0
  62. package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
  63. package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
  64. package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
  65. package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
  66. package/dist/shell-shard/contract.d.ts +7 -0
  67. package/dist/shell-shard/dispatch-custom.test.js +3 -1
  68. package/dist/shell-shard/dispatch-gating.test.js +6 -2
  69. package/dist/shell-shard/dispatch-invoke.test.js +10 -8
  70. package/dist/shell-shard/dispatch.d.ts +7 -2
  71. package/dist/shell-shard/dispatch.js +23 -27
  72. package/dist/shell-shard/display-cwd.d.ts +1 -0
  73. package/dist/shell-shard/display-cwd.js +27 -0
  74. package/dist/shell-shard/display-cwd.test.d.ts +1 -0
  75. package/dist/shell-shard/display-cwd.test.js +29 -0
  76. package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
  77. package/dist/shell-shard/manifest.js +2 -1
  78. package/dist/shell-shard/manifest.test.d.ts +1 -0
  79. package/dist/shell-shard/manifest.test.js +8 -0
  80. package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
  81. package/dist/shell-shard/mode-buffer.svelte.js +19 -0
  82. package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
  83. package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
  84. package/dist/shell-shard/modes/builtin.js +2 -0
  85. package/dist/shell-shard/modes/types.d.ts +8 -0
  86. package/dist/shell-shard/protocol.d.ts +12 -6
  87. package/dist/shell-shard/replay.d.ts +3 -0
  88. package/dist/shell-shard/replay.js +44 -0
  89. package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
  90. package/dist/shell-shard/replay.svelte.test.js +47 -0
  91. package/dist/shell-shard/rich-registry.d.ts +5 -0
  92. package/dist/shell-shard/rich-registry.js +25 -0
  93. package/dist/shell-shard/rich-registry.test.d.ts +1 -0
  94. package/dist/shell-shard/rich-registry.test.js +31 -0
  95. package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
  96. package/dist/shell-shard/scrollback.svelte.js +23 -0
  97. package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
  98. package/dist/shell-shard/scrollback.svelte.test.js +51 -0
  99. package/dist/shell-shard/session-client.svelte.d.ts +18 -2
  100. package/dist/shell-shard/session-client.svelte.js +21 -4
  101. package/dist/shell-shard/shellApi.d.ts +2 -1
  102. package/dist/shell-shard/shellApi.js +31 -3
  103. package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
  104. package/dist/shell-shard/shellApi.svelte.test.js +59 -0
  105. package/dist/shell-shard/shellShard.svelte.js +11 -1
  106. package/dist/shell-shard/terminal-dispatch.test.js +3 -1
  107. package/dist/shell-shard/verbs/apps.js +7 -0
  108. package/dist/shell-shard/verbs/env.js +4 -0
  109. package/dist/shell-shard/verbs/help.js +4 -0
  110. package/dist/shell-shard/verbs/history.js +8 -1
  111. package/dist/shell-shard/verbs/index.js +0 -8
  112. package/dist/shell-shard/verbs/shards.js +4 -0
  113. package/dist/shell-shard/verbs/views.js +4 -0
  114. package/dist/shell-shard/verbs/zones.js +7 -0
  115. package/dist/version.d.ts +1 -1
  116. package/dist/version.js +1 -1
  117. package/package.json +1 -1
  118. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
  119. package/dist/shell-shard/verbs/cat.d.ts +0 -2
  120. package/dist/shell-shard/verbs/cat.js +0 -35
  121. package/dist/shell-shard/verbs/cd.test.js +0 -56
  122. package/dist/shell-shard/verbs/ls.d.ts +0 -2
  123. package/dist/shell-shard/verbs/ls.js +0 -30
  124. package/dist/shell-shard/verbs/ls.test.js +0 -49
  125. package/dist/shell-shard/verbs/session.d.ts +0 -4
  126. package/dist/shell-shard/verbs/session.js +0 -99
  127. /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
  128. /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
@@ -1,21 +1,22 @@
1
1
  <script lang="ts">
2
2
  import type { SessionClient } from './session-client.svelte';
3
+ import CommandLine from './CommandLine.svelte';
3
4
 
4
5
  interface Props {
5
6
  cwd: string;
7
+ /** When false, hide the cwd chip in front of the prompt. */
8
+ showCwd?: boolean;
6
9
  locked: boolean; // true while a process is running
7
10
  history: string[]; // persisted history, newest last
8
11
  session: SessionClient;
9
12
  onSubmit: (line: string) => void; // called with the raw entered line
10
13
  }
11
- let { cwd, locked, history, session, onSubmit }: Props = $props();
14
+ let { cwd, showCwd = true, locked, history, session, onSubmit }: Props = $props();
12
15
 
13
16
  let draft = $state('');
14
17
  let historyIndex = $state<number | null>(null); // null = live draft
15
18
  let savedDraft = $state(''); // restored when user returns from history
16
19
 
17
- let input: HTMLInputElement | null = $state(null);
18
-
19
20
  function submit() {
20
21
  if (locked) return;
21
22
  const line = draft;
@@ -74,6 +75,8 @@
74
75
  e.preventDefault();
75
76
  navHistoryDown();
76
77
  } else if (e.ctrlKey && e.key === 'c') {
78
+ // CommandLine has already filtered this case to no-selection, so the
79
+ // user is asking to clear the draft, not copy.
77
80
  e.preventDefault();
78
81
  draft = '';
79
82
  historyIndex = null;
@@ -83,52 +86,26 @@
83
86
  submit();
84
87
  }
85
88
  }
86
-
87
- // Re-focus the input when the locked state flips to false
88
- $effect(() => {
89
- if (!locked && input) {
90
- input.focus();
91
- }
92
- });
93
89
  </script>
94
90
 
95
- <div class="shell-input" class:locked>
96
- <span class="shell-input-cwd">{cwd}</span>
97
- <span class="shell-input-arrow">❯</span>
98
- <input
99
- bind:this={input}
91
+ <div class="shell-input-row">
92
+ <CommandLine
100
93
  bind:value={draft}
101
- type="text"
102
94
  disabled={locked}
95
+ name="shell-cmdline"
103
96
  onkeydown={onKeyDown}
104
- spellcheck="false"
105
- autocomplete="off"
106
- autocapitalize="off"
107
- class="shell-input-field"
108
- />
97
+ >
98
+ {#snippet prefix()}
99
+ {#if showCwd}<span class="shell-input-cwd">{cwd}</span>{/if}
100
+ <span class="shell-input-arrow">❯</span>
101
+ {/snippet}
102
+ </CommandLine>
109
103
  </div>
110
104
 
111
105
  <style>
112
- .shell-input {
113
- display: flex;
114
- gap: 8px;
115
- padding: 4px 8px;
106
+ .shell-input-row {
116
107
  border-top: 1px solid var(--shell-border, #333);
117
- font-family: var(--shell-font-mono, monospace);
118
108
  }
119
109
  .shell-input-cwd { color: var(--shell-fg-muted, #888); }
120
- .shell-input-arrow { color: var(--shell-accent, #6cf); }
121
- .shell-input-field {
122
- padding: 0;
123
- flex: 1 1 auto;
124
- background: transparent;
125
- border: 0;
126
- outline: 0;
127
- color: var(--shell-fg, #ddd);
128
- font: inherit;
129
- }
130
- .shell-input.locked .shell-input-field {
131
- opacity: 0.5;
132
- cursor: default;
133
- }
110
+ .shell-input-arrow { color: var(--shell-accent, #6cf); margin-left: 6px; }
134
111
  </style>
@@ -1,6 +1,8 @@
1
1
  import type { SessionClient } from './session-client.svelte';
2
2
  interface Props {
3
3
  cwd: string;
4
+ /** When false, hide the cwd chip in front of the prompt. */
5
+ showCwd?: boolean;
4
6
  locked: boolean;
5
7
  history: string[];
6
8
  session: SessionClient;
@@ -4,11 +4,13 @@
4
4
  import PromptEntry from './entries/PromptEntry.svelte';
5
5
  import StatusEntry from './entries/StatusEntry.svelte';
6
6
  import RichEntry from './entries/RichEntry.svelte';
7
+ import { lookupRichComponent } from './rich-registry';
7
8
 
8
9
  interface Props {
9
10
  scrollback: Scrollback;
11
+ showPromptCwd: boolean;
10
12
  }
11
- let { scrollback }: Props = $props();
13
+ let { scrollback, showPromptCwd }: Props = $props();
12
14
 
13
15
  let container: HTMLDivElement | null = $state(null);
14
16
  let content: HTMLDivElement | null = $state(null);
@@ -48,11 +50,16 @@
48
50
  {#if entry.kind === 'text'}
49
51
  <TextEntry stream={entry.stream} chunks={entry.chunks} />
50
52
  {:else if entry.kind === 'prompt'}
51
- <PromptEntry cwd={entry.cwd} line={entry.line} />
53
+ <PromptEntry cwd={showPromptCwd ? entry.cwd : ''} line={entry.line} />
52
54
  {:else if entry.kind === 'status'}
53
55
  <StatusEntry text={entry.text} level={entry.level} />
54
56
  {:else if entry.kind === 'rich'}
55
- <RichEntry component={entry.component} componentProps={entry.props} />
57
+ {@const comp = entry.component ?? (entry.componentKey ? lookupRichComponent(entry.componentKey) : null)}
58
+ {#if comp}
59
+ <RichEntry component={comp} componentProps={entry.props} />
60
+ {:else}
61
+ <StatusEntry text={`<rich entry: ${entry.componentKey ?? 'unknown'} not registered>`} level="info" />
62
+ {/if}
56
63
  {/if}
57
64
  {/each}
58
65
  </div>
@@ -1,6 +1,7 @@
1
1
  import type { Scrollback } from './scrollback.svelte';
2
2
  interface Props {
3
3
  scrollback: Scrollback;
4
+ showPromptCwd: boolean;
4
5
  }
5
6
  declare const ScrollbackView: import("svelte").Component<Props, {}, "">;
6
7
  type ScrollbackView = ReturnType<typeof ScrollbackView>;
@@ -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',
@@ -182,7 +233,7 @@
182
233
  mode: () => mode,
183
234
  role: () => role,
184
235
  resolver,
185
- scrollback,
236
+ buffer: () => currentBuffer,
186
237
  session,
187
238
  shell: shellWithModes,
188
239
  fs,
@@ -191,8 +242,6 @@
191
242
  customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
192
243
  }));
193
244
 
194
- let locked = $state(false);
195
-
196
245
  // ---------------------------------------------------------------------------
197
246
  // Auto-relocate: track the focused shard and update session.cwd when focus
198
247
  // changes to a shard whose documents directory exists. focusLocked and
@@ -266,18 +315,30 @@
266
315
  });
267
316
 
268
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
+ }
269
324
  if (msg.t !== 'event') return;
270
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');
271
332
  switch (e.kind) {
272
333
  case 'prompt':
273
- scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
274
- locked = true;
334
+ bashBuf.scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
335
+ bashBuf.locked = true;
275
336
  break;
276
337
  case 'stdout':
277
- 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 });
278
339
  break;
279
340
  case 'stderr':
280
- 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 });
281
342
  break;
282
343
  case 'exit':
283
344
  // Match real-shell UX: stay silent on clean exit. Only surface
@@ -285,7 +346,7 @@
285
346
  // processes (SIGINT, spawn errors, etc.). `code === null` with
286
347
  // no signal happens on clean close too — treat as success.
287
348
  if (e.signal || (e.code !== null && e.code !== 0)) {
288
- scrollback.push({
349
+ bashBuf.scrollback.push({
289
350
  kind: 'status',
290
351
  text: e.signal
291
352
  ? `shell: process exited (${e.signal})`
@@ -294,10 +355,10 @@
294
355
  ts: e.ts,
295
356
  });
296
357
  }
297
- locked = false;
358
+ bashBuf.locked = false;
298
359
  break;
299
360
  case 'status':
300
- 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 });
301
362
  break;
302
363
  }
303
364
  }
@@ -306,9 +367,18 @@
306
367
 
307
368
  onMount(() => {
308
369
  unsub = session.onMessage(handleServerMessage);
309
- if (mode.transport === 'ws') {
310
- 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;
311
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();
312
382
  });
313
383
 
314
384
  onDestroy(() => {
@@ -328,11 +398,12 @@
328
398
  'target-shard': { target: targetShard },
329
399
  }}
330
400
  />
331
- <ScrollbackView {scrollback} />
401
+ <ScrollbackView showPromptCwd={mode.showCwd || false} scrollback={currentBuffer.scrollback} />
332
402
  <InputLine
333
- cwd={session.cwd}
334
- {locked}
335
- history={session.history}
403
+ cwd={shortenCwd(session.cwd, session.tenantRoot)}
404
+ showCwd={mode.showCwd !== false}
405
+ locked={currentBuffer.locked}
406
+ history={currentBuffer.history}
336
407
  {session}
337
408
  onSubmit={dispatch}
338
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;