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,7 +1,7 @@
1
1
  import type { VerbRegistry, ShellApi } from './registry';
2
- import type { Scrollback } from './scrollback.svelte';
3
2
  import type { SessionClient } from './session-client.svelte';
4
3
  import type { TenantFsClient } from './tenant-fs-client';
4
+ import type { ModeBuffer } from './mode-buffer.svelte';
5
5
  import type { ShellMode, ShellRole } from './modes/types';
6
6
  import type { ShellModeDescriptor } from './contract';
7
7
  export interface DispatchDeps {
@@ -9,7 +9,12 @@ export interface DispatchDeps {
9
9
  /** Current shell role — used by invoke() role-gating. */
10
10
  role: () => ShellRole;
11
11
  resolver: VerbRegistry;
12
- scrollback: Scrollback;
12
+ /**
13
+ * Active-mode buffer getter. Returns the ModeBuffer for the current
14
+ * mode id; reads its `scrollback` and `history` to fold dispatched
15
+ * activity into per-mode state.
16
+ */
17
+ buffer: () => ModeBuffer;
13
18
  session: SessionClient;
14
19
  shell: ShellApi;
15
20
  fs: TenantFsClient;
@@ -20,7 +20,6 @@ export function makeDispatch(deps) {
20
20
  * because the caller is a trusted mode shard, not user input from a
21
21
  * different mode.
22
22
  */
23
- let bashConnected = false;
24
23
  async function invoke(modeId, line) {
25
24
  var _a, _b, _c, _d;
26
25
  const current = deps.mode();
@@ -35,7 +34,7 @@ export function makeDispatch(deps) {
35
34
  }
36
35
  await resolution.verb.run({
37
36
  shell: deps.shell,
38
- scrollback: deps.scrollback,
37
+ scrollback: deps.buffer().scrollback,
39
38
  session: deps.session,
40
39
  cwd: deps.cwd(),
41
40
  dispatch,
@@ -47,11 +46,7 @@ export function makeDispatch(deps) {
47
46
  if (deps.role() !== 'admin') {
48
47
  throw new Error("invoke: 'bash' requires admin role");
49
48
  }
50
- if (!bashConnected) {
51
- bashConnected = true;
52
- deps.session.connect();
53
- }
54
- deps.session.send({ t: 'submit', line });
49
+ deps.session.send({ t: 'submit', line, mode: 'bash' });
55
50
  return;
56
51
  }
57
52
  // Custom mode
@@ -66,7 +61,7 @@ export function makeDispatch(deps) {
66
61
  throw new Error('invoke: server-side modes are not yet supported');
67
62
  }
68
63
  const subOutput = makeShellModeOutput({
69
- scrollback: deps.scrollback,
64
+ scrollback: deps.buffer().scrollback,
70
65
  busy: deps.busy,
71
66
  invoke,
72
67
  });
@@ -79,26 +74,27 @@ export function makeDispatch(deps) {
79
74
  const controller = new AbortController();
80
75
  activeController = controller;
81
76
  const mode = deps.mode();
82
- deps.session.history.push(line);
77
+ deps.buffer().history.push(line);
83
78
  // User-mode $ escape: block server-shell access
84
79
  if (mode.transport === 'none' && line.trimStart().startsWith('$ ')) {
85
- deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
86
- deps.scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
80
+ deps.buffer().scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
81
+ deps.buffer().scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
87
82
  return;
88
83
  }
89
84
  const resolution = deps.resolver.resolve(line, {
90
85
  globalOnly: mode.id !== 'sh3',
91
86
  });
92
87
  if (resolution.kind === 'local') {
93
- // Log locally-dispatched verbs for shared history (ws only)
94
- if (mode.transport === 'ws') {
95
- deps.session.send({ t: 'history-log', line });
96
- }
97
- deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
88
+ // Log locally-dispatched verbs to the server's per-mode history.
89
+ // The session-client queues this message while disconnected and
90
+ // flushes on reconnect; sh3/custom modes don't depend on a live
91
+ // bash WS for history persistence.
92
+ deps.session.send({ t: 'history-log', line, mode: mode.id });
93
+ deps.buffer().scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
98
94
  try {
99
95
  await resolution.verb.run({
100
96
  shell: deps.shell,
101
- scrollback: deps.scrollback,
97
+ scrollback: deps.buffer().scrollback,
102
98
  session: deps.session,
103
99
  cwd: deps.cwd(),
104
100
  dispatch,
@@ -106,7 +102,7 @@ export function makeDispatch(deps) {
106
102
  }, resolution.args);
107
103
  }
108
104
  catch (err) {
109
- deps.scrollback.push({
105
+ deps.buffer().scrollback.push({
110
106
  kind: 'status',
111
107
  text: `shell: verb ${resolution.verb.name} threw — ${err.message}`,
112
108
  level: 'error',
@@ -117,14 +113,14 @@ export function makeDispatch(deps) {
117
113
  }
118
114
  // forward path
119
115
  if (mode.transport === 'ws') {
120
- deps.session.send({ t: 'submit', line: resolution.line });
116
+ deps.session.send({ t: 'submit', line: resolution.line, mode: mode.id });
121
117
  return;
122
118
  }
123
119
  if (mode.transport === 'custom') {
124
- deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
120
+ deps.buffer().scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
125
121
  const desc = (_b = (_a = deps.customMode) === null || _a === void 0 ? void 0 : _a.call(deps, mode.id)) !== null && _b !== void 0 ? _b : null;
126
122
  if (!desc) {
127
- deps.scrollback.push({
123
+ deps.buffer().scrollback.push({
128
124
  kind: 'status',
129
125
  text: `mode '${mode.id}' is no longer available`,
130
126
  level: 'error',
@@ -133,7 +129,7 @@ export function makeDispatch(deps) {
133
129
  return;
134
130
  }
135
131
  if (desc.runsOn === 'server') {
136
- deps.scrollback.push({
132
+ deps.buffer().scrollback.push({
137
133
  kind: 'status',
138
134
  text: 'server-side modes are not yet supported (planned for a future release)',
139
135
  level: 'error',
@@ -142,7 +138,7 @@ export function makeDispatch(deps) {
142
138
  return;
143
139
  }
144
140
  const output = makeShellModeOutput({
145
- scrollback: deps.scrollback,
141
+ scrollback: deps.buffer().scrollback,
146
142
  busy: deps.busy,
147
143
  invoke,
148
144
  });
@@ -152,10 +148,10 @@ export function makeDispatch(deps) {
152
148
  }
153
149
  catch (err) {
154
150
  if ((err === null || err === void 0 ? void 0 : err.name) === 'AbortError') {
155
- deps.scrollback.push({ kind: 'status', text: 'mode dispatch aborted', level: 'info', ts: Date.now() });
151
+ deps.buffer().scrollback.push({ kind: 'status', text: 'mode dispatch aborted', level: 'info', ts: Date.now() });
156
152
  }
157
153
  else {
158
- deps.scrollback.push({
154
+ deps.buffer().scrollback.push({
159
155
  kind: 'status',
160
156
  text: `mode '${mode.id}' threw — ${(_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : String(err)}`,
161
157
  level: 'error',
@@ -170,8 +166,8 @@ export function makeDispatch(deps) {
170
166
  }
171
167
  // 'none' transport, unknown verb: print error
172
168
  const firstToken = (_d = resolution.line.split(/\s+/)[0]) !== null && _d !== void 0 ? _d : '';
173
- deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
174
- deps.scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
169
+ deps.buffer().scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
170
+ deps.buffer().scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
175
171
  }
176
172
  return {
177
173
  dispatch,
@@ -0,0 +1 @@
1
+ export declare function shortenCwd(cwd: string, tenantRoot: string): string;
@@ -0,0 +1,27 @@
1
+ /*
2
+ * shortenCwd — render a server cwd relative to the per-user tenant root.
3
+ *
4
+ * - Equal to tenant root → `~`
5
+ * - Inside tenant root → `~/<rest>` (forward-slash separators)
6
+ * - Outside tenant root → cwd unchanged
7
+ *
8
+ * Boundary-aware: a tenant root of `/foo` does not match `/foobar`. Handles
9
+ * both POSIX (`/`) and Windows (`\`) path separators in the input; the
10
+ * resulting display path always uses forward slashes for readability.
11
+ */
12
+ export function shortenCwd(cwd, tenantRoot) {
13
+ if (!cwd || !tenantRoot)
14
+ return cwd;
15
+ if (cwd === tenantRoot)
16
+ return '~';
17
+ // Boundary check: the next character after the tenant prefix must be a
18
+ // path separator; otherwise we'd shorten /foobar against /foo.
19
+ if (cwd.length > tenantRoot.length && cwd.startsWith(tenantRoot)) {
20
+ const next = cwd.charAt(tenantRoot.length);
21
+ if (next === '/' || next === '\\') {
22
+ const rest = cwd.slice(tenantRoot.length + 1).replace(/\\/g, '/');
23
+ return `~/${rest}`;
24
+ }
25
+ }
26
+ return cwd;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { shortenCwd } from './display-cwd';
3
+ describe('shortenCwd', () => {
4
+ it('returns ~ when cwd equals tenant root', () => {
5
+ expect(shortenCwd('/home/u/data/users/x/documents/shell', '/home/u/data/users/x/documents/shell')).toBe('~');
6
+ });
7
+ it('substitutes ~ for tenant-root prefix (POSIX)', () => {
8
+ expect(shortenCwd('/home/u/data/users/x/documents/shell/notes', '/home/u/data/users/x/documents/shell')).toBe('~/notes');
9
+ });
10
+ it('substitutes ~ for tenant-root prefix (Windows)', () => {
11
+ expect(shortenCwd('C:\\a\\b\\users\\x\\documents\\shell\\notes', 'C:\\a\\b\\users\\x\\documents\\shell')).toBe('~/notes');
12
+ });
13
+ it('normalizes Windows backslashes to forward slashes after the tilde', () => {
14
+ expect(shortenCwd('C:\\root\\a\\b\\c', 'C:\\root')).toBe('~/a/b/c');
15
+ });
16
+ it('returns the absolute cwd when outside the tenant root', () => {
17
+ expect(shortenCwd('/tmp', '/home/u/data/users/x/documents/shell')).toBe('/tmp');
18
+ });
19
+ it('does not match when cwd matches the tenant root prefix without a separator boundary', () => {
20
+ // tenant=/foo, cwd=/foobar → must NOT shorten to ~bar
21
+ expect(shortenCwd('/foobar', '/foo')).toBe('/foobar');
22
+ });
23
+ it('returns cwd unchanged when tenantRoot is empty', () => {
24
+ expect(shortenCwd('/some/path', '')).toBe('/some/path');
25
+ });
26
+ it('returns cwd unchanged when cwd is empty', () => {
27
+ expect(shortenCwd('', '/tenant')).toBe('');
28
+ });
29
+ });
@@ -15,6 +15,8 @@
15
15
  padding: 2px 8px;
16
16
  font-family: var(--shell-font-mono, monospace);
17
17
  font-style: italic;
18
+ white-space: pre-wrap;
19
+ word-break: break-word;
18
20
  }
19
21
  .shell-status.info { color: var(--shell-fg-muted, #888); }
20
22
  .shell-status.warn { color: var(--shell-fg-warn, #fc6); }
@@ -1,4 +1,5 @@
1
1
  import { VERSION } from '../version';
2
+ import { PERMISSION_STATE_MANAGE } from '../state/types';
2
3
  export const manifest = {
3
4
  id: 'shell',
4
5
  label: 'Shell',
@@ -8,5 +9,5 @@ export const manifest = {
8
9
  // and is statically mounted at sh3-server boot. The existing contract in
9
10
  // sh3-core/src/shards/types.ts documents that framework-shipped shards do
10
11
  // not use this field.
11
- permissions: [],
12
+ permissions: [PERMISSION_STATE_MANAGE],
12
13
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { manifest } from './manifest';
3
+ import { PERMISSION_STATE_MANAGE } from '../state/types';
4
+ describe('shell manifest', () => {
5
+ it('declares state:manage permission for cross-shard zone introspection', () => {
6
+ expect(manifest.permissions).toContain(PERMISSION_STATE_MANAGE);
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { Scrollback } from './scrollback.svelte';
2
+ export declare class ModeBuffer {
3
+ readonly modeId: string;
4
+ readonly scrollback: Scrollback;
5
+ history: string[];
6
+ locked: boolean;
7
+ constructor(modeId: string);
8
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ * ModeBuffer — per-mode bundle of scrollback + history + locked flag.
3
+ *
4
+ * Owned by Terminal.svelte's Map<modeId, ModeBuffer>. Switching mode
5
+ * rebinds the visible buffer; each buffer remains in the map so the
6
+ * user can flip back without losing state.
7
+ *
8
+ * `locked` is bash-specific today (set true while a process runs).
9
+ * sh3 / custom modes leave it false.
10
+ */
11
+ import { Scrollback } from './scrollback.svelte';
12
+ export class ModeBuffer {
13
+ constructor(modeId) {
14
+ this.history = $state([]);
15
+ this.locked = $state(false);
16
+ this.modeId = modeId;
17
+ this.scrollback = new Scrollback();
18
+ }
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ModeBuffer } from './mode-buffer.svelte';
3
+ describe('ModeBuffer', () => {
4
+ it('constructs with empty scrollback and history', () => {
5
+ const buf = new ModeBuffer('sh3');
6
+ expect(buf.modeId).toBe('sh3');
7
+ expect(buf.scrollback.entries).toEqual([]);
8
+ expect(buf.history).toEqual([]);
9
+ expect(buf.locked).toBe(false);
10
+ });
11
+ it('history is independently mutable per buffer', () => {
12
+ const a = new ModeBuffer('a');
13
+ const b = new ModeBuffer('b');
14
+ a.history.push('one');
15
+ expect(a.history).toEqual(['one']);
16
+ expect(b.history).toEqual([]);
17
+ });
18
+ it('locked flag is independently mutable per buffer', () => {
19
+ const a = new ModeBuffer('a');
20
+ const b = new ModeBuffer('b');
21
+ a.locked = true;
22
+ expect(a.locked).toBe(true);
23
+ expect(b.locked).toBe(false);
24
+ });
25
+ });
@@ -5,12 +5,14 @@ export const BASH_MODE = {
5
5
  requiresRole: 'admin',
6
6
  transport: 'ws',
7
7
  autoRelocate: false,
8
+ showCwd: true,
8
9
  };
9
10
  export const SH3_MODE = {
10
11
  id: 'sh3',
11
12
  label: 'SH3',
12
13
  transport: 'none',
13
14
  autoRelocate: true,
15
+ showCwd: false,
14
16
  };
15
17
  export function registerBuiltinModes(reg) {
16
18
  reg.register(BASH_MODE);
@@ -5,4 +5,12 @@ export interface ShellMode {
5
5
  requiresRole?: 'admin';
6
6
  transport: 'ws' | 'none' | 'custom';
7
7
  autoRelocate: boolean;
8
+ /**
9
+ * Whether the input prompt should display the current working directory.
10
+ * Builtin shell modes (sh3, bash) set this true; custom modes default to
11
+ * false (a `cwd` chip in front of every prompt is rarely meaningful for
12
+ * LLM/agent-style modes). Undefined falls through to the consumer's
13
+ * default — InputLine renders the cwd unless this is explicitly false.
14
+ */
15
+ showCwd?: boolean;
8
16
  }
@@ -5,20 +5,23 @@ export type ClientMessage =
5
5
  t: 'hello';
6
6
  replayFrom?: number;
7
7
  }
8
- /** A line the client could not resolve locally. */
8
+ /** A line the client could not resolve locally. `mode` tags the active
9
+ * mode at submit time; the server records it on the history entry. */
9
10
  | {
10
11
  t: 'submit';
11
12
  line: string;
13
+ mode: string;
12
14
  }
13
15
  /** Ctrl+C / Ctrl+D while a process runs. */
14
16
  | {
15
17
  t: 'signal';
16
18
  sig: 'SIGINT' | 'EOF';
17
19
  }
18
- /** Local verb dispatch — tell the server to append to shared history. */
20
+ /** Local verb dispatch — tell the server to append to per-mode history. */
19
21
  | {
20
22
  t: 'history-log';
21
23
  line: string;
24
+ mode: string;
22
25
  }
23
26
  /** Ask the server for its current cwd. */
24
27
  | {
@@ -31,11 +34,14 @@ export type ClientMessage =
31
34
  path: string;
32
35
  };
33
36
  export type ServerMessage =
34
- /** Sent once on successful attach, immediately after hello. */
37
+ /** Sent once on successful attach, immediately after hello.
38
+ * `tenantRoot` is the initial per-user shell cwd; clients use it to
39
+ * shorten displayed paths (e.g., render the tenant root as `~`). */
35
40
  {
36
41
  t: 'welcome';
37
42
  userId: string;
38
43
  cwd: string;
44
+ tenantRoot: string;
39
45
  env: Record<string, string>;
40
46
  seq: number;
41
47
  }
@@ -44,10 +50,10 @@ export type ServerMessage =
44
50
  t: 'replay';
45
51
  events: ServerEvent[];
46
52
  }
47
- /** Persisted history for up-arrow navigation. */
53
+ /** Per-mode persisted history for up-arrow navigation. Keyed by mode id. */
48
54
  | {
49
- t: 'history';
50
- lines: string[];
55
+ t: 'history-bundle';
56
+ byMode: Record<string, string[]>;
51
57
  }
52
58
  /** Live event. */
53
59
  | {
@@ -0,0 +1,3 @@
1
+ import type { ModeBuffer } from './mode-buffer.svelte';
2
+ import type { ServerEvent } from './protocol';
3
+ export declare function applyReplayEvents(buf: ModeBuffer, events: ServerEvent[]): void;
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Pure replay-folder. Takes a list of server events and folds them into
3
+ * a ModeBuffer in the same order, using the same routing rules as the
4
+ * live event-handler in Terminal.svelte. Extracted so it can be unit-
5
+ * tested without DOM mounting and so the live handler and the on-attach
6
+ * replay path share one implementation of the rules.
7
+ *
8
+ * Used by Terminal.svelte's `t: 'replay'` handler to fill the gap between
9
+ * the last persisted bash-buffer snapshot (workspace zone) and the WS
10
+ * reconnect — events the server still has in its ring buffer but the
11
+ * client hasn't seen since the last page load.
12
+ */
13
+ export function applyReplayEvents(buf, events) {
14
+ for (const e of events) {
15
+ switch (e.kind) {
16
+ case 'prompt':
17
+ buf.scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
18
+ buf.locked = true;
19
+ break;
20
+ case 'stdout':
21
+ buf.scrollback.push({ kind: 'text', stream: 'stdout', chunks: [e.data], ts: e.ts });
22
+ break;
23
+ case 'stderr':
24
+ buf.scrollback.push({ kind: 'text', stream: 'stderr', chunks: [e.data], ts: e.ts });
25
+ break;
26
+ case 'exit':
27
+ if (e.signal || (e.code !== null && e.code !== 0)) {
28
+ buf.scrollback.push({
29
+ kind: 'status',
30
+ text: e.signal
31
+ ? `shell: process exited (${e.signal})`
32
+ : `shell: process exited (${e.code})`,
33
+ level: 'error',
34
+ ts: e.ts,
35
+ });
36
+ }
37
+ buf.locked = false;
38
+ break;
39
+ case 'status':
40
+ buf.scrollback.push({ kind: 'status', text: e.text, level: e.level, ts: e.ts });
41
+ break;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { applyReplayEvents } from './replay';
3
+ import { ModeBuffer } from './mode-buffer.svelte';
4
+ describe('applyReplayEvents', () => {
5
+ it('folds events into a fresh bash buffer like live events', () => {
6
+ const buf = new ModeBuffer('bash');
7
+ const events = [
8
+ { seq: 5, kind: 'prompt', line: 'ls', cwd: '/', ts: 100 },
9
+ { seq: 6, kind: 'stdout', data: 'a\nb\n', ts: 101 },
10
+ { seq: 7, kind: 'exit', code: 0, signal: null, ts: 102 },
11
+ ];
12
+ applyReplayEvents(buf, events);
13
+ // prompt + coalesced text — exit code 0 is silent (matches Terminal.svelte UX).
14
+ expect(buf.scrollback.entries).toHaveLength(2);
15
+ expect(buf.locked).toBe(false);
16
+ });
17
+ it('locks the buffer if replay ends with a prompt without exit', () => {
18
+ const buf = new ModeBuffer('bash');
19
+ const events = [
20
+ { seq: 10, kind: 'prompt', line: 'sleep 5', cwd: '/', ts: 200 },
21
+ ];
22
+ applyReplayEvents(buf, events);
23
+ expect(buf.locked).toBe(true);
24
+ });
25
+ it('emits a status entry on non-zero exit', () => {
26
+ const buf = new ModeBuffer('bash');
27
+ const events = [
28
+ { seq: 1, kind: 'prompt', line: 'false', cwd: '/', ts: 1 },
29
+ { seq: 2, kind: 'exit', code: 1, signal: null, ts: 2 },
30
+ ];
31
+ applyReplayEvents(buf, events);
32
+ const last = buf.scrollback.entries[buf.scrollback.entries.length - 1];
33
+ expect(last.kind).toBe('status');
34
+ if (last.kind === 'status')
35
+ expect(last.text).toMatch(/exited \(1\)/);
36
+ });
37
+ it('routes stderr to a stderr text entry', () => {
38
+ const buf = new ModeBuffer('bash');
39
+ applyReplayEvents(buf, [
40
+ { seq: 1, kind: 'stderr', data: 'oops\n', ts: 1 },
41
+ ]);
42
+ const e = buf.scrollback.entries[0];
43
+ expect(e.kind).toBe('text');
44
+ if (e.kind === 'text')
45
+ expect(e.stream).toBe('stderr');
46
+ });
47
+ });
@@ -0,0 +1,5 @@
1
+ import type { Component } from 'svelte';
2
+ export declare function registerRichComponent(key: string, component: Component<any>): void;
3
+ export declare function lookupRichComponent(key: string): Component<any> | null;
4
+ /** Test-only seam. Do not call from production code. */
5
+ export declare function __resetRichRegistryForTests(): void;
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Stable string-keyed registry mapping a componentKey to a Svelte
3
+ * component. Used by ScrollbackView (live render) and BufferStore
4
+ * (rehydrate persisted rich entries).
5
+ *
6
+ * Each rich-emitting verb registers its component once at module load.
7
+ * Keys must be globally unique within the shell-shard namespace.
8
+ */
9
+ const components = new Map();
10
+ export function registerRichComponent(key, component) {
11
+ const existing = components.get(key);
12
+ if (existing && existing !== component) {
13
+ console.warn(`[shell-shard/rich-registry] duplicate registration for key "${key}" — keeping first`);
14
+ return;
15
+ }
16
+ components.set(key, component);
17
+ }
18
+ export function lookupRichComponent(key) {
19
+ var _a;
20
+ return (_a = components.get(key)) !== null && _a !== void 0 ? _a : null;
21
+ }
22
+ /** Test-only seam. Do not call from production code. */
23
+ export function __resetRichRegistryForTests() {
24
+ components.clear();
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { registerRichComponent, lookupRichComponent, __resetRichRegistryForTests, } from './rich-registry';
3
+ const FakeComp = {};
4
+ const OtherComp = {};
5
+ describe('rich-registry', () => {
6
+ beforeEach(() => {
7
+ __resetRichRegistryForTests();
8
+ });
9
+ it('returns null for unknown keys', () => {
10
+ expect(lookupRichComponent('nope')).toBe(null);
11
+ });
12
+ it('roundtrips a registered key', () => {
13
+ registerRichComponent('test', FakeComp);
14
+ expect(lookupRichComponent('test')).toBe(FakeComp);
15
+ });
16
+ it('warns and ignores duplicate registrations of a different component', () => {
17
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
18
+ registerRichComponent('dup', FakeComp);
19
+ registerRichComponent('dup', OtherComp);
20
+ expect(lookupRichComponent('dup')).toBe(FakeComp);
21
+ expect(warn).toHaveBeenCalledOnce();
22
+ warn.mockRestore();
23
+ });
24
+ it('idempotent registration of the same component is silent', () => {
25
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
26
+ registerRichComponent('idem', FakeComp);
27
+ registerRichComponent('idem', FakeComp);
28
+ expect(warn).not.toHaveBeenCalled();
29
+ warn.mockRestore();
30
+ });
31
+ });
@@ -15,6 +15,7 @@ export type ScrollbackEntry = {
15
15
  } | {
16
16
  kind: 'rich';
17
17
  id: string;
18
+ componentKey?: string;
18
19
  component: RichComponent;
19
20
  props: Record<string, unknown>;
20
21
  ts: number;
@@ -32,5 +33,6 @@ export declare class Scrollback {
32
33
  constructor(cap?: number);
33
34
  push(entry: DistributiveOmit<ScrollbackEntry, 'id'>): void;
34
35
  clear(): void;
36
+ restore(entries: ScrollbackEntry[]): void;
35
37
  }
36
38
  export {};
@@ -16,6 +16,14 @@ let nextId = 0;
16
16
  function mkId() {
17
17
  return `e${++nextId}`;
18
18
  }
19
+ // Parses the numeric suffix of an id minted by mkId (`e<n>`). Returns NaN
20
+ // for any other shape so restore() can ignore it without throwing.
21
+ function parseIdNumber(id) {
22
+ if (id.length < 2 || id.charCodeAt(0) !== 101 /* 'e' */)
23
+ return NaN;
24
+ const n = Number(id.slice(1));
25
+ return Number.isInteger(n) ? n : NaN;
26
+ }
19
27
  export class Scrollback {
20
28
  constructor(cap = DEFAULT_CAP) {
21
29
  this.entries = $state([]);
@@ -40,4 +48,19 @@ export class Scrollback {
40
48
  clear() {
41
49
  this.entries.length = 0;
42
50
  }
51
+ // Replace entries from a persisted snapshot. Splices in place to keep the
52
+ // $state proxy identity stable, and bumps the module-scoped id counter
53
+ // past any restored numeric id so subsequent push()es never collide with
54
+ // hydrated keys (which would crash Svelte's keyed {#each}).
55
+ restore(entries) {
56
+ let max = 0;
57
+ for (const e of entries) {
58
+ const n = parseIdNumber(e.id);
59
+ if (Number.isFinite(n) && n > max)
60
+ max = n;
61
+ }
62
+ if (max > nextId)
63
+ nextId = max;
64
+ this.entries.splice(0, this.entries.length, ...entries);
65
+ }
43
66
  }
@@ -0,0 +1 @@
1
+ export {};