sh3-core 0.13.4 → 0.14.3

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 (65) hide show
  1. package/dist/api.d.ts +3 -0
  2. package/dist/api.js +3 -0
  3. package/dist/host.js +2 -0
  4. package/dist/layout/LayoutRenderer.svelte +1 -1
  5. package/dist/layout/tree-walk.js +6 -1
  6. package/dist/layout/types.d.ts +7 -0
  7. package/dist/migrations/mode-id-rename.d.ts +9 -0
  8. package/dist/migrations/mode-id-rename.js +39 -0
  9. package/dist/migrations/mode-id-rename.test.d.ts +1 -0
  10. package/dist/migrations/mode-id-rename.test.js +52 -0
  11. package/dist/overlays/FloatFrame.svelte +8 -2
  12. package/dist/overlays/float.js +6 -3
  13. package/dist/overlays/float.test.js +71 -0
  14. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
  15. package/dist/primitives/widgets/Segmented.svelte +4 -1
  16. package/dist/sh3core-shard/AppInfoView.svelte +154 -0
  17. package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
  18. package/dist/sh3core-shard/appActions.js +23 -5
  19. package/dist/shell-shard/ScrollbackView.svelte +40 -19
  20. package/dist/shell-shard/Terminal.svelte +140 -12
  21. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  22. package/dist/shell-shard/contract.d.ts +99 -0
  23. package/dist/shell-shard/contract.js +11 -0
  24. package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
  25. package/dist/shell-shard/dispatch-custom.test.js +152 -0
  26. package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
  27. package/dist/shell-shard/dispatch-gating.test.js +63 -0
  28. package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
  29. package/dist/shell-shard/dispatch-invoke.test.js +214 -0
  30. package/dist/shell-shard/dispatch.d.ts +23 -2
  31. package/dist/shell-shard/dispatch.js +130 -6
  32. package/dist/shell-shard/modes/builtin.d.ts +2 -2
  33. package/dist/shell-shard/modes/builtin.js +8 -8
  34. package/dist/shell-shard/modes/prefs.js +1 -1
  35. package/dist/shell-shard/modes/prefs.test.js +13 -13
  36. package/dist/shell-shard/modes/registry.test.js +13 -13
  37. package/dist/shell-shard/output.d.ts +10 -0
  38. package/dist/shell-shard/output.js +91 -0
  39. package/dist/shell-shard/output.test.d.ts +1 -0
  40. package/dist/shell-shard/output.test.js +73 -0
  41. package/dist/shell-shard/registerShellMode.d.ts +13 -0
  42. package/dist/shell-shard/registerShellMode.js +14 -0
  43. package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
  44. package/dist/shell-shard/registerShellMode.test.js +19 -0
  45. package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
  46. package/dist/shell-shard/registry-resolve.test.js +26 -0
  47. package/dist/shell-shard/registry.d.ts +12 -1
  48. package/dist/shell-shard/registry.js +12 -1
  49. package/dist/shell-shard/shellShard.svelte.js +8 -1
  50. package/dist/shell-shard/terminal-dispatch.test.js +19 -12
  51. package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
  52. package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
  53. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
  54. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
  55. package/dist/shell-shard/toolbar/slots.test.js +6 -6
  56. package/dist/shell-shard/verbs/clear.js +1 -0
  57. package/dist/shell-shard/verbs/index.js +2 -0
  58. package/dist/shell-shard/verbs/mode.d.ts +2 -0
  59. package/dist/shell-shard/verbs/mode.js +29 -0
  60. package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
  61. package/dist/shell-shard/verbs/mode.test.js +43 -0
  62. package/dist/verbs/types.d.ts +19 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { makeDispatch } from './dispatch';
