@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.
- package/LICENSE.md +11 -6
- package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
- package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
- package/README.md +194 -212
- package/THIRD-PARTY.md +17 -0
- package/index.d.ts +6 -1
- package/index.js +8 -1
- package/package.json +17 -3
- package/source/.ci/wasm-bundle/Cargo.toml +1 -1
- package/source/CHANGELOG.md +59 -2
- package/source/Cargo.lock +9 -1
- package/source/Cargo.toml +4 -4
- package/source/LICENSE.md +11 -6
- package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
- package/source/README.md +67 -12
- package/source/THIRD-PARTY.md +17 -0
- package/source/dependencies.tar.gz +0 -0
- package/source/src/auth.rs +435 -0
- package/source/src/configuration.rs +51 -0
- package/source/src/conversation.rs +25 -0
- package/source/src/dock.rs +564 -0
- package/source/src/lib.rs +9 -0
- package/source/src/native/client.rs +6 -0
- package/source/src/native/drivers/codex.rs +506 -0
- package/source/src/native/drivers/mod.rs +305 -0
- package/source/src/native/drivers/opencode.rs +531 -0
- package/source/src/native/env.rs +264 -0
- package/source/src/native/fixture.py +78 -1
- package/source/src/native/mod.rs +5 -0
- package/source/src/native/pool.rs +440 -0
- package/source/src/native/session.rs +6 -0
- package/source/src/native/tests.rs +204 -0
- package/source/src/transport.rs +330 -0
- package/src/auth.ts +149 -0
- package/src/components/AccountConnection.svelte +201 -0
- package/src/components/Dock.svelte +172 -0
- package/src/dock.ts +244 -0
- package/wasm/ccht_bg.wasm +0 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
//! Portable headless dock state shared by native, Wasm and web consumers.
|
|
2
|
+
//!
|
|
3
|
+
//! This module only tracks which docks exist, where they are placed, and
|
|
4
|
+
//! whether they are open. It performs no I/O, spawns nothing, and renders no
|
|
5
|
+
//! UI; applications own product flows, persistence, and presentation.
|
|
6
|
+
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
|
|
9
|
+
/// What a dock is for. Product-neutral; applications map kinds to views.
|
|
10
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
11
|
+
#[serde(rename_all = "lowercase")]
|
|
12
|
+
pub enum DockKind {
|
|
13
|
+
/// Conversation or chat surface.
|
|
14
|
+
Chat,
|
|
15
|
+
/// Configuration or settings surface.
|
|
16
|
+
Config,
|
|
17
|
+
/// Application-defined surface.
|
|
18
|
+
Custom,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Where a dock is placed relative to the main surface.
|
|
22
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
23
|
+
#[serde(rename_all = "lowercase")]
|
|
24
|
+
pub enum Placement {
|
|
25
|
+
/// Docked to the left side.
|
|
26
|
+
Left,
|
|
27
|
+
/// Docked to the right side.
|
|
28
|
+
Right,
|
|
29
|
+
/// Docked to the bottom.
|
|
30
|
+
Bottom,
|
|
31
|
+
/// Rendered inline with the main content.
|
|
32
|
+
Inline,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Validated dock identifier: non-empty, at most 64 characters, charset
|
|
36
|
+
/// `[a-z0-9-_]`.
|
|
37
|
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
38
|
+
#[serde(transparent)]
|
|
39
|
+
pub struct DockId(String);
|
|
40
|
+
|
|
41
|
+
impl DockId {
|
|
42
|
+
/// Maximum identifier length in characters.
|
|
43
|
+
const MAX_LEN: usize = 64;
|
|
44
|
+
|
|
45
|
+
/// Validate an identifier without performing any I/O.
|
|
46
|
+
///
|
|
47
|
+
/// # Errors
|
|
48
|
+
///
|
|
49
|
+
/// Returns [`DockError::EmptyId`] for empty input,
|
|
50
|
+
/// [`DockError::IdTooLong`] when longer than 64 characters, or
|
|
51
|
+
/// [`DockError::InvalidChar`] for the first out-of-charset character.
|
|
52
|
+
pub fn new(id: &str) -> Result<Self, DockError> {
|
|
53
|
+
if id.is_empty() {
|
|
54
|
+
return Err(DockError::EmptyId);
|
|
55
|
+
}
|
|
56
|
+
if id.chars().count() > Self::MAX_LEN {
|
|
57
|
+
return Err(DockError::IdTooLong);
|
|
58
|
+
}
|
|
59
|
+
if let Some(invalid) = id
|
|
60
|
+
.chars()
|
|
61
|
+
.find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '_'))
|
|
62
|
+
{
|
|
63
|
+
return Err(DockError::InvalidChar(invalid));
|
|
64
|
+
}
|
|
65
|
+
Ok(Self(id.to_owned()))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Borrow the identifier as a string slice.
|
|
69
|
+
#[must_use]
|
|
70
|
+
pub fn as_str(&self) -> &str {
|
|
71
|
+
&self.0
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Dock validation and lookup failure. Messages are fixed strings and never
|
|
76
|
+
/// carry focus tokens or other sensitive data.
|
|
77
|
+
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
|
|
78
|
+
pub enum DockError {
|
|
79
|
+
/// The identifier is empty.
|
|
80
|
+
#[error("dock id must not be empty")]
|
|
81
|
+
EmptyId,
|
|
82
|
+
/// The identifier exceeds 64 characters.
|
|
83
|
+
#[error("dock id must be at most 64 characters")]
|
|
84
|
+
IdTooLong,
|
|
85
|
+
/// The identifier contains a character outside `[a-z0-9-_]`.
|
|
86
|
+
#[error("dock id contains invalid character: {0}")]
|
|
87
|
+
InvalidChar(char),
|
|
88
|
+
/// No dock is registered under this identifier.
|
|
89
|
+
#[error("unknown dock: {0}")]
|
|
90
|
+
UnknownDock(String),
|
|
91
|
+
/// A dock is already registered under this identifier.
|
|
92
|
+
#[error("duplicate dock: {0}")]
|
|
93
|
+
DuplicateDock(String),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Runtime state of a single registered dock.
|
|
97
|
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
98
|
+
pub struct DockState {
|
|
99
|
+
/// Validated dock identifier.
|
|
100
|
+
pub id: DockId,
|
|
101
|
+
/// What the dock is for.
|
|
102
|
+
pub kind: DockKind,
|
|
103
|
+
/// Where the dock is placed.
|
|
104
|
+
pub placement: Placement,
|
|
105
|
+
/// Whether the dock is currently open.
|
|
106
|
+
pub open: bool,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Headless registry of docks plus a transient focus token.
|
|
110
|
+
///
|
|
111
|
+
/// The focus token is never serialized; it only travels in memory so the
|
|
112
|
+
/// application can restore focus after opening a dock.
|
|
113
|
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
114
|
+
pub struct DockManager {
|
|
115
|
+
/// Registered docks in registration order.
|
|
116
|
+
pub docks: Vec<DockState>,
|
|
117
|
+
/// Transient focus token, replaced by `open_with_focus` and consumed by
|
|
118
|
+
/// `take_focus_token`. Skipped by serde.
|
|
119
|
+
#[serde(skip, default)]
|
|
120
|
+
pub focus_token: Option<String>,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
impl DockManager {
|
|
124
|
+
/// Empty registry with no focus token.
|
|
125
|
+
#[must_use]
|
|
126
|
+
pub fn new() -> Self {
|
|
127
|
+
Self::default()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Find a dock index by identifier text.
|
|
131
|
+
fn index_of(&self, id: &str) -> Option<usize> {
|
|
132
|
+
self.docks.iter().position(|dock| dock.id.as_str() == id)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// Register a new closed dock.
|
|
136
|
+
///
|
|
137
|
+
/// # Errors
|
|
138
|
+
///
|
|
139
|
+
/// Returns [`DockError::EmptyId`], [`DockError::IdTooLong`] or
|
|
140
|
+
/// [`DockError::InvalidChar`] for invalid identifiers, or
|
|
141
|
+
/// [`DockError::DuplicateDock`] when the identifier is already registered.
|
|
142
|
+
pub fn register(
|
|
143
|
+
&mut self,
|
|
144
|
+
id: &str,
|
|
145
|
+
kind: DockKind,
|
|
146
|
+
placement: Placement,
|
|
147
|
+
) -> Result<(), DockError> {
|
|
148
|
+
let validated = DockId::new(id)?;
|
|
149
|
+
if self.docks.iter().any(|dock| dock.id == validated) {
|
|
150
|
+
return Err(DockError::DuplicateDock(validated.as_str().to_owned()));
|
|
151
|
+
}
|
|
152
|
+
self.docks.push(DockState {
|
|
153
|
+
id: validated,
|
|
154
|
+
kind,
|
|
155
|
+
placement,
|
|
156
|
+
open: false,
|
|
157
|
+
});
|
|
158
|
+
Ok(())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Open a dock. Idempotent; a plain open never clears a stored focus
|
|
162
|
+
/// token.
|
|
163
|
+
///
|
|
164
|
+
/// # Errors
|
|
165
|
+
///
|
|
166
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
167
|
+
/// registered.
|
|
168
|
+
pub fn open(&mut self, id: &str) -> Result<(), DockError> {
|
|
169
|
+
let Some(index) = self.index_of(id) else {
|
|
170
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
171
|
+
};
|
|
172
|
+
self.docks[index].open = true;
|
|
173
|
+
Ok(())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Open a dock and replace the stored focus token.
|
|
177
|
+
///
|
|
178
|
+
/// # Errors
|
|
179
|
+
///
|
|
180
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
181
|
+
/// registered.
|
|
182
|
+
pub fn open_with_focus(&mut self, id: &str, token: String) -> Result<(), DockError> {
|
|
183
|
+
let Some(index) = self.index_of(id) else {
|
|
184
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
185
|
+
};
|
|
186
|
+
self.docks[index].open = true;
|
|
187
|
+
self.focus_token = Some(token);
|
|
188
|
+
Ok(())
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Close a dock. Idempotent; keeps any stored focus token.
|
|
192
|
+
///
|
|
193
|
+
/// # Errors
|
|
194
|
+
///
|
|
195
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
196
|
+
/// registered.
|
|
197
|
+
pub fn close(&mut self, id: &str) -> Result<(), DockError> {
|
|
198
|
+
let Some(index) = self.index_of(id) else {
|
|
199
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
200
|
+
};
|
|
201
|
+
self.docks[index].open = false;
|
|
202
|
+
Ok(())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Toggle a dock between open and closed.
|
|
206
|
+
///
|
|
207
|
+
/// # Errors
|
|
208
|
+
///
|
|
209
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
210
|
+
/// registered.
|
|
211
|
+
pub fn toggle(&mut self, id: &str) -> Result<(), DockError> {
|
|
212
|
+
let Some(index) = self.index_of(id) else {
|
|
213
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
214
|
+
};
|
|
215
|
+
self.docks[index].open = !self.docks[index].open;
|
|
216
|
+
Ok(())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Whether a dock is currently open.
|
|
220
|
+
///
|
|
221
|
+
/// # Errors
|
|
222
|
+
///
|
|
223
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
224
|
+
/// registered.
|
|
225
|
+
pub fn is_open(&self, id: &str) -> Result<bool, DockError> {
|
|
226
|
+
let Some(index) = self.index_of(id) else {
|
|
227
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
228
|
+
};
|
|
229
|
+
Ok(self.docks[index].open)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Current placement of a dock.
|
|
233
|
+
///
|
|
234
|
+
/// # Errors
|
|
235
|
+
///
|
|
236
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
237
|
+
/// registered.
|
|
238
|
+
pub fn placement(&self, id: &str) -> Result<Placement, DockError> {
|
|
239
|
+
let Some(index) = self.index_of(id) else {
|
|
240
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
241
|
+
};
|
|
242
|
+
Ok(self.docks[index].placement)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Move a dock without changing whether it is open.
|
|
246
|
+
///
|
|
247
|
+
/// # Errors
|
|
248
|
+
///
|
|
249
|
+
/// Returns [`DockError::UnknownDock`] when the identifier is not
|
|
250
|
+
/// registered.
|
|
251
|
+
pub fn set_placement(&mut self, id: &str, placement: Placement) -> Result<(), DockError> {
|
|
252
|
+
let Some(index) = self.index_of(id) else {
|
|
253
|
+
return Err(DockError::UnknownDock(id.to_owned()));
|
|
254
|
+
};
|
|
255
|
+
self.docks[index].placement = placement;
|
|
256
|
+
Ok(())
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Currently open docks in registration order.
|
|
260
|
+
#[must_use]
|
|
261
|
+
pub fn open_docks(&self) -> Vec<&DockState> {
|
|
262
|
+
self.docks.iter().filter(|dock| dock.open).collect()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Take and clear the stored focus token, if any.
|
|
266
|
+
pub fn take_focus_token(&mut self) -> Option<String> {
|
|
267
|
+
self.focus_token.take()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Close every dock. Keeps any stored focus token.
|
|
271
|
+
pub fn close_all(&mut self) {
|
|
272
|
+
for dock in &mut self.docks {
|
|
273
|
+
dock.open = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[cfg(test)]
|
|
279
|
+
mod tests {
|
|
280
|
+
use super::*;
|
|
281
|
+
|
|
282
|
+
fn manager_with(id: &str, kind: DockKind, placement: Placement) -> DockManager {
|
|
283
|
+
let mut manager = DockManager::new();
|
|
284
|
+
manager.register(id, kind, placement).unwrap();
|
|
285
|
+
manager
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn id_rejects_empty() {
|
|
290
|
+
assert_eq!(DockId::new("").unwrap_err(), DockError::EmptyId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[test]
|
|
294
|
+
fn id_rejects_too_long() {
|
|
295
|
+
let long: String = std::iter::repeat_n('a', 65).collect();
|
|
296
|
+
assert_eq!(DockId::new(&long).unwrap_err(), DockError::IdTooLong);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn id_accepts_exact_64_chars() {
|
|
301
|
+
let exact: String = std::iter::repeat_n('a', 64).collect();
|
|
302
|
+
let id = DockId::new(&exact).unwrap();
|
|
303
|
+
assert_eq!(id.as_str(), exact);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#[test]
|
|
307
|
+
fn id_accepts_valid_charset() {
|
|
308
|
+
for valid in ["chat", "a-z_0-9", "abc-123_xyz", "0", "-", "_"] {
|
|
309
|
+
let id = DockId::new(valid).unwrap();
|
|
310
|
+
assert_eq!(id.as_str(), valid);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#[test]
|
|
315
|
+
fn id_rejects_invalid_characters() {
|
|
316
|
+
for invalid in [
|
|
317
|
+
"Chat",
|
|
318
|
+
"CHAT",
|
|
319
|
+
"has space",
|
|
320
|
+
"with.dot",
|
|
321
|
+
"with/slash",
|
|
322
|
+
"UPPER",
|
|
323
|
+
] {
|
|
324
|
+
assert!(
|
|
325
|
+
matches!(DockId::new(invalid).unwrap_err(), DockError::InvalidChar(_)),
|
|
326
|
+
"expected InvalidChar for {invalid:?}"
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
assert_eq!(
|
|
330
|
+
DockId::new("ok Bad").unwrap_err(),
|
|
331
|
+
DockError::InvalidChar(' ')
|
|
332
|
+
);
|
|
333
|
+
assert_eq!(DockId::new("ABC").unwrap_err(), DockError::InvalidChar('A'));
|
|
334
|
+
assert_eq!(DockId::new("a.b").unwrap_err(), DockError::InvalidChar('.'));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn register_ok_and_duplicate() {
|
|
339
|
+
let mut manager = DockManager::new();
|
|
340
|
+
manager
|
|
341
|
+
.register("chat", DockKind::Chat, Placement::Right)
|
|
342
|
+
.unwrap();
|
|
343
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
344
|
+
assert_eq!(
|
|
345
|
+
manager.register("chat", DockKind::Chat, Placement::Right),
|
|
346
|
+
Err(DockError::DuplicateDock("chat".into()))
|
|
347
|
+
);
|
|
348
|
+
// Invalid identifiers surface validation errors, not duplicates.
|
|
349
|
+
assert_eq!(
|
|
350
|
+
manager.register("", DockKind::Chat, Placement::Right),
|
|
351
|
+
Err(DockError::EmptyId)
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#[test]
|
|
356
|
+
fn open_close_toggle_idempotency_and_unknown() {
|
|
357
|
+
let mut manager = manager_with("chat", DockKind::Chat, Placement::Right);
|
|
358
|
+
|
|
359
|
+
// Open is idempotent.
|
|
360
|
+
manager.open("chat").unwrap();
|
|
361
|
+
assert!(manager.is_open("chat").unwrap());
|
|
362
|
+
manager.open("chat").unwrap();
|
|
363
|
+
assert!(manager.is_open("chat").unwrap());
|
|
364
|
+
|
|
365
|
+
// Close is idempotent.
|
|
366
|
+
manager.close("chat").unwrap();
|
|
367
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
368
|
+
manager.close("chat").unwrap();
|
|
369
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
370
|
+
|
|
371
|
+
// Toggle flips each time.
|
|
372
|
+
manager.toggle("chat").unwrap();
|
|
373
|
+
assert!(manager.is_open("chat").unwrap());
|
|
374
|
+
manager.toggle("chat").unwrap();
|
|
375
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
376
|
+
|
|
377
|
+
// Unknown identifiers report UnknownDock.
|
|
378
|
+
for result in [
|
|
379
|
+
manager.open("missing"),
|
|
380
|
+
manager.close("missing"),
|
|
381
|
+
manager.toggle("missing"),
|
|
382
|
+
manager.open_with_focus("missing", "token".into()),
|
|
383
|
+
manager.set_placement("missing", Placement::Left),
|
|
384
|
+
] {
|
|
385
|
+
assert_eq!(result, Err(DockError::UnknownDock("missing".into())));
|
|
386
|
+
}
|
|
387
|
+
assert_eq!(
|
|
388
|
+
manager.is_open("missing"),
|
|
389
|
+
Err(DockError::UnknownDock("missing".into()))
|
|
390
|
+
);
|
|
391
|
+
assert_eq!(
|
|
392
|
+
manager.placement("missing"),
|
|
393
|
+
Err(DockError::UnknownDock("missing".into()))
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#[test]
|
|
398
|
+
fn multi_open_docks_are_independent() {
|
|
399
|
+
let mut manager = DockManager::new();
|
|
400
|
+
manager
|
|
401
|
+
.register("chat", DockKind::Chat, Placement::Right)
|
|
402
|
+
.unwrap();
|
|
403
|
+
manager
|
|
404
|
+
.register("config", DockKind::Config, Placement::Left)
|
|
405
|
+
.unwrap();
|
|
406
|
+
|
|
407
|
+
manager.open("chat").unwrap();
|
|
408
|
+
assert!(manager.is_open("chat").unwrap());
|
|
409
|
+
assert!(!manager.is_open("config").unwrap());
|
|
410
|
+
|
|
411
|
+
manager.open("config").unwrap();
|
|
412
|
+
assert!(manager.is_open("chat").unwrap());
|
|
413
|
+
assert!(manager.is_open("config").unwrap());
|
|
414
|
+
|
|
415
|
+
manager.close("chat").unwrap();
|
|
416
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
417
|
+
assert!(manager.is_open("config").unwrap());
|
|
418
|
+
|
|
419
|
+
let open = manager.open_docks();
|
|
420
|
+
assert_eq!(open.len(), 1);
|
|
421
|
+
assert_eq!(open[0].id.as_str(), "config");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#[test]
|
|
425
|
+
fn set_placement_updates_only_target() {
|
|
426
|
+
let mut manager = DockManager::new();
|
|
427
|
+
manager
|
|
428
|
+
.register("chat", DockKind::Chat, Placement::Right)
|
|
429
|
+
.unwrap();
|
|
430
|
+
manager
|
|
431
|
+
.register("config", DockKind::Config, Placement::Left)
|
|
432
|
+
.unwrap();
|
|
433
|
+
|
|
434
|
+
assert_eq!(manager.placement("chat").unwrap(), Placement::Right);
|
|
435
|
+
manager.set_placement("chat", Placement::Bottom).unwrap();
|
|
436
|
+
assert_eq!(manager.placement("chat").unwrap(), Placement::Bottom);
|
|
437
|
+
// The other dock is untouched.
|
|
438
|
+
assert_eq!(manager.placement("config").unwrap(), Placement::Left);
|
|
439
|
+
|
|
440
|
+
assert_eq!(
|
|
441
|
+
manager.set_placement("missing", Placement::Inline),
|
|
442
|
+
Err(DockError::UnknownDock("missing".into()))
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#[test]
|
|
447
|
+
fn focus_token_lifecycle() {
|
|
448
|
+
let mut manager = manager_with("chat", DockKind::Chat, Placement::Right);
|
|
449
|
+
|
|
450
|
+
// open_with_focus stores the token.
|
|
451
|
+
manager.open_with_focus("chat", "first".into()).unwrap();
|
|
452
|
+
assert!(manager.is_open("chat").unwrap());
|
|
453
|
+
|
|
454
|
+
// Plain open preserves an existing token.
|
|
455
|
+
manager.open("chat").unwrap();
|
|
456
|
+
assert_eq!(manager.take_focus_token().as_deref(), Some("first"));
|
|
457
|
+
// Take clears the token.
|
|
458
|
+
assert_eq!(manager.take_focus_token(), None);
|
|
459
|
+
|
|
460
|
+
// open_with_focus replaces the stored token.
|
|
461
|
+
manager.open_with_focus("chat", "second".into()).unwrap();
|
|
462
|
+
manager.open_with_focus("chat", "third".into()).unwrap();
|
|
463
|
+
assert_eq!(manager.take_focus_token().as_deref(), Some("third"));
|
|
464
|
+
|
|
465
|
+
// Close keeps the token for a later take.
|
|
466
|
+
manager.open_with_focus("chat", "kept".into()).unwrap();
|
|
467
|
+
manager.close("chat").unwrap();
|
|
468
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
469
|
+
assert_eq!(manager.take_focus_token().as_deref(), Some("kept"));
|
|
470
|
+
assert_eq!(manager.take_focus_token(), None);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[test]
|
|
474
|
+
fn close_all_closes_docks_but_keeps_token() {
|
|
475
|
+
let mut manager = DockManager::new();
|
|
476
|
+
manager
|
|
477
|
+
.register("chat", DockKind::Chat, Placement::Right)
|
|
478
|
+
.unwrap();
|
|
479
|
+
manager
|
|
480
|
+
.register("config", DockKind::Config, Placement::Left)
|
|
481
|
+
.unwrap();
|
|
482
|
+
manager.open_with_focus("chat", "token".into()).unwrap();
|
|
483
|
+
manager.open("config").unwrap();
|
|
484
|
+
assert_eq!(manager.open_docks().len(), 2);
|
|
485
|
+
|
|
486
|
+
manager.close_all();
|
|
487
|
+
assert!(!manager.is_open("chat").unwrap());
|
|
488
|
+
assert!(!manager.is_open("config").unwrap());
|
|
489
|
+
assert!(manager.open_docks().is_empty());
|
|
490
|
+
assert_eq!(manager.take_focus_token().as_deref(), Some("token"));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#[test]
|
|
494
|
+
fn serde_roundtrip_preserves_state_without_focus_token() {
|
|
495
|
+
let mut manager = DockManager::new();
|
|
496
|
+
manager
|
|
497
|
+
.register("chat", DockKind::Chat, Placement::Bottom)
|
|
498
|
+
.unwrap();
|
|
499
|
+
manager
|
|
500
|
+
.register("custom-1", DockKind::Custom, Placement::Inline)
|
|
501
|
+
.unwrap();
|
|
502
|
+
manager.open("chat").unwrap();
|
|
503
|
+
manager
|
|
504
|
+
.open_with_focus("custom-1", "transient".into())
|
|
505
|
+
.unwrap();
|
|
506
|
+
|
|
507
|
+
let json = serde_json::to_value(&manager).unwrap();
|
|
508
|
+
// The transient token is never serialized.
|
|
509
|
+
assert!(json.get("focus_token").is_none());
|
|
510
|
+
|
|
511
|
+
let restored: DockManager = serde_json::from_value(json).unwrap();
|
|
512
|
+
assert!(restored.is_open("chat").unwrap());
|
|
513
|
+
assert!(restored.is_open("custom-1").unwrap());
|
|
514
|
+
assert_eq!(restored.placement("chat").unwrap(), Placement::Bottom);
|
|
515
|
+
assert_eq!(restored.placement("custom-1").unwrap(), Placement::Inline);
|
|
516
|
+
// Deserialized managers start without a focus token.
|
|
517
|
+
let mut restored = restored;
|
|
518
|
+
assert_eq!(restored.take_focus_token(), None);
|
|
519
|
+
assert_eq!(restored.open_docks().len(), 2);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#[test]
|
|
523
|
+
fn open_docks_content_and_order() {
|
|
524
|
+
let mut manager = DockManager::new();
|
|
525
|
+
manager
|
|
526
|
+
.register("one", DockKind::Chat, Placement::Left)
|
|
527
|
+
.unwrap();
|
|
528
|
+
manager
|
|
529
|
+
.register("two", DockKind::Config, Placement::Right)
|
|
530
|
+
.unwrap();
|
|
531
|
+
manager
|
|
532
|
+
.register("three", DockKind::Custom, Placement::Inline)
|
|
533
|
+
.unwrap();
|
|
534
|
+
assert!(manager.open_docks().is_empty());
|
|
535
|
+
|
|
536
|
+
manager.open("two").unwrap();
|
|
537
|
+
manager.open("one").unwrap();
|
|
538
|
+
let open = manager.open_docks();
|
|
539
|
+
// Registration order, not open order.
|
|
540
|
+
let ids: Vec<&str> = open.iter().map(|dock| dock.id.as_str()).collect();
|
|
541
|
+
assert_eq!(ids, vec!["one", "two"]);
|
|
542
|
+
assert_eq!(open[0].kind, DockKind::Chat);
|
|
543
|
+
assert_eq!(open[1].placement, Placement::Right);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
#[test]
|
|
547
|
+
fn error_messages_never_carry_focus_tokens() {
|
|
548
|
+
let token = "focus-secret-token";
|
|
549
|
+
let mut manager = manager_with("chat", DockKind::Chat, Placement::Right);
|
|
550
|
+
manager.open_with_focus("chat", token.into()).unwrap();
|
|
551
|
+
|
|
552
|
+
let errors = [
|
|
553
|
+
DockError::EmptyId,
|
|
554
|
+
DockError::IdTooLong,
|
|
555
|
+
DockError::InvalidChar('X'),
|
|
556
|
+
DockError::UnknownDock("missing".into()),
|
|
557
|
+
DockError::DuplicateDock("chat".into()),
|
|
558
|
+
];
|
|
559
|
+
for error in errors {
|
|
560
|
+
let rendered = format!("{error} {error:?}");
|
|
561
|
+
assert!(!rendered.contains("focus-secret-token"));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
package/source/src/lib.rs
CHANGED
|
@@ -20,9 +20,18 @@ pub use agent_client_protocol_schema::v1 as acp;
|
|
|
20
20
|
mod configuration;
|
|
21
21
|
pub use configuration::*;
|
|
22
22
|
|
|
23
|
+
mod auth;
|
|
24
|
+
pub use auth::*;
|
|
25
|
+
|
|
23
26
|
mod conversation;
|
|
24
27
|
pub use conversation::*;
|
|
25
28
|
|
|
29
|
+
mod transport;
|
|
30
|
+
pub use transport::*;
|
|
31
|
+
|
|
32
|
+
mod dock;
|
|
33
|
+
pub use dock::*;
|
|
34
|
+
|
|
26
35
|
#[cfg(all(feature = "native", not(target_family = "wasm")))]
|
|
27
36
|
pub mod native;
|
|
28
37
|
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
//! Native client connection to one ACP agent process.
|
|
2
|
+
//!
|
|
3
|
+
//! The upstream SDK owns process spawning, protocol dispatch, and cleanup.
|
|
4
|
+
//! This module only validates options, clears ambient credential variables,
|
|
5
|
+
//! and negotiates stable ACP v1 before handing sessions to [`super::session`].
|
|
6
|
+
|
|
1
7
|
use std::sync::{Arc, Mutex};
|
|
2
8
|
|
|
3
9
|
use agent_client_protocol::schema::ProtocolVersion;
|