@webinson/peer 0.4.15 → 0.4.17
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/README.md +57 -8
- package/bin/codex-thread-mark.mjs +232 -0
- package/bin/peer.mjs +85 -40
- package/mod/codex/.agents/plugins/marketplace.json +12 -0
- package/mod/codex/client/.gitattributes +1 -0
- package/mod/codex/client/0001-memory.patch +327 -0
- package/mod/codex/client/0002-terminal.patch +308 -0
- package/mod/codex/client/CODEX-LICENSE +201 -0
- package/mod/codex/client/CODEX-NOTICE +6 -0
- package/mod/codex/client/README.md +20 -0
- package/mod/codex/client/build.sh +67 -0
- package/mod/codex/client/run.sh +22 -0
- package/mod/codex/client/src/actions.rs +217 -0
- package/mod/codex/client/src/band.rs +469 -0
- package/mod/codex/client/src/openviking.rs +110 -0
- package/mod/codex/client/src/policy.rs +132 -0
- package/mod/codex/peer-thread-mark/.codex-plugin/plugin.json +12 -0
- package/mod/codex/peer-thread-mark/hooks/hooks.json +9 -0
- package/mod/codex/peer-thread-mark/hooks/peer.mjs +17 -0
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +1 -1
- package/mod/peer-thread-mark/hooks/register.tsx +50 -24
- package/mod/peer-thread-mark/tests/band.test.tsx +5 -0
- package/mod/peer-thread-mark/tests/modes.test.tsx +1 -0
- package/mod/peer-thread-mark/tests/saved.test.tsx +2 -2
- package/package.json +4 -3
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
//! Peer controls render in the composer; every action runs locally, outside the model.
|
|
2
|
+
|
|
3
|
+
use super::*;
|
|
4
|
+
use codex_config::peer_thread_mark::{self, Mode};
|
|
5
|
+
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
|
6
|
+
use ratatui::text::Span;
|
|
7
|
+
use serde::Deserialize;
|
|
8
|
+
use std::cell::Cell;
|
|
9
|
+
|
|
10
|
+
pub(crate) const TITLE_MARK: &str = "● Peer · ";
|
|
11
|
+
pub(crate) const THREADS_VIEW: &str = "peer-threads";
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Clone)]
|
|
14
|
+
pub(crate) enum PeerAction {
|
|
15
|
+
Refresh,
|
|
16
|
+
Start(Option<UserMessage>),
|
|
17
|
+
Stop,
|
|
18
|
+
Elephant,
|
|
19
|
+
Jellyfish,
|
|
20
|
+
List,
|
|
21
|
+
ReloadList,
|
|
22
|
+
Thread,
|
|
23
|
+
All(String),
|
|
24
|
+
Search(String),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
impl PeerAction {
|
|
28
|
+
pub(crate) fn command(&self) -> &'static str {
|
|
29
|
+
match self {
|
|
30
|
+
Self::Refresh => "status",
|
|
31
|
+
Self::Start(_) => "start",
|
|
32
|
+
Self::Stop => "stop",
|
|
33
|
+
Self::Elephant => "elephant",
|
|
34
|
+
Self::Jellyfish => "jellyfish",
|
|
35
|
+
Self::List | Self::ReloadList => "list",
|
|
36
|
+
Self::Thread => "thread",
|
|
37
|
+
Self::All(_) | Self::Search(_) => "all",
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub(crate) fn changes_mode(&self) -> bool {
|
|
42
|
+
matches!(
|
|
43
|
+
self,
|
|
44
|
+
Self::Start(_) | Self::Stop | Self::Elephant | Self::Jellyfish
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(Debug, Deserialize)]
|
|
50
|
+
#[serde(rename_all = "camelCase")]
|
|
51
|
+
pub(crate) struct PeerReply {
|
|
52
|
+
pub output: Option<String>,
|
|
53
|
+
pub memory_count: Option<u32>,
|
|
54
|
+
pub memory_page: Option<PeerMemoryPage>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[derive(Debug, Deserialize)]
|
|
58
|
+
pub(crate) struct PeerMemoryPage {
|
|
59
|
+
text: String,
|
|
60
|
+
here: bool,
|
|
61
|
+
sort: String,
|
|
62
|
+
total: usize,
|
|
63
|
+
page: usize,
|
|
64
|
+
pages: usize,
|
|
65
|
+
rows: Vec<PeerMemoryRow>,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[derive(Debug, Deserialize)]
|
|
69
|
+
struct PeerMemoryRow {
|
|
70
|
+
title: String,
|
|
71
|
+
repo: String,
|
|
72
|
+
#[serde(rename = "type")]
|
|
73
|
+
kind: String,
|
|
74
|
+
at: String,
|
|
75
|
+
lines: Vec<String>,
|
|
76
|
+
uri: String,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn memory_query(text: &str, here: bool, sort: &str, page: usize) -> String {
|
|
80
|
+
let order = match sort {
|
|
81
|
+
"new" => "--newest",
|
|
82
|
+
"old" => "--oldest",
|
|
83
|
+
_ => "",
|
|
84
|
+
};
|
|
85
|
+
[
|
|
86
|
+
text.to_string(),
|
|
87
|
+
if here { "here".into() } else { String::new() },
|
|
88
|
+
order.into(),
|
|
89
|
+
format!("page {page}"),
|
|
90
|
+
]
|
|
91
|
+
.into_iter()
|
|
92
|
+
.filter(|part| !part.is_empty())
|
|
93
|
+
.collect::<Vec<_>>()
|
|
94
|
+
.join(" ")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[derive(Default)]
|
|
98
|
+
pub(super) struct PeerBandState {
|
|
99
|
+
mode: Mode,
|
|
100
|
+
memory_count: Option<u32>,
|
|
101
|
+
bounds: Cell<Option<Rect>>,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pub(super) struct PeerBand<'a> {
|
|
105
|
+
state: &'a PeerBandState,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn band_labels(width: u16, count: Option<u32>, mode: Mode) -> [String; 4] {
|
|
109
|
+
let count = count.map_or_else(|| "–".to_string(), |value| value.to_string());
|
|
110
|
+
let active = if mode == Mode::Inactive {
|
|
111
|
+
"○ Inactive"
|
|
112
|
+
} else {
|
|
113
|
+
"● Active"
|
|
114
|
+
};
|
|
115
|
+
if width >= 66 {
|
|
116
|
+
[
|
|
117
|
+
format!("{count} memories"),
|
|
118
|
+
"Use Elephant".to_string(),
|
|
119
|
+
"Use Jellyfish".to_string(),
|
|
120
|
+
active.to_string(),
|
|
121
|
+
]
|
|
122
|
+
} else if width >= 53 {
|
|
123
|
+
[
|
|
124
|
+
format!("{count} mem"),
|
|
125
|
+
"Elephant".to_string(),
|
|
126
|
+
"Jellyfish".to_string(),
|
|
127
|
+
active.to_string(),
|
|
128
|
+
]
|
|
129
|
+
} else {
|
|
130
|
+
[
|
|
131
|
+
count,
|
|
132
|
+
"Elephant".into(),
|
|
133
|
+
"Jellyfish".into(),
|
|
134
|
+
if mode == Mode::Inactive {
|
|
135
|
+
"○".into()
|
|
136
|
+
} else {
|
|
137
|
+
"●".into()
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
impl Renderable for PeerBand<'_> {
|
|
144
|
+
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
145
|
+
self.state.bounds.set(Some(area));
|
|
146
|
+
let [count, elephant, jellyfish, active] =
|
|
147
|
+
band_labels(area.width, self.state.memory_count, self.state.mode);
|
|
148
|
+
let accent = crate::style::accent_style();
|
|
149
|
+
let elephant = if self.state.mode == Mode::Elephant {
|
|
150
|
+
Span::styled(elephant, accent).bold()
|
|
151
|
+
} else {
|
|
152
|
+
elephant.into()
|
|
153
|
+
};
|
|
154
|
+
let jellyfish = if self.state.mode == Mode::Jellyfish {
|
|
155
|
+
Span::styled(jellyfish, accent).bold()
|
|
156
|
+
} else {
|
|
157
|
+
jellyfish.into()
|
|
158
|
+
};
|
|
159
|
+
let line = Line::from(vec![
|
|
160
|
+
" ".into(),
|
|
161
|
+
Span::styled(count, accent),
|
|
162
|
+
" · ".dim(),
|
|
163
|
+
elephant,
|
|
164
|
+
" · ".dim(),
|
|
165
|
+
jellyfish,
|
|
166
|
+
" · ".dim(),
|
|
167
|
+
active.dim(),
|
|
168
|
+
]);
|
|
169
|
+
Renderable::render(&Paragraph::new(line), area, buf);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn desired_height(&self, width: u16) -> u16 {
|
|
173
|
+
u16::from(width >= 35)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
impl ChatWidget {
|
|
178
|
+
pub(crate) fn peer_available(&self) -> bool {
|
|
179
|
+
self.remote_connection.is_none()
|
|
180
|
+
&& !self.active_side_conversation
|
|
181
|
+
&& !self.blocks_direct_input
|
|
182
|
+
&& peer_thread_mark::home().is_some_and(|home| home.join("config.json").is_file())
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pub(super) fn peer_band(&self) -> Option<PeerBand<'_>> {
|
|
186
|
+
self.peer_mark.bounds.set(None);
|
|
187
|
+
(self.thread_id.is_some() && self.peer_available() && !self.bottom_pane.has_active_view())
|
|
188
|
+
.then_some(PeerBand {
|
|
189
|
+
state: &self.peer_mark,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pub(crate) fn handle_peer_mouse(&mut self, mouse: MouseEvent) -> bool {
|
|
194
|
+
if mouse.kind != MouseEventKind::Down(MouseButton::Left)
|
|
195
|
+
|| !self.peer_available()
|
|
196
|
+
|| self.bottom_pane.has_active_view()
|
|
197
|
+
|| self.quick_dial_menu
|
|
198
|
+
|| self.safety_recourse.is_some()
|
|
199
|
+
{
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
let Some(area) = self.peer_mark.bounds.get() else {
|
|
203
|
+
return false;
|
|
204
|
+
};
|
|
205
|
+
if mouse.row != area.y || area.height == 0 || mouse.column >= area.right() {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
let mut x = area.x.saturating_add(2);
|
|
209
|
+
let labels = band_labels(area.width, self.peer_mark.memory_count, self.peer_mark.mode);
|
|
210
|
+
for (label, action) in labels.into_iter().zip([
|
|
211
|
+
PeerAction::Thread,
|
|
212
|
+
PeerAction::Elephant,
|
|
213
|
+
PeerAction::Jellyfish,
|
|
214
|
+
PeerAction::List,
|
|
215
|
+
]) {
|
|
216
|
+
let end = x.saturating_add(Line::from(label).width() as u16);
|
|
217
|
+
if (x..end).contains(&mouse.column) {
|
|
218
|
+
self.request_peer_action(action);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
x = end.saturating_add(5);
|
|
222
|
+
}
|
|
223
|
+
false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
pub(super) fn request_peer_action(&mut self, action: PeerAction) {
|
|
227
|
+
if !self.peer_available() {
|
|
228
|
+
if !matches!(action, PeerAction::Refresh) {
|
|
229
|
+
self.add_error_message("Peer is unavailable here. Run `peer install --codex` and start the Peer Codex client.".into());
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if let Some(thread_id) = self.thread_id {
|
|
234
|
+
self.app_event_tx
|
|
235
|
+
.send(AppEvent::PeerThreadMark { thread_id, action });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
pub(super) fn reset_peer_band(&mut self) {
|
|
240
|
+
self.peer_mark = PeerBandState::default();
|
|
241
|
+
self.sync_peer_band(/*memory_count*/ None);
|
|
242
|
+
self.request_peer_action(PeerAction::Refresh);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
pub(super) fn stop_peer_before_clear(&mut self) {
|
|
246
|
+
if self.peer_mark.mode != Mode::Inactive {
|
|
247
|
+
self.request_peer_action(PeerAction::Stop);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
pub(super) fn restore_peer_title(&mut self) {
|
|
252
|
+
if self.peer_available()
|
|
253
|
+
&& self.peer_mark.mode != Mode::Inactive
|
|
254
|
+
&& let Some(title) = &self.thread_name
|
|
255
|
+
&& !title.starts_with(TITLE_MARK)
|
|
256
|
+
{
|
|
257
|
+
self.submit_op(AppCommand::set_thread_name(format!("{TITLE_MARK}{title}")));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
pub(crate) fn sync_peer_band(&mut self, memory_count: Option<u32>) {
|
|
262
|
+
if let Some(id) = self.thread_id {
|
|
263
|
+
self.peer_mark.mode = peer_thread_mark::mode(&id.to_string());
|
|
264
|
+
}
|
|
265
|
+
if memory_count.is_some() {
|
|
266
|
+
self.peer_mark.memory_count = memory_count;
|
|
267
|
+
}
|
|
268
|
+
self.request_redraw();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
pub(crate) fn peer_list_open(&self) -> bool {
|
|
272
|
+
self.bottom_pane
|
|
273
|
+
.selected_index_for_active_view(THREADS_VIEW)
|
|
274
|
+
.is_some()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pub(crate) fn show_peer_result(
|
|
278
|
+
&mut self,
|
|
279
|
+
title: &str,
|
|
280
|
+
view_id: &'static str,
|
|
281
|
+
text: &str,
|
|
282
|
+
replace: bool,
|
|
283
|
+
) {
|
|
284
|
+
let items = text
|
|
285
|
+
.lines()
|
|
286
|
+
.filter(|line| !line.trim().is_empty())
|
|
287
|
+
.map(|line| SelectionItem {
|
|
288
|
+
name: line.chars().filter(|c| !c.is_control()).collect(),
|
|
289
|
+
..Default::default()
|
|
290
|
+
})
|
|
291
|
+
.collect();
|
|
292
|
+
let params = SelectionViewParams {
|
|
293
|
+
title: Some(title.to_string()),
|
|
294
|
+
view_id: Some(view_id),
|
|
295
|
+
items,
|
|
296
|
+
auto_number_shortcuts: false,
|
|
297
|
+
is_searchable: true,
|
|
298
|
+
..SelectionViewParams::picker()
|
|
299
|
+
};
|
|
300
|
+
if replace {
|
|
301
|
+
self.bottom_pane
|
|
302
|
+
.replace_selection_view_if_active(view_id, params);
|
|
303
|
+
} else {
|
|
304
|
+
self.show_selection_view(params);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
pub(crate) fn show_peer_search(&mut self, thread_id: ThreadId, query: String) {
|
|
309
|
+
let tx = self.app_event_tx.clone();
|
|
310
|
+
self.bottom_pane.show_text_prompt(CustomPromptView::new(
|
|
311
|
+
"Search your memories".into(),
|
|
312
|
+
"Words, quoted phrases, here, --newest, --oldest or page N".into(),
|
|
313
|
+
query,
|
|
314
|
+
None,
|
|
315
|
+
Box::new(move |query| {
|
|
316
|
+
tx.send(AppEvent::PeerThreadMark {
|
|
317
|
+
thread_id,
|
|
318
|
+
action: PeerAction::All(query),
|
|
319
|
+
})
|
|
320
|
+
}),
|
|
321
|
+
));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
pub(crate) fn show_peer_memories(&mut self, thread_id: ThreadId, page: PeerMemoryPage) {
|
|
325
|
+
let action_item = |name: &str, action: PeerAction| SelectionItem {
|
|
326
|
+
name: name.into(),
|
|
327
|
+
actions: vec![Box::new(move |tx| {
|
|
328
|
+
tx.send(AppEvent::PeerThreadMark {
|
|
329
|
+
thread_id,
|
|
330
|
+
action: action.clone(),
|
|
331
|
+
})
|
|
332
|
+
})],
|
|
333
|
+
dismiss_on_select: true,
|
|
334
|
+
..Default::default()
|
|
335
|
+
};
|
|
336
|
+
let query = |here, sort, number| memory_query(&page.text, here, sort, number);
|
|
337
|
+
let (sort_label, next_sort) = match page.sort.as_str() {
|
|
338
|
+
"new" => ("Order: newest first", "old"),
|
|
339
|
+
"old" => ("Order: oldest first", ""),
|
|
340
|
+
_ => ("Order: OpenViking", "new"),
|
|
341
|
+
};
|
|
342
|
+
let mut items = vec![
|
|
343
|
+
action_item(
|
|
344
|
+
"Search your memories…",
|
|
345
|
+
PeerAction::Search(query(page.here, &page.sort, 1)),
|
|
346
|
+
),
|
|
347
|
+
action_item(
|
|
348
|
+
if page.here {
|
|
349
|
+
"Repository: this one"
|
|
350
|
+
} else {
|
|
351
|
+
"Repository: all"
|
|
352
|
+
},
|
|
353
|
+
PeerAction::All(query(!page.here, &page.sort, 1)),
|
|
354
|
+
),
|
|
355
|
+
action_item(sort_label, PeerAction::All(query(page.here, next_sort, 1))),
|
|
356
|
+
];
|
|
357
|
+
if page.page > 1 {
|
|
358
|
+
items.push(action_item(
|
|
359
|
+
"Previous page",
|
|
360
|
+
PeerAction::All(query(page.here, &page.sort, page.page - 1)),
|
|
361
|
+
));
|
|
362
|
+
}
|
|
363
|
+
if page.page < page.pages {
|
|
364
|
+
items.push(action_item(
|
|
365
|
+
"Next page",
|
|
366
|
+
PeerAction::All(query(page.here, &page.sort, page.page + 1)),
|
|
367
|
+
));
|
|
368
|
+
}
|
|
369
|
+
let plain = |text: &str| text.chars().filter(|c| !c.is_control()).collect::<String>();
|
|
370
|
+
items.extend(page.rows.into_iter().map(|row| SelectionItem {
|
|
371
|
+
name: plain(&row.title),
|
|
372
|
+
description: Some(plain(&format!("{} · {} · {}", row.repo, row.kind, row.at))),
|
|
373
|
+
selected_description: Some(format!(
|
|
374
|
+
"{}\n{}",
|
|
375
|
+
plain(&row.lines.join(" / ")),
|
|
376
|
+
plain(&row.uri)
|
|
377
|
+
)),
|
|
378
|
+
..Default::default()
|
|
379
|
+
}));
|
|
380
|
+
self.show_selection_view(SelectionViewParams {
|
|
381
|
+
title: Some("Your memories".into()),
|
|
382
|
+
subtitle: Some(format!(
|
|
383
|
+
"{} memories · page {} of {}",
|
|
384
|
+
page.total, page.page, page.pages
|
|
385
|
+
)),
|
|
386
|
+
items,
|
|
387
|
+
auto_number_shortcuts: false,
|
|
388
|
+
..SelectionViewParams::picker()
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
pub(super) fn open_peer_modes(&mut self) {
|
|
393
|
+
let Some(thread_id) = self.thread_id.filter(|_| self.peer_available()) else {
|
|
394
|
+
self.add_error_message(
|
|
395
|
+
"Run `peer install --codex` to set up Peer in this client.".into(),
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
};
|
|
399
|
+
let mut items = Vec::new();
|
|
400
|
+
for (name, description, mode, action) in [
|
|
401
|
+
(
|
|
402
|
+
"Use Elephant",
|
|
403
|
+
"Share this thread and use OpenViking and Codex memory.",
|
|
404
|
+
Mode::Elephant,
|
|
405
|
+
PeerAction::Elephant,
|
|
406
|
+
),
|
|
407
|
+
(
|
|
408
|
+
"Use Jellyfish",
|
|
409
|
+
"Share this thread without memory. Instructions stay.",
|
|
410
|
+
Mode::Jellyfish,
|
|
411
|
+
PeerAction::Jellyfish,
|
|
412
|
+
),
|
|
413
|
+
(
|
|
414
|
+
"Inactive",
|
|
415
|
+
"Stop sharing and keep Codex's own memory.",
|
|
416
|
+
Mode::Inactive,
|
|
417
|
+
PeerAction::Stop,
|
|
418
|
+
),
|
|
419
|
+
] {
|
|
420
|
+
let action: super::SelectionAction = Box::new(move |tx| {
|
|
421
|
+
tx.send(AppEvent::PeerThreadMark {
|
|
422
|
+
thread_id,
|
|
423
|
+
action: action.clone(),
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
items.push(SelectionItem {
|
|
427
|
+
name: name.to_string(),
|
|
428
|
+
description: Some(description.to_string()),
|
|
429
|
+
is_current: self.peer_mark.mode == mode,
|
|
430
|
+
actions: vec![action],
|
|
431
|
+
dismiss_on_select: true,
|
|
432
|
+
..Default::default()
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
self.show_selection_view(SelectionViewParams {
|
|
436
|
+
title: Some("Peer memory mode".into()),
|
|
437
|
+
items,
|
|
438
|
+
..SelectionViewParams::picker()
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#[cfg(test)]
|
|
444
|
+
mod tests {
|
|
445
|
+
use super::*;
|
|
446
|
+
|
|
447
|
+
#[test]
|
|
448
|
+
fn the_band_shows_the_memory_count_and_selected_mode() {
|
|
449
|
+
let state = PeerBandState {
|
|
450
|
+
mode: Mode::Jellyfish,
|
|
451
|
+
memory_count: Some(7),
|
|
452
|
+
..Default::default()
|
|
453
|
+
};
|
|
454
|
+
let area = Rect::new(0, 0, 80, 1);
|
|
455
|
+
let mut buf = Buffer::empty(area);
|
|
456
|
+
PeerBand { state: &state }.render(area, &mut buf);
|
|
457
|
+
let line: String = buf.content().iter().map(|cell| cell.symbol()).collect();
|
|
458
|
+
assert!(line.contains("7 memories"));
|
|
459
|
+
assert!(line.contains("Use Elephant"));
|
|
460
|
+
assert!(line.contains("Use Jellyfish"));
|
|
461
|
+
assert!(line.contains("● Active"));
|
|
462
|
+
let selected = line.find("Use Jellyfish").expect("selected label");
|
|
463
|
+
assert!(
|
|
464
|
+
buf.content()[selected]
|
|
465
|
+
.modifier
|
|
466
|
+
.contains(ratatui::style::Modifier::BOLD)
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//! OpenViking reads a projection of the opted-in turns, including when it sweeps old sessions.
|
|
2
|
+
|
|
3
|
+
use std::fs;
|
|
4
|
+
use std::io::{BufRead, BufReader, Write};
|
|
5
|
+
use std::path::Path;
|
|
6
|
+
|
|
7
|
+
use codex_config::peer_thread_mark::{self, Mode, Window};
|
|
8
|
+
use serde_json::Value;
|
|
9
|
+
|
|
10
|
+
pub(super) fn is_openviking(root: &str) -> bool {
|
|
11
|
+
Path::new(root).ancestors().take(2).any(|dir| {
|
|
12
|
+
dir.file_name()
|
|
13
|
+
.is_some_and(|name| name == "openviking-memory")
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub(super) fn project(home: &Path, mut input: Value) -> Value {
|
|
18
|
+
let session = input["session_id"].as_str().unwrap_or_default();
|
|
19
|
+
let windows = peer_thread_mark::windows(home, session);
|
|
20
|
+
let projected = if peer_thread_mark::mode_at(home, session) == Mode::Elephant
|
|
21
|
+
&& !windows.is_empty()
|
|
22
|
+
&& let Some(transcript) = input["transcript_path"].as_str()
|
|
23
|
+
{
|
|
24
|
+
let output = home
|
|
25
|
+
.join("codex-capture/rollouts")
|
|
26
|
+
.join(format!("codex-{session}.jsonl"));
|
|
27
|
+
project_file(Path::new(transcript), &output, &windows)
|
|
28
|
+
.ok()
|
|
29
|
+
.map(|()| output)
|
|
30
|
+
} else {
|
|
31
|
+
None
|
|
32
|
+
};
|
|
33
|
+
input["transcript_path"] = projected.map_or(Value::Null, |path| {
|
|
34
|
+
Value::String(path.to_string_lossy().into_owned())
|
|
35
|
+
});
|
|
36
|
+
if let Some(path) = input.get_mut("agent_transcript_path") {
|
|
37
|
+
*path = Value::Null;
|
|
38
|
+
}
|
|
39
|
+
input
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn project_file(source: &Path, output: &Path, windows: &[Window]) -> std::io::Result<()> {
|
|
43
|
+
let directory = output.parent().expect("projection directory");
|
|
44
|
+
fs::create_dir_all(directory)?;
|
|
45
|
+
let mut projected = tempfile::NamedTempFile::new_in(directory)?;
|
|
46
|
+
for (index, line) in BufReader::new(fs::File::open(source)?).lines().enumerate() {
|
|
47
|
+
let line = line?;
|
|
48
|
+
let Ok(row) = serde_json::from_str::<Value>(&line) else {
|
|
49
|
+
continue;
|
|
50
|
+
};
|
|
51
|
+
let header = index == 0 && row["type"].as_str() == Some("session_meta");
|
|
52
|
+
let allowed = row["timestamp"]
|
|
53
|
+
.as_str()
|
|
54
|
+
.and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok())
|
|
55
|
+
.is_some_and(|at| {
|
|
56
|
+
peer_thread_mark::contains(windows, Mode::Elephant, at.timestamp_millis())
|
|
57
|
+
});
|
|
58
|
+
if header || allowed {
|
|
59
|
+
writeln!(projected, "{line}")?;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
projected.persist(output)?;
|
|
63
|
+
Ok(())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[cfg(test)]
|
|
67
|
+
mod tests {
|
|
68
|
+
use super::*;
|
|
69
|
+
|
|
70
|
+
#[test]
|
|
71
|
+
fn projection_keeps_elephant_turns_across_a_pause() -> std::io::Result<()> {
|
|
72
|
+
let dir = tempfile::tempdir()?;
|
|
73
|
+
let source = dir.path().join("full.jsonl");
|
|
74
|
+
let output = dir.path().join("elephant.jsonl");
|
|
75
|
+
fs::write(
|
|
76
|
+
&source,
|
|
77
|
+
concat!(
|
|
78
|
+
"{\"type\":\"session_meta\",\"timestamp\":\"1970-01-01T00:00:00Z\"}\n",
|
|
79
|
+
"{\"timestamp\":\"1970-01-01T00:00:01Z\",\"text\":\"before opt-in\"}\n",
|
|
80
|
+
"{\"timestamp\":\"1970-01-01T00:00:02Z\",\"text\":\"first elephant turn\"}\n",
|
|
81
|
+
"{\"timestamp\":\"1970-01-01T00:00:03Z\",\"text\":\"private jellyfish\"}\n",
|
|
82
|
+
"{\"timestamp\":\"1970-01-01T00:00:04Z\",\"text\":\"second elephant turn\"}\n",
|
|
83
|
+
),
|
|
84
|
+
)?;
|
|
85
|
+
let windows = [
|
|
86
|
+
Window {
|
|
87
|
+
mode: Mode::Elephant,
|
|
88
|
+
start: 2000,
|
|
89
|
+
end: Some(3000),
|
|
90
|
+
},
|
|
91
|
+
Window {
|
|
92
|
+
mode: Mode::Jellyfish,
|
|
93
|
+
start: 3000,
|
|
94
|
+
end: Some(4000),
|
|
95
|
+
},
|
|
96
|
+
Window {
|
|
97
|
+
mode: Mode::Elephant,
|
|
98
|
+
start: 4000,
|
|
99
|
+
end: None,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
project_file(&source, &output, &windows)?;
|
|
103
|
+
let filtered = fs::read_to_string(output)?;
|
|
104
|
+
assert!(filtered.contains("first elephant turn"));
|
|
105
|
+
assert!(filtered.contains("second elephant turn"));
|
|
106
|
+
assert!(!filtered.contains("before opt-in"));
|
|
107
|
+
assert!(!filtered.contains("private jellyfish"));
|
|
108
|
+
Ok(())
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//! Local Peer state shared by the terminal, memory extension and hook runtime.
|
|
2
|
+
|
|
3
|
+
use std::env;
|
|
4
|
+
use std::fs;
|
|
5
|
+
use std::path::Path;
|
|
6
|
+
use std::path::PathBuf;
|
|
7
|
+
|
|
8
|
+
use serde::Deserialize;
|
|
9
|
+
|
|
10
|
+
pub const OPENVIKING_PLUGIN: &str = "openviking-memory@openviking";
|
|
11
|
+
|
|
12
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
|
13
|
+
#[serde(rename_all = "lowercase")]
|
|
14
|
+
pub enum Mode {
|
|
15
|
+
#[default]
|
|
16
|
+
Inactive,
|
|
17
|
+
Elephant,
|
|
18
|
+
Jellyfish,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Deserialize)]
|
|
22
|
+
struct Marker {
|
|
23
|
+
mode: Mode,
|
|
24
|
+
workspace: String,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Deserialize)]
|
|
28
|
+
pub struct Window {
|
|
29
|
+
pub mode: Mode,
|
|
30
|
+
pub start: i64,
|
|
31
|
+
pub end: Option<i64>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn home() -> Option<PathBuf> {
|
|
35
|
+
if env::var("PEER_CODEX_THREAD_MARK").as_deref() != Ok("1") {
|
|
36
|
+
return None;
|
|
37
|
+
}
|
|
38
|
+
let home = env::var_os("PEER_HOME")
|
|
39
|
+
.filter(|value| !value.is_empty())
|
|
40
|
+
.map(PathBuf::from)
|
|
41
|
+
.or_else(|| env::home_dir().map(|path| path.join(".peer")))?;
|
|
42
|
+
home.join("codex-cli.json").is_file().then_some(home)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn valid_id(id: &str) -> bool {
|
|
46
|
+
!id.is_empty()
|
|
47
|
+
&& id.len() <= 114
|
|
48
|
+
&& id
|
|
49
|
+
.bytes()
|
|
50
|
+
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pub fn mode_at(home: &Path, thread_id: &str) -> Mode {
|
|
54
|
+
if !valid_id(thread_id) {
|
|
55
|
+
return Mode::Inactive;
|
|
56
|
+
}
|
|
57
|
+
fs::read(home.join("sessions").join(format!("codex-{thread_id}")))
|
|
58
|
+
.ok()
|
|
59
|
+
.and_then(|bytes| serde_json::from_slice::<Marker>(&bytes).ok())
|
|
60
|
+
.filter(|mark| !mark.workspace.is_empty())
|
|
61
|
+
.map_or(Mode::Inactive, |mark| mark.mode)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn mode(thread_id: &str) -> Mode {
|
|
65
|
+
home().map_or(Mode::Inactive, |home| mode_at(&home, thread_id))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub fn disabled_plugins(thread_id: &str, original: &[String]) -> Vec<String> {
|
|
69
|
+
let mut disabled = original.to_vec();
|
|
70
|
+
if let Some(home) = home()
|
|
71
|
+
&& mode_at(&home, thread_id) != Mode::Elephant
|
|
72
|
+
&& !disabled.iter().any(|id| id == OPENVIKING_PLUGIN)
|
|
73
|
+
{
|
|
74
|
+
disabled.push(OPENVIKING_PLUGIN.to_string());
|
|
75
|
+
}
|
|
76
|
+
disabled
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
pub fn windows(home: &Path, thread_id: &str) -> Vec<Window> {
|
|
80
|
+
if !valid_id(thread_id) {
|
|
81
|
+
return Vec::new();
|
|
82
|
+
}
|
|
83
|
+
fs::read(
|
|
84
|
+
home.join("codex-capture")
|
|
85
|
+
.join(format!("codex-{thread_id}.json")),
|
|
86
|
+
)
|
|
87
|
+
.ok()
|
|
88
|
+
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
|
89
|
+
.unwrap_or_default()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub fn contains(windows: &[Window], mode: Mode, timestamp_ms: i64) -> bool {
|
|
93
|
+
windows.iter().any(|window| {
|
|
94
|
+
window.mode == mode
|
|
95
|
+
&& window.start <= timestamp_ms
|
|
96
|
+
&& window.end.is_none_or(|end| timestamp_ms < end)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[cfg(test)]
|
|
101
|
+
mod tests {
|
|
102
|
+
use super::*;
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn a_mode_owns_only_the_rows_inside_its_window() {
|
|
106
|
+
let windows = [
|
|
107
|
+
Window {
|
|
108
|
+
mode: Mode::Elephant,
|
|
109
|
+
start: 3,
|
|
110
|
+
end: Some(6),
|
|
111
|
+
},
|
|
112
|
+
Window {
|
|
113
|
+
mode: Mode::Jellyfish,
|
|
114
|
+
start: 6,
|
|
115
|
+
end: Some(9),
|
|
116
|
+
},
|
|
117
|
+
Window {
|
|
118
|
+
mode: Mode::Elephant,
|
|
119
|
+
start: 15,
|
|
120
|
+
end: None,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
let captured: Vec<_> = (1..18)
|
|
124
|
+
.filter(|ts| contains(&windows, Mode::Elephant, *ts))
|
|
125
|
+
.collect();
|
|
126
|
+
assert_eq!(captured, [3, 4, 5, 15, 16, 17]);
|
|
127
|
+
let jellyfish: Vec<_> = (1..18)
|
|
128
|
+
.filter(|ts| contains(&windows, Mode::Jellyfish, *ts))
|
|
129
|
+
.collect();
|
|
130
|
+
assert_eq!(jellyfish, [6, 7, 8]);
|
|
131
|
+
}
|
|
132
|
+
}
|