3
+ const openVerb = {
4
+ name: 'open',
5
+ summary: '',
6
+ async run(ctx, args) {
7
+ var _a;
8
+ ctx.scrollback.push({ kind: 'status', text: `opened:${(_a = args[0]) !== null && _a !== void 0 ? _a : ''}`, level: 'info', ts: 0 });
9
+ },
10
+ };
11
+ function scaffold(opts) {
12
+ const sent = [];
13
+ const pushed = [];
14
+ const connectSpy = vi.fn();
15
+ const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
16
+ const session = {
17
+ history: { push: vi.fn() },
18
+ send: (m) => sent.push(m),
19
+ cwd: '/',
20
+ connect: connectSpy,
21
+ };
22
+ const fs = {};
23
+ const shell = {};
24
+ const resolver = {
25
+ resolve: (line, _opts = {}) => {
26
+ const head = line.trim().split(/\s+/)[0];
27
+ const rest = line.trim().split(/\s+/).slice(1);
28
+ if (head === 'open')
29
+ return { kind: 'local', verb: openVerb, args: rest, line };
30
+ return { kind: 'forward', line };
31
+ },
32
+ };
33
+ const { dispatch } = makeDispatch({
34
+ mode: () => opts.current,
35
+ role: () => opts.role,
36
+ resolver,
37
+ scrollback,
38
+ session,
39
+ shell,
40
+ fs,
41
+ cwd: () => '/',
42
+ busy: () => () => { },
43
+ customMode: (id) => { var _a, _b; return (_b = (_a = opts.customs) === null || _a === void 0 ? void 0 : _a.find((d) => d.id === id)) !== null && _b !== void 0 ? _b : null; },
44
+ });
45
+ return { dispatch, sent, pushed, connectSpy };
46
+ }
47
+ const customMode = (id) => ({ id, label: id, transport: 'custom', autoRelocate: false });
48
+ describe('output.invoke — sh3 target', () => {
49
+ it('runs an sh3 verb when invoked from a custom mode', async () => {
50
+ const captured = [];
51
+ const customs = [{
52
+ id: 'gemini',
53
+ label: 'Gemini',
54
+ runsOn: 'client',
55
+ dispatch: async (_input, output) => {
56
+ await output.invoke('sh3', 'open foo.md');
57
+ captured.push('after-invoke');
58
+ },
59
+ }];
60
+ const { dispatch, pushed } = scaffold({
61
+ current: customMode('gemini'),
62
+ role: 'user',
63
+ customs,
64
+ });
65
+ await dispatch('hello');
66
+ expect(pushed.some((e) => e.kind === 'status' && e.text === 'opened:foo.md')).toBe(true);
67
+ expect(captured).toEqual(['after-invoke']);
68
+ });
69
+ it('throws for unknown sh3 verb', async () => {
70
+ let caught;
71
+ const customs = [{
72
+ id: 'gemini',
73
+ label: 'Gemini',
74
+ runsOn: 'client',
75
+ dispatch: async (_input, output) => {
76
+ try {
77
+ await output.invoke('sh3', 'nonexistent');
78
+ }
79
+ catch (e) {
80
+ caught = e;
81
+ }
82
+ },
83
+ }];
84
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
85
+ await dispatch('hello');
86
+ expect(caught).toBeInstanceOf(Error);
87
+ expect(caught.message).toMatch(/unknown sh3 verb/);
88
+ });
89
+ });
90
+ describe('output.invoke — bash target', () => {
91
+ it('admin invocation lazy-connects and forwards', async () => {
92
+ const customs = [{
93
+ id: 'gemini',
94
+ label: 'Gemini',
95
+ runsOn: 'client',
96
+ dispatch: async (_input, output) => {
97
+ await output.invoke('bash', 'ls');
98
+ },
99
+ }];
100
+ const { dispatch, sent, connectSpy } = scaffold({
101
+ current: customMode('gemini'),
102
+ role: 'admin',
103
+ customs,
104
+ });
105
+ await dispatch('hello');
106
+ expect(connectSpy).toHaveBeenCalledTimes(1);
107
+ expect(sent.some((m) => m.t === 'submit' && m.line === 'ls')).toBe(true);
108
+ });
109
+ it('lazy-connect only fires once across multiple invokes', async () => {
110
+ const customs = [{
111
+ id: 'gemini',
112
+ label: 'Gemini',
113
+ runsOn: 'client',
114
+ dispatch: async (_input, output) => {
115
+ await output.invoke('bash', 'ls');
116
+ await output.invoke('bash', 'pwd');
117
+ },
118
+ }];
119
+ const { dispatch, connectSpy } = scaffold({
120
+ current: customMode('gemini'),
121
+ role: 'admin',
122
+ customs,
123
+ });
124
+ await dispatch('hello');
125
+ expect(connectSpy).toHaveBeenCalledTimes(1);
126
+ });
127
+ it('non-admin invoking bash throws', async () => {
128
+ let caught;
129
+ const customs = [{
130
+ id: 'gemini',
131
+ label: 'Gemini',
132
+ runsOn: 'client',
133
+ dispatch: async (_input, output) => {
134
+ try {
135
+ await output.invoke('bash', 'ls');
136
+ }
137
+ catch (e) {
138
+ caught = e;
139
+ }
140
+ },
141
+ }];
142
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
143
+ await dispatch('hello');
144
+ expect(caught).toBeInstanceOf(Error);
145
+ expect(caught.message).toMatch(/admin role/);
146
+ });
147
+ });
148
+ describe('output.invoke — guards', () => {
149
+ it('self-invoke throws', async () => {
150
+ let caught;
151
+ const customs = [{
152
+ id: 'gemini',
153
+ label: 'Gemini',
154
+ runsOn: 'client',
155
+ dispatch: async (_input, output) => {
156
+ try {
157
+ await output.invoke('gemini', 'foo');
158
+ }
159
+ catch (e) {
160
+ caught = e;
161
+ }
162
+ },
163
+ }];
164
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
165
+ await dispatch('hello');
166
+ expect(caught).toBeInstanceOf(Error);
167
+ expect(caught.message).toMatch(/cannot invoke own mode/);
168
+ });
169
+ it('unknown custom mode throws', async () => {
170
+ let caught;
171
+ const customs = [{
172
+ id: 'gemini',
173
+ label: 'Gemini',
174
+ runsOn: 'client',
175
+ dispatch: async (_input, output) => {
176
+ try {
177
+ await output.invoke('claude', 'foo');
178
+ }
179
+ catch (e) {
180
+ caught = e;
181
+ }
182
+ },
183
+ }];
184
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
185
+ await dispatch('hello');
186
+ expect(caught).toBeInstanceOf(Error);
187
+ expect(caught.message).toMatch(/unknown mode/);
188
+ });
189
+ });
190
+ describe('output.invoke — custom target', () => {
191
+ it('routes through the target descriptor dispatch with the same scrollback', async () => {
192
+ const customs = [
193
+ {
194
+ id: 'gemini',
195
+ label: 'Gemini',
196
+ runsOn: 'client',
197
+ dispatch: async (_input, output) => {
198
+ await output.invoke('claude', 'hi');
199
+ },
200
+ },
201
+ {
202
+ id: 'claude',
203
+ label: 'Claude',
204
+ runsOn: 'client',
205
+ dispatch: async (input, output) => {
206
+ output.text('stdout', `claude:${input.line}\n`);
207
+ },
208
+ },
209
+ ];
210
+ const { dispatch, pushed } = scaffold({ current: customMode('gemini'), role: 'user', customs });
211
+ await dispatch('hello');
212
+ expect(pushed.some((e) => { var _a, _b; return e.kind === 'text' && ((_b = (_a = e.chunks) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.includes('claude:hi')); })).toBe(true);
213
+ });
214
+ });
@@ -2,14 +2,35 @@ import type { VerbRegistry, ShellApi } from './registry';
2
2
  import type { Scrollback } from './scrollback.svelte';
3
3
  import type { SessionClient } from './session-client.svelte';
4
4
  import type { TenantFsClient } from './tenant-fs-client';
5
- import type { ShellMode } from './modes/types';
5
+ import type { ShellMode, ShellRole } from './modes/types';
6
+ import type { ShellModeDescriptor } from './contract';
6
7
  export interface DispatchDeps {
7
8
  mode: () => ShellMode;
9
+ /** Current shell role — used by invoke() role-gating. */
10
+ role: () => ShellRole;
8
11
  resolver: VerbRegistry;
9
12
  scrollback: Scrollback;
10
13
  session: SessionClient;
11
14
  shell: ShellApi;
12
15
  fs: TenantFsClient;
13
16
  cwd: () => string;
17
+ /**
18
+ * Acquire a busy indicator. Returns a clear handle. Calling clear()
19
+ * multiple times is safe (idempotent). Used internally to auto-spawn
20
+ * a spinner around custom-mode dispatch and exposed via output.busy().
21
+ */
22
+ busy: (label?: string) => () => void;
23
+ /**
24
+ * Look up a contributed mode descriptor by id. Called only when the active
25
+ * mode has `transport: 'custom'`. Returns null if the descriptor has been
26
+ * unloaded (rare race; the active-mode-fallback effect in Terminal.svelte
27
+ * handles this on the next tick — dispatch surfaces an error in the meantime).
28
+ */
29
+ customMode?: (id: string) => ShellModeDescriptor | null;
14
30
  }
15
- export declare function makeDispatch(deps: DispatchDeps): (line: string) => Promise<void>;
31
+ export interface DispatchHandle {
32
+ dispatch: (line: string) => Promise<void>;
33
+ /** Abort any in-flight custom-mode dispatch. Safe to call repeatedly. */
34
+ cancel: () => void;
35
+ }
36
+ export declare function makeDispatch(deps: DispatchDeps): DispatchHandle;
@@ -4,10 +4,80 @@
4
4
  * Pure function (no Svelte reactivity) so it can be unit-tested independently.
5
5
  * The mode is passed as a getter so the dispatch closure always sees the
6
6
  * current mode without being reconstructed on every mode change.
7
+ *
8
+ * Returns a `{ dispatch, cancel }` handle so the caller can abort an
9
+ * in-flight custom-mode dispatch (e.g. when the user switches mode mid-stream).
7
10
  */
11
+ import { makeShellModeOutput } from './output';
8
12
  export function makeDispatch(deps) {
9
- return async function dispatch(line) {
10
- var _a;
13
+ let activeController = null;
14
+ /**
15
+ * Programmatic cross-mode dispatch. Validates target id, role gate, and
16
+ * self-invoke; routes to sh3 (full local resolution), bash (WS forward
17
+ * with lazy connect), or a custom descriptor (recursive desc.dispatch).
18
+ *
19
+ * sh3 path bypasses mode gating: a verb's globalVerb flag is irrelevant
20
+ * because the caller is a trusted mode shard, not user input from a
21
+ * different mode.
22
+ */
23
+ let bashConnected = false;
24
+ async function invoke(modeId, line) {
25
+ var _a, _b, _c, _d;
26
+ const current = deps.mode();
27
+ if (modeId === current.id) {
28
+ throw new Error(`invoke: cannot invoke own mode '${modeId}'`);
29
+ }
30
+ if (modeId === 'sh3') {
31
+ const resolution = deps.resolver.resolve(line, { globalOnly: false });
32
+ if (resolution.kind === 'forward') {
33
+ const head = (_a = line.trim().split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
34
+ throw new Error(`invoke: unknown sh3 verb '${head}'`);
35
+ }
36
+ await resolution.verb.run({
37
+ shell: deps.shell,
38
+ scrollback: deps.scrollback,
39
+ session: deps.session,
40
+ cwd: deps.cwd(),
41
+ dispatch,
42
+ fs: deps.fs,
43
+ }, resolution.args);
44
+ return;
45
+ }
46
+ if (modeId === 'bash') {
47
+ if (deps.role() !== 'admin') {
48
+ throw new Error("invoke: 'bash' requires admin role");
49
+ }
50
+ if (!bashConnected) {
51
+ bashConnected = true;
52
+ deps.session.connect();
53
+ }
54
+ deps.session.send({ t: 'submit', line });
55
+ return;
56
+ }
57
+ // Custom mode
58
+ const desc = (_c = (_b = deps.customMode) === null || _b === void 0 ? void 0 : _b.call(deps, modeId)) !== null && _c !== void 0 ? _c : null;
59
+ if (!desc) {
60
+ throw new Error(`invoke: unknown mode '${modeId}'`);
61
+ }
62
+ if (desc.requiresRole && desc.requiresRole !== deps.role()) {
63
+ throw new Error(`invoke: mode '${modeId}' requires ${desc.requiresRole} role`);
64
+ }
65
+ if (desc.runsOn === 'server') {
66
+ throw new Error('invoke: server-side modes are not yet supported');
67
+ }
68
+ const subOutput = makeShellModeOutput({
69
+ scrollback: deps.scrollback,
70
+ busy: deps.busy,
71
+ invoke,
72
+ });
73
+ await desc.dispatch({ line, cwd: deps.cwd(), signal: (_d = activeController === null || activeController === void 0 ? void 0 : activeController.signal) !== null && _d !== void 0 ? _d : new AbortController().signal }, subOutput);
74
+ }
75
+ async function dispatch(line) {
76
+ var _a, _b, _c, _d;
77
+ // Abort any in-flight custom dispatch when a new line is submitted.
78
+ activeController === null || activeController === void 0 ? void 0 : activeController.abort();
79
+ const controller = new AbortController();
80
+ activeController = controller;
11
81
  const mode = deps.mode();
12
82
  deps.session.history.push(line);
13
83
  // User-mode $ escape: block server-shell access
@@ -16,7 +86,9 @@ export function makeDispatch(deps) {
16
86
  deps.scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
17
87
  return;
18
88
  }
19
- const resolution = deps.resolver.resolve(line);
89
+ const resolution = deps.resolver.resolve(line, {
90
+ globalOnly: mode.id !== 'sh3',
91
+ });
20
92
  if (resolution.kind === 'local') {
21
93
  // Log locally-dispatched verbs for shared history (ws only)
22
94
  if (mode.transport === 'ws') {
@@ -46,11 +118,63 @@ export function makeDispatch(deps) {
46
118
  // forward path
47
119
  if (mode.transport === 'ws') {
48
120
  deps.session.send({ t: 'submit', line: resolution.line });
121
+ return;
49
122
  }
50
- else {
51
- const firstToken = (_a = resolution.line.split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
123
+ if (mode.transport === 'custom') {
52
124
  deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
53
- deps.scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
125
+ const desc = (_b = (_a = deps.customMode) === null || _a === void 0 ? void 0 : _a.call(deps, mode.id)) !== null && _b !== void 0 ? _b : null;
126
+ if (!desc) {
127
+ deps.scrollback.push({
128
+ kind: 'status',
129
+ text: `mode '${mode.id}' is no longer available`,
130
+ level: 'error',
131
+ ts: Date.now(),
132
+ });
133
+ return;
134
+ }
135
+ if (desc.runsOn === 'server') {
136
+ deps.scrollback.push({
137
+ kind: 'status',
138
+ text: 'server-side modes are not yet supported (planned for a future release)',
139
+ level: 'error',
140
+ ts: Date.now(),
141
+ });
142
+ return;
143
+ }
144
+ const output = makeShellModeOutput({
145
+ scrollback: deps.scrollback,
146
+ busy: deps.busy,
147
+ invoke,
148
+ });
149
+ const clearBusy = deps.busy();
150
+ try {
151
+ await desc.dispatch({ line: resolution.line, cwd: deps.cwd(), signal: controller.signal }, output);
152
+ }
153
+ catch (err) {
154
+ 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() });
156
+ }
157
+ else {
158
+ deps.scrollback.push({
159
+ kind: 'status',
160
+ text: `mode '${mode.id}' threw — ${(_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : String(err)}`,
161
+ level: 'error',
162
+ ts: Date.now(),
163
+ });
164
+ }
165
+ }
166
+ finally {
167
+ clearBusy();
168
+ }
169
+ return;
54
170
  }
171
+ // 'none' transport, unknown verb: print error
172
+ 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() });
175
+ }
176
+ return {
177
+ dispatch,
178
+ cancel: () => activeController === null || activeController === void 0 ? void 0 : activeController.abort(),
55
179
  };
56
180
  }
@@ -1,5 +1,5 @@
1
1
  import { ShellModeRegistry } from './registry';
2
2
  import type { ShellMode } from './types';
3
- export declare const DEV_MODE: ShellMode;
4
- export declare const USER_MODE: ShellMode;
3
+ export declare const BASH_MODE: ShellMode;
4
+ export declare const SH3_MODE: ShellMode;
5
5
  export declare function registerBuiltinModes(reg: ShellModeRegistry): void;
@@ -1,18 +1,18 @@
1
1
  import { ShellModeRegistry } from './registry';
2
- export const DEV_MODE = {
3
- id: 'dev',
4
- label: 'Dev',
2
+ export const BASH_MODE = {
3
+ id: 'bash',
4
+ label: 'Bash',
5
5
  requiresRole: 'admin',
6
6
  transport: 'ws',
7
7
  autoRelocate: false,
8
8
  };
9
- export const USER_MODE = {
10
- id: 'user',
11
- label: 'User',
9
+ export const SH3_MODE = {
10
+ id: 'sh3',
11
+ label: 'SH3',
12
12
  transport: 'none',
13
13
  autoRelocate: true,
14
14
  };
15
15
  export function registerBuiltinModes(reg) {
16
- reg.register(DEV_MODE);
17
- reg.register(USER_MODE);
16
+ reg.register(BASH_MODE);
17
+ reg.register(SH3_MODE);
18
18
  }
@@ -26,6 +26,6 @@ export function resolveInitialMode(reg, userId, role) {
26
26
  if (m && (!m.requiresRole || m.requiresRole === role))
27
27
  return m;
28
28
  }
29
- const fallback = role === 'admin' ? 'dev' : 'user';
29
+ const fallback = role === 'admin' ? 'bash' : 'sh3';
30
30
  return reg.get(fallback);
31
31
  }
@@ -15,8 +15,8 @@ beforeEach(() => {
15
15
  });
16
16
  describe('readLastMode / writeLastMode', () => {
17
17
  it('round-trips a mode id for a user', () => {
18
- writeLastMode('alice', 'user');
19
- expect(readLastMode('alice')).toBe('user');
18
+ writeLastMode('alice', 'sh3');
19
+ expect(readLastMode('alice')).toBe('sh3');
20
20
  });
21
21
  it('returns null when nothing persisted', () => {
22
22
  expect(readLastMode('bob')).toBeNull();
@@ -25,22 +25,22 @@ describe('readLastMode / writeLastMode', () => {
25
25
  describe('resolveInitialMode', () => {
26
26
  const reg = new ShellModeRegistry();
27
27
  registerBuiltinModes(reg);
28
- it('admin with no pref → dev', () => {
29
- expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('dev');
28
+ it('admin with no pref → bash', () => {
29
+ expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('bash');
30
30
  });
31
- it('user with no pref → user', () => {
32
- expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('user');
31
+ it('user with no pref → sh3', () => {
32
+ expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('sh3');
33
33
  });
34
- it('admin with persisted useruser', () => {
35
- writeLastMode('alice', 'user');
36
- expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('user');
34
+ it('admin with persisted sh3sh3', () => {
35
+ writeLastMode('alice', 'sh3');
36
+ expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('sh3');
37
37
  });
38
- it('user with persisted dev (not allowed) → falls back to user', () => {
39
- writeLastMode('alice', 'dev');
40
- expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('user');
38
+ it('user with persisted bash (not allowed) → falls back to sh3', () => {
39
+ writeLastMode('alice', 'bash');
40
+ expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('sh3');
41
41
  });
42
42
  it('persisted unknown id → role default', () => {
43
43
  writeLastMode('alice', 'nonsense');
44
- expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('dev');
44
+ expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('bash');
45
45
  });
46
46
  });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { ShellModeRegistry } from './registry';
3
- const dev = { id: 'dev', label: 'Dev', requiresRole: 'admin', transport: 'ws', autoRelocate: false };
4
- const user = { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
3
+ const bash = { id: 'bash', label: 'Bash', requiresRole: 'admin', transport: 'ws', autoRelocate: false };
4
+ const sh3 = { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true };
5
5
  const ssh = { id: 'ssh', label: 'SSH', requiresRole: 'admin', transport: 'custom', autoRelocate: false };
6
6
  describe('ShellModeRegistry', () => {
7
7
  let reg;
@@ -9,27 +9,27 @@ describe('ShellModeRegistry', () => {
9
9
  reg = new ShellModeRegistry();
10
10
  });
11
11
  it('registers and retrieves modes', () => {
12
- reg.register(dev);
13
- expect(reg.get('dev')).toEqual(dev);
12
+ reg.register(bash);
13
+ expect(reg.get('bash')).toEqual(bash);
14
14
  });
15
15
  it('list(user) excludes admin-only modes', () => {
16
- reg.register(dev);
17
- reg.register(user);
16
+ reg.register(bash);
17
+ reg.register(sh3);
18
18
  reg.register(ssh);
19
19
  const ids = reg.list('user').map((m) => m.id);
20
- expect(ids).toEqual(['user']);
20
+ expect(ids).toEqual(['sh3']);
21
21
  });
22
22
  it('list(admin) includes all modes', () => {
23
- reg.register(dev);
24
- reg.register(user);
23
+ reg.register(bash);
24
+ reg.register(sh3);
25
25
  reg.register(ssh);
26
26
  const ids = reg.list('admin').map((m) => m.id).sort();
27
- expect(ids).toEqual(['dev', 'ssh', 'user']);
27
+ expect(ids).toEqual(['bash', 'sh3', 'ssh']);
28
28
  });
29
29
  it('re-registering same id replaces the mode', () => {
30
30
  var _a;
31
- reg.register(dev);
32
- reg.register(Object.assign(Object.assign({}, dev), { label: 'Dev+' }));
33
- expect((_a = reg.get('dev')) === null || _a === void 0 ? void 0 : _a.label).toBe('Dev+');
31
+ reg.register(bash);
32
+ reg.register(Object.assign(Object.assign({}, bash), { label: 'Bash+' }));
33
+ expect((_a = reg.get('bash')) === null || _a === void 0 ? void 0 : _a.label).toBe('Bash+');
34
34
  });
35
35
  });
@@ -0,0 +1,10 @@
1
+ import type { Scrollback } from './scrollback.svelte';
2
+ import type { ShellModeOutput } from './contract';
3
+ export interface ShellModeOutputDeps {
4
+ scrollback: Scrollback;
5
+ /** Acquire a busy indicator; returns a clear handle. */
6
+ busy: (label?: string) => () => void;
7
+ /** Programmatically dispatch a line through another mode's resolution path. */
8
+ invoke: (modeId: string, line: string) => Promise<void>;
9
+ }
10
+ export declare function makeShellModeOutput(deps: ShellModeOutputDeps): ShellModeOutput;
@@ -0,0 +1,91 @@
1
+ /*
2
+ * makeShellModeOutput — wraps a Scrollback in the typed ShellModeOutput
3
+ * surface external mode shards consume. The framework is the only writer to
4
+ * scrollback shapes; mode authors talk to this handle.
5
+ *
6
+ * Streaming entries carry a `__streamState` prop set to 'streaming' on push,
7
+ * 'complete' on complete(), 'error' on error(). The entry's component is
8
+ * expected to read this prop (or ignore it — it's optional).
9
+ */
10
+ function findRich(sb, entryId) {
11
+ return sb.entries.find((e) => e.kind === 'rich' && e.id === entryId);
12
+ }
13
+ function makeRichHandle(sb, entryId) {
14
+ return {
15
+ update(patch) {
16
+ const entry = findRich(sb, entryId);
17
+ if (!entry)
18
+ return;
19
+ Object.assign(entry.props, patch);
20
+ },
21
+ };
22
+ }
23
+ function lastRichId(sb) {
24
+ const last = sb.entries[sb.entries.length - 1];
25
+ return last.id;
26
+ }
27
+ export function makeShellModeOutput(deps) {
28
+ const sb = deps.scrollback;
29
+ return {
30
+ text(stream, chunk) {
31
+ sb.push({ kind: 'text', stream, chunks: [chunk], ts: Date.now() });
32
+ },
33
+ status(level, msg) {
34
+ sb.push({ kind: 'status', text: msg, level, ts: Date.now() });
35
+ },
36
+ rich(component, props) {
37
+ sb.push({ kind: 'rich', component, props: Object.assign({}, props), ts: Date.now() });
38
+ return makeRichHandle(sb, lastRichId(sb));
39
+ },
40
+ stream(component, initialProps) {
41
+ sb.push({
42
+ kind: 'rich',
43
+ component,
44
+ props: Object.assign(Object.assign({}, initialProps), { __streamState: 'streaming' }),
45
+ ts: Date.now(),
46
+ });
47
+ const id = lastRichId(sb);
48
+ return {
49
+ append(patch) {
50
+ const entry = findRich(sb, id);
51
+ if (!entry)
52
+ return;
53
+ Object.assign(entry.props, patch);
54
+ },
55
+ complete() {
56
+ const entry = findRich(sb, id);
57
+ if (!entry)
58
+ return;
59
+ entry.props.__streamState = 'complete';
60
+ },
61
+ error(err) {
62
+ var _a;
63
+ const entry = findRich(sb, id);
64
+ if (entry)
65
+ entry.props.__streamState = 'error';
66
+ sb.push({
67
+ kind: 'status',
68
+ text: `mode: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err)}`,
69
+ level: 'error',
70
+ ts: Date.now(),
71
+ });
72
+ },
73
+ };
74
+ },
75
+ busy(label) {
76
+ const clearController = deps.busy(label);
77
+ let cleared = false;
78
+ return {
79
+ clear() {
80
+ if (cleared)
81
+ return;
82
+ cleared = true;
83
+ clearController();
84
+ },
85
+ };
86
+ },
87
+ invoke(modeId, line) {
88
+ return deps.invoke(modeId, line);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1 @@
1
+ export {};