@corbet-labs/ccht 0.2.0 → 0.2.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 (40) hide show
  1. package/LICENSE.md +11 -6
  2. package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  3. package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  4. package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
  5. package/README.md +194 -212
  6. package/THIRD-PARTY.md +17 -0
  7. package/index.d.ts +6 -1
  8. package/index.js +8 -1
  9. package/package.json +17 -3
  10. package/source/.ci/wasm-bundle/Cargo.toml +1 -1
  11. package/source/CHANGELOG.md +59 -2
  12. package/source/Cargo.lock +9 -1
  13. package/source/Cargo.toml +4 -4
  14. package/source/LICENSE.md +11 -6
  15. package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  16. package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  17. package/source/README.md +67 -12
  18. package/source/THIRD-PARTY.md +17 -0
  19. package/source/dependencies.tar.gz +0 -0
  20. package/source/src/auth.rs +435 -0
  21. package/source/src/configuration.rs +51 -0
  22. package/source/src/conversation.rs +25 -0
  23. package/source/src/dock.rs +564 -0
  24. package/source/src/lib.rs +9 -0
  25. package/source/src/native/client.rs +6 -0
  26. package/source/src/native/drivers/codex.rs +506 -0
  27. package/source/src/native/drivers/mod.rs +305 -0
  28. package/source/src/native/drivers/opencode.rs +531 -0
  29. package/source/src/native/env.rs +264 -0
  30. package/source/src/native/fixture.py +78 -1
  31. package/source/src/native/mod.rs +5 -0
  32. package/source/src/native/pool.rs +440 -0
  33. package/source/src/native/session.rs +6 -0
  34. package/source/src/native/tests.rs +204 -0
  35. package/source/src/transport.rs +330 -0
  36. package/src/auth.ts +149 -0
  37. package/src/components/AccountConnection.svelte +201 -0
  38. package/src/components/Dock.svelte +172 -0
  39. package/src/dock.ts +244 -0
  40. package/wasm/ccht_bg.wasm +0 -0
@@ -0,0 +1,172 @@
1
+ <!-- Product-neutral edge rail. The application owns all effects (dock state,
2
+ persistence, focus targets beyond this panel); this component only renders
3
+ the tab and panel chrome and forwards user intent through app-supplied
4
+ open/close callbacks. It never fetches, spawns, or stores anything. -->
5
+ <script lang="ts">
6
+ import type { Snippet } from 'svelte';
7
+
8
+ let {
9
+ side = 'left',
10
+ title,
11
+ open,
12
+ onClose,
13
+ onOpen,
14
+ tabLabel,
15
+ tabSummary = '',
16
+ closeLabel,
17
+ panelId,
18
+ children
19
+ }: {
20
+ side: 'left' | 'right';
21
+ title: string;
22
+ open: boolean;
23
+ onClose: () => void;
24
+ onOpen: () => void;
25
+ tabLabel: string;
26
+ tabSummary?: string;
27
+ closeLabel?: string;
28
+ panelId: string;
29
+ children: Snippet;
30
+ } = $props();
31
+
32
+ let panelEl: HTMLElement | undefined = $state(undefined);
33
+ let tabEl: HTMLButtonElement | undefined = $state(undefined);
34
+ let wasOpen = $state(false);
35
+ let prevFocus: HTMLElement | null = $state(null);
36
+ const dismissLabel = $derived(closeLabel ?? `Close ${title}`);
37
+
38
+ function handleTabClick(event: MouseEvent) {
39
+ prevFocus = event.currentTarget as HTMLElement;
40
+ onOpen();
41
+ }
42
+
43
+ $effect(() => {
44
+ if (open && !wasOpen) {
45
+ wasOpen = true;
46
+ prevFocus ??= document.activeElement as HTMLElement | null;
47
+ requestAnimationFrame(() => {
48
+ // First visible, enabled control wins; hidden or disabled nodes
49
+ // never take focus. Fixed panels have no offsetParent, so filter
50
+ // on client rects instead.
51
+ const target = [...(panelEl?.querySelectorAll<HTMLElement>(
52
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
53
+ ) ?? [])].find((node) => node.getClientRects().length > 0) ?? panelEl;
54
+ target?.focus();
55
+ });
56
+ } else if (!open && wasOpen) {
57
+ wasOpen = false;
58
+ const restore = prevFocus?.isConnected ? prevFocus : null;
59
+ prevFocus = null;
60
+ requestAnimationFrame(() => {
61
+ (restore ?? tabEl)?.focus?.();
62
+ });
63
+ }
64
+ });
65
+
66
+ $effect(() => {
67
+ if (panelEl) (panelEl as HTMLElement & { inert: boolean }).inert = !open;
68
+ });
69
+
70
+ function onKeydown(event: KeyboardEvent) {
71
+ if (event.key === 'Escape' && open) onClose();
72
+ }
73
+ </script>
74
+
75
+ <svelte:window onkeydown={onKeydown} />
76
+
77
+ {#if !open}
78
+ <button
79
+ bind:this={tabEl}
80
+ type="button"
81
+ class="ccht-dock-tab"
82
+ data-side={side}
83
+ aria-expanded="false"
84
+ aria-controls={panelId}
85
+ aria-label={tabSummary ? `Open ${title}: ${tabSummary}` : `Open ${title}`}
86
+ onclick={handleTabClick}
87
+ ><span class="ccht-dock-tab-text">{tabLabel} · {tabSummary || title}</span></button>
88
+ {/if}
89
+
90
+ <div
91
+ bind:this={panelEl}
92
+ id={panelId}
93
+ class="ccht-dock-panel"
94
+ data-side={side}
95
+ data-open={open ? 'true' : 'false'}
96
+ role="dialog"
97
+ aria-label={title}
98
+ aria-hidden={!open}
99
+ tabindex="-1"
100
+ >
101
+ <div class="ccht-dock-head">
102
+ <div class="ccht-dock-title"><h2>{title}</h2>{#if tabSummary}<p>{tabSummary}</p>{/if}</div>
103
+ <button type="button" class="ccht-dock-close" aria-label={dismissLabel} onclick={onClose}>Close</button>
104
+ </div>
105
+ <div class="ccht-dock-body">{@render children()}</div>
106
+ </div>
107
+
108
+ <style>
109
+ .ccht-dock-tab {
110
+ position: fixed; top: 50%; z-index: 80; transform: translateY(-50%);
111
+ max-height: min(60vh, 30rem); max-width: 2.4rem; padding: 0.7rem 0.4rem;
112
+ display: flex; align-items: center; justify-content: center;
113
+ border: 1px solid var(--ccht-border, #30405c);
114
+ color: var(--ccht-fg, #c2cede);
115
+ background: var(--ccht-tab-bg, #111c2df2);
116
+ font: 800 0.68rem ui-monospace, monospace; cursor: pointer;
117
+ writing-mode: vertical-rl; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
118
+ }
119
+ .ccht-dock-tab[data-side='left'] { left: 0; border-left: 0; border-radius: 0 0.5rem 0.5rem 0; }
120
+ .ccht-dock-tab[data-side='right'] { right: 0; border-right: 0; border-radius: 0.5rem 0 0 0.5rem; }
121
+ .ccht-dock-tab:hover {
122
+ border-color: var(--ccht-accent, #53708f);
123
+ color: var(--ccht-fg-bright, #e2eaf5);
124
+ background: var(--ccht-tab-bg-hover, #17243af2);
125
+ }
126
+ .ccht-dock-tab-text { overflow: hidden; text-overflow: ellipsis; }
127
+ .ccht-dock-panel {
128
+ position: fixed; top: 0; bottom: 0; z-index: 70;
129
+ width: min(30rem, 100vw); display: flex; flex-direction: column;
130
+ background: var(--ccht-panel-bg, #0d1625);
131
+ color: var(--ccht-fg, #d8e2f0); visibility: hidden;
132
+ transition: transform 0.2s ease, visibility 0s linear 0.2s;
133
+ }
134
+ .ccht-dock-panel[data-side='left'] {
135
+ left: 0; border-right: 1px solid var(--ccht-border, #223049); transform: translateX(-102%);
136
+ }
137
+ .ccht-dock-panel[data-side='right'] {
138
+ right: 0; border-left: 1px solid var(--ccht-border, #223049); transform: translateX(102%);
139
+ }
140
+ .ccht-dock-panel[data-open='true'] { transform: none; visibility: visible; transition: transform 0.2s ease; }
141
+ .ccht-dock-panel[data-open='false'] { visibility: hidden; }
142
+ .ccht-dock-head {
143
+ display: flex; align-items: center; justify-content: space-between; gap: 0.8rem;
144
+ padding: 1rem 1.1rem; border-bottom: 1px solid var(--ccht-border, #223049);
145
+ }
146
+ .ccht-dock-title { min-width: 0; }
147
+ .ccht-dock-title h2 { margin: 0; font-size: 1.02rem; overflow-wrap: anywhere; }
148
+ .ccht-dock-title p {
149
+ margin: 0.3rem 0 0; overflow-wrap: anywhere;
150
+ color: var(--ccht-muted, #8fa0b7); font: 750 0.68rem ui-monospace, monospace;
151
+ }
152
+ .ccht-dock-close {
153
+ flex: 0 0 auto; min-height: 2.5rem; padding: 0.55rem 0.85rem;
154
+ border: 1px solid var(--ccht-border, #30405c); border-radius: 0.5rem;
155
+ color: var(--ccht-fg, #c2cede);
156
+ background: var(--ccht-tab-bg, #111c2d);
157
+ font-size: 0.72rem; font-weight: 850; cursor: pointer;
158
+ }
159
+ .ccht-dock-close:hover {
160
+ border-color: var(--ccht-accent, #53708f);
161
+ background: var(--ccht-tab-bg-hover, #17243a);
162
+ }
163
+ .ccht-dock-body {
164
+ flex: 1; min-height: 0; overflow: auto; padding: 1rem 1.1rem;
165
+ display: grid; gap: 0.85rem; align-content: start;
166
+ }
167
+ @media (max-width: 560px) {
168
+ .ccht-dock-panel { width: 100vw; }
169
+ .ccht-dock-head, .ccht-dock-body { padding-left: 0.85rem; padding-right: 0.85rem; }
170
+ }
171
+ @media (prefers-reduced-motion: reduce) { .ccht-dock-panel { transition: none; } }
172
+ </style>
package/src/dock.ts ADDED
@@ -0,0 +1,244 @@
1
+ /** Framework-free dock state for web consumers, mirroring the dock vocabulary.
2
+ *
3
+ * Docks are named regions of application chrome (for example a chat rail or
4
+ * a configuration panel). `DockKind` classifies a dock, `Placement` places
5
+ * it, and the manager tracks which docks exist, which are open, and where
6
+ * each one sits. The conversation model stays in Rust/Wasm; these types only
7
+ * describe *where* a surface lives and whether it is visible.
8
+ *
9
+ * This module is plain TypeScript: no DOM access, no network I/O, no spawned
10
+ * processes, and no storage. Persistence travels through `serialize` /
11
+ * `restore` as explicit JSON handled by the application; focus is handed off
12
+ * through an opaque token the application moves to its own focus target.
13
+ * Product copy belongs to the embedding application.
14
+ */
15
+
16
+ /** What a dock is for. `chat` hosts a conversation surface, `config` hosts
17
+ * settings, and `custom` covers every application-defined use. */
18
+ export type DockKind = 'chat' | 'config' | 'custom';
19
+
20
+ /** Where a dock sits. Edge values pin the dock to a window edge; `inline`
21
+ * renders it in the normal application flow instead. */
22
+ export type Placement = 'left' | 'right' | 'bottom' | 'inline';
23
+
24
+ const DOCK_ID_PATTERN = /^[a-z0-9-_]+$/;
25
+ const DOCK_ID_MAX_LENGTH = 64;
26
+
27
+ /** Validate a dock id. Returns an error message, or null when the id is valid.
28
+ *
29
+ * Rules: a non-empty string of at most 64 ASCII `[a-z0-9-_]` characters.
30
+ * Never throws; validate before registering or restoring a dock.
31
+ */
32
+ export function validateDockId(id: unknown): string | null {
33
+ if (typeof id !== 'string' || id.length === 0) {
34
+ return 'invalid dock id: must be a non-empty string';
35
+ }
36
+ if (id.length > DOCK_ID_MAX_LENGTH) {
37
+ return 'invalid dock id: must be at most 64 characters';
38
+ }
39
+ if (!DOCK_ID_PATTERN.test(id)) {
40
+ return 'invalid dock id: must match [a-z0-9-_]';
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** One persisted dock entry. `open` is the only mutable runtime state; the
46
+ * focus token is deliberately absent and never survives serialization. */
47
+ export interface DockEntry {
48
+ id: string;
49
+ kind: DockKind;
50
+ placement: Placement;
51
+ open: boolean;
52
+ }
53
+
54
+ /** Named dock registry with open state, placement, and focus handoff.
55
+ *
56
+ * Methods that name a dock throw `Error('UnknownDock: <id>')` for ids that
57
+ * were never registered. `register` throws `Error('DuplicateDock: <id>')`
58
+ * for an id that already exists, and rethrows the `validateDockId` message
59
+ * for a malformed id. Invalid kinds, placements, focus tokens, and snapshots
60
+ * throw `Error` with a fixed `invalid ...` message.
61
+ */
62
+ export interface DockManager {
63
+ /** Register a closed dock. Throws on a malformed id, an invalid kind or
64
+ * placement, or a duplicate id. */
65
+ register(id: string, kind: DockKind, placement: Placement): void;
66
+ /** Mark a dock open. Throws `UnknownDock` for an unregistered id. */
67
+ open(id: string): void;
68
+ /** Mark a dock open and stage a focus token for the application to consume
69
+ * with `takeFocusToken`. The token must be a non-empty string. */
70
+ openWithFocus(id: string, token: string): void;
71
+ /** Mark a dock closed. Throws `UnknownDock` for an unregistered id. */
72
+ close(id: string): void;
73
+ /** Flip a dock's open state and return the new state. */
74
+ toggle(id: string): boolean;
75
+ /** Whether a dock is currently open. */
76
+ isOpen(id: string): boolean;
77
+ /** A dock's current placement. */
78
+ placement(id: string): Placement;
79
+ /** Move a dock to another placement. */
80
+ setPlacement(id: string, placement: Placement): void;
81
+ /** Ids of open docks, in registration order. */
82
+ openDocks(): string[];
83
+ /** Take the staged focus token once, clearing it; null when none is staged. */
84
+ takeFocusToken(): string | null;
85
+ /** Mark every dock closed. Staged focus tokens are left untouched. */
86
+ closeAll(): void;
87
+ /** Persist `[{id, kind, placement, open}]` as JSON, in registration order.
88
+ * The focus token is never persisted. */
89
+ serialize(): string;
90
+ /** Replace all state from `serialize` output. Validates every entry first,
91
+ * so a malformed snapshot leaves the current state untouched, and clears
92
+ * any staged focus token. */
93
+ restore(json: string): void;
94
+ }
95
+
96
+ /** Create an empty dock manager. Managers are independent; applications that
97
+ * need shared state pass one manager around instead of creating several. */
98
+ export function createDockManager(): DockManager {
99
+ const docks = new Map<string, { kind: DockKind; placement: Placement; open: boolean }>();
100
+ let focusToken: string | null = null;
101
+
102
+ function entry(id: string): { kind: DockKind; placement: Placement; open: boolean } {
103
+ const found = docks.get(id);
104
+ if (found === undefined) {
105
+ throw new Error(`UnknownDock: ${id}`);
106
+ }
107
+ return found;
108
+ }
109
+
110
+ function checkKind(kind: unknown): asserts kind is DockKind {
111
+ if (kind !== 'chat' && kind !== 'config' && kind !== 'custom') {
112
+ throw new Error('invalid dock kind');
113
+ }
114
+ }
115
+
116
+ function checkPlacement(value: unknown): asserts value is Placement {
117
+ if (value !== 'left' && value !== 'right' && value !== 'bottom' && value !== 'inline') {
118
+ throw new Error('invalid dock placement');
119
+ }
120
+ }
121
+
122
+ function checkToken(token: unknown): asserts token is string {
123
+ if (typeof token !== 'string' || token.length === 0) {
124
+ throw new Error('invalid focus token');
125
+ }
126
+ }
127
+
128
+ return {
129
+ register(id, kind, placement) {
130
+ const invalid = validateDockId(id);
131
+ if (invalid !== null) {
132
+ throw new Error(invalid);
133
+ }
134
+ checkKind(kind);
135
+ checkPlacement(placement);
136
+ if (docks.has(id)) {
137
+ throw new Error(`DuplicateDock: ${id}`);
138
+ }
139
+ docks.set(id, { kind, placement, open: false });
140
+ },
141
+ open(id) {
142
+ entry(id).open = true;
143
+ },
144
+ openWithFocus(id, token) {
145
+ checkToken(token);
146
+ entry(id).open = true;
147
+ focusToken = token;
148
+ },
149
+ close(id) {
150
+ entry(id).open = false;
151
+ },
152
+ toggle(id) {
153
+ const target = entry(id);
154
+ target.open = !target.open;
155
+ return target.open;
156
+ },
157
+ isOpen(id) {
158
+ return entry(id).open;
159
+ },
160
+ placement(id) {
161
+ return entry(id).placement;
162
+ },
163
+ setPlacement(id, placement) {
164
+ checkPlacement(placement);
165
+ entry(id).placement = placement;
166
+ },
167
+ openDocks() {
168
+ const ids: string[] = [];
169
+ for (const [id, state] of docks) {
170
+ if (state.open) {
171
+ ids.push(id);
172
+ }
173
+ }
174
+ return ids;
175
+ },
176
+ takeFocusToken() {
177
+ const token = focusToken;
178
+ focusToken = null;
179
+ return token;
180
+ },
181
+ closeAll() {
182
+ for (const state of docks.values()) {
183
+ state.open = false;
184
+ }
185
+ },
186
+ serialize() {
187
+ const snapshot: DockEntry[] = [];
188
+ for (const [id, state] of docks) {
189
+ snapshot.push({ id, kind: state.kind, placement: state.placement, open: state.open });
190
+ }
191
+ return JSON.stringify(snapshot);
192
+ },
193
+ restore(json) {
194
+ let decoded: unknown;
195
+ try {
196
+ decoded = JSON.parse(json);
197
+ } catch {
198
+ throw new Error('invalid dock snapshot: not JSON');
199
+ }
200
+ if (!Array.isArray(decoded)) {
201
+ throw new Error('invalid dock snapshot: must be an array');
202
+ }
203
+ const next = new Map<string, { kind: DockKind; placement: Placement; open: boolean }>();
204
+ for (const value of decoded) {
205
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
206
+ throw new Error('invalid dock snapshot: entry must be an object');
207
+ }
208
+ const record = value as Record<string, unknown>;
209
+ const invalid = validateDockId(record.id);
210
+ if (invalid !== null) {
211
+ throw new Error(`invalid dock snapshot: ${invalid}`);
212
+ }
213
+ const id = record.id as string;
214
+ if (record.kind !== 'chat' && record.kind !== 'config' && record.kind !== 'custom') {
215
+ throw new Error('invalid dock snapshot: invalid dock kind');
216
+ }
217
+ if (
218
+ record.placement !== 'left' &&
219
+ record.placement !== 'right' &&
220
+ record.placement !== 'bottom' &&
221
+ record.placement !== 'inline'
222
+ ) {
223
+ throw new Error('invalid dock snapshot: invalid dock placement');
224
+ }
225
+ if (typeof record.open !== 'boolean') {
226
+ throw new Error('invalid dock snapshot: open must be a boolean');
227
+ }
228
+ if (next.has(id)) {
229
+ throw new Error('invalid dock snapshot: duplicate dock id');
230
+ }
231
+ next.set(id, {
232
+ kind: record.kind,
233
+ placement: record.placement,
234
+ open: record.open,
235
+ });
236
+ }
237
+ docks.clear();
238
+ for (const [id, state] of next) {
239
+ docks.set(id, state);
240
+ }
241
+ focusToken = null;
242
+ },
243
+ };
244
+ }
package/wasm/ccht_bg.wasm CHANGED
Binary file