clankerbend 0.1.0
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/DEVELOPERS.md +63 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/apps/README.md +11 -0
- package/apps/sticky-notes/README.md +11 -0
- package/apps/sticky-notes/onewhack.manifest.json +74 -0
- package/apps/sticky-notes/package.json +14 -0
- package/apps/sticky-notes/public/app.js +117 -0
- package/apps/sticky-notes/public/index.html +23 -0
- package/apps/sticky-notes/public/styles.css +84 -0
- package/apps/sticky-notes/src/sticky-notes-app.js +413 -0
- package/apps/vim-nav/README.md +126 -0
- package/apps/vim-nav/onewhack.manifest.json +69 -0
- package/apps/vim-nav/package.json +16 -0
- package/apps/vim-nav/public/app.js +276 -0
- package/apps/vim-nav/public/index.html +53 -0
- package/apps/vim-nav/public/styles.css +211 -0
- package/apps/vim-nav/server.mjs +10 -0
- package/apps/vim-nav/src/vim-nav-app.js +221 -0
- package/assets/onewhack.jpg +0 -0
- package/cli.mjs +91 -0
- package/docs/app-lifecycle.md +63 -0
- package/docs/app-manifest.md +130 -0
- package/docs/author-guide.md +126 -0
- package/docs/launcher-profiles.md +50 -0
- package/docs/protocol.md +1935 -0
- package/host/README.md +18 -0
- package/host/src/app-registry.js +315 -0
- package/host/src/codex-desktop-cdp-adapter.js +1826 -0
- package/host/src/codex-desktop-renderer-bridge.js +3536 -0
- package/host/src/index.js +1177 -0
- package/launch/profiles.mjs +93 -0
- package/launch/runtime-paths.mjs +21 -0
- package/package.json +66 -0
- package/scripts/release-npm.mjs +202 -0
- package/server.mjs +58 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
export const STICKY_NOTES_APP_ID = "onewill.sticky-notes";
|
|
2
|
+
|
|
3
|
+
export function createStickyNotesApp(options = {}) {
|
|
4
|
+
const notes = new Map();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
appId: STICKY_NOTES_APP_ID,
|
|
8
|
+
name: "Sticky Notes",
|
|
9
|
+
publicDir: options.publicDir,
|
|
10
|
+
contributes: {
|
|
11
|
+
panel: true,
|
|
12
|
+
annotations: true,
|
|
13
|
+
actions: true,
|
|
14
|
+
appState: true,
|
|
15
|
+
selectionActions: true,
|
|
16
|
+
overlays: true,
|
|
17
|
+
composerContext: true,
|
|
18
|
+
composerDraft: true
|
|
19
|
+
},
|
|
20
|
+
permissions: {
|
|
21
|
+
transcriptRead: true,
|
|
22
|
+
transcriptAnnotate: true,
|
|
23
|
+
transcriptNavigate: true,
|
|
24
|
+
overlayWrite: true,
|
|
25
|
+
composerWrite: true,
|
|
26
|
+
appServerRead: false,
|
|
27
|
+
appServerApprove: false,
|
|
28
|
+
appServerRollback: false
|
|
29
|
+
},
|
|
30
|
+
panel: {
|
|
31
|
+
title: "Sticky Notes",
|
|
32
|
+
reloadPolicy: "preserve",
|
|
33
|
+
preferredWidth: 380
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
getManifest(context) {
|
|
37
|
+
return {
|
|
38
|
+
oneWhackVersion: context.protocolVersion,
|
|
39
|
+
appId: STICKY_NOTES_APP_ID,
|
|
40
|
+
name: "Sticky Notes",
|
|
41
|
+
entry: context.entry,
|
|
42
|
+
contributes: this.contributes,
|
|
43
|
+
permissions: this.permissions,
|
|
44
|
+
panel: this.panel
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
getState(context) {
|
|
49
|
+
const noteList = [...notes.values()];
|
|
50
|
+
return {
|
|
51
|
+
appId: STICKY_NOTES_APP_ID,
|
|
52
|
+
status: "ready",
|
|
53
|
+
source: context.desktop.target?.type === "mock" ? "mock" : "host-api",
|
|
54
|
+
connected: context.desktop.cdpStatus === "connected",
|
|
55
|
+
entries: noteList.map((note) => noteEntry(note)),
|
|
56
|
+
annotations: annotationsForNotes(noteList, context),
|
|
57
|
+
selectionActions: [
|
|
58
|
+
selectionAction("sticky.addToChat", "Add to chat", "plus-square"),
|
|
59
|
+
selectionAction("sticky.note.open", "Add note", "sticky-note")
|
|
60
|
+
],
|
|
61
|
+
updatedAt: context.state.generatedAt
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async handleAction(action, context) {
|
|
66
|
+
if (action.type === "sticky.note.open") {
|
|
67
|
+
const selection = selectionFromAction(action, context);
|
|
68
|
+
return openNoteOverlay(action, context, selection);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (action.type === "sticky.note.create") {
|
|
72
|
+
const selection = selectionFromAction(action, context);
|
|
73
|
+
const body = String(action.payload?.body || action.payload?.note || "").trim();
|
|
74
|
+
if (!body) throw context.httpError(400, "bad_request", "note body is required");
|
|
75
|
+
const note = persistNoteFile(normalizeNote({
|
|
76
|
+
noteId: action.payload?.noteId,
|
|
77
|
+
body,
|
|
78
|
+
selection,
|
|
79
|
+
status: "queued",
|
|
80
|
+
requestedAt: action.requestedAt
|
|
81
|
+
}), context);
|
|
82
|
+
notes.set(note.noteId, note);
|
|
83
|
+
context.closeOverlay(action.payload?.overlayId, { broadcast: false });
|
|
84
|
+
attachNoteFile(notes, note.noteId, context);
|
|
85
|
+
return applied(action, { note, file: note.file, attachment: note.attachment });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (action.type === "sticky.note.update") {
|
|
89
|
+
const noteId = requireNoteId(action, context);
|
|
90
|
+
const current = requireNote(notes, noteId, context);
|
|
91
|
+
const body = String(action.payload?.body || action.payload?.note || "").trim();
|
|
92
|
+
if (!body) throw context.httpError(400, "bad_request", "note body is required");
|
|
93
|
+
const note = persistNoteFile({ ...current, body, updatedAt: action.requestedAt || new Date().toISOString(), attachment: null }, context);
|
|
94
|
+
notes.set(noteId, note);
|
|
95
|
+
attachNoteFile(notes, note.noteId, context);
|
|
96
|
+
return applied(action, { note, file: note.file, attachment: note.attachment });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (action.type === "sticky.note.delete") {
|
|
100
|
+
const noteId = requireNoteId(action, context);
|
|
101
|
+
const current = requireNote(notes, noteId, context);
|
|
102
|
+
notes.delete(noteId);
|
|
103
|
+
context.removeComposerContext(contextItemId(current.noteId), { broadcast: false });
|
|
104
|
+
return applied(action, { noteId, deleted: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (action.type === "sticky.note.resolve") {
|
|
108
|
+
const noteId = requireNoteId(action, context);
|
|
109
|
+
const current = requireNote(notes, noteId, context);
|
|
110
|
+
const note = { ...current, status: "resolved", updatedAt: action.requestedAt || new Date().toISOString() };
|
|
111
|
+
notes.set(noteId, note);
|
|
112
|
+
context.removeComposerContext(contextItemId(noteId), { broadcast: false });
|
|
113
|
+
return applied(action, { note });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (action.type === "sticky.addToChat") {
|
|
117
|
+
const selection = selectionFromAction(action, context);
|
|
118
|
+
const item = context.addComposerContext(selectionContextItem(action, selection), { broadcast: false }).item;
|
|
119
|
+
return applied(action, { contextItem: item });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (action.type === "sticky.compose.insertContext") {
|
|
123
|
+
const text = composePrompt(context, action.payload?.userText || "");
|
|
124
|
+
const result = await context.setComposerDraft({
|
|
125
|
+
text,
|
|
126
|
+
mode: action.payload?.mode || "replace",
|
|
127
|
+
contextItemIds: context.state.composer.contextItems
|
|
128
|
+
.filter((item) => item.appId === STICKY_NOTES_APP_ID)
|
|
129
|
+
.map((item) => item.itemId)
|
|
130
|
+
}, { broadcast: false });
|
|
131
|
+
return applied(action, { draft: result.draft || context.state.composer.draft });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw context.httpError(404, "not_found", `unknown action: ${action.type}`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function selectionFromAction(action, context) {
|
|
140
|
+
const selection = action.payload?.selection || context.selection;
|
|
141
|
+
if (!selection?.anchorId) throw context.httpError(400, "bad_request", "selection.anchorId is required");
|
|
142
|
+
const range = selection.range || {
|
|
143
|
+
anchorId: selection.anchorId,
|
|
144
|
+
text: selection.quote || context.findAnchor(selection.anchorId)?.textPreview || "",
|
|
145
|
+
quote: selection.quote || context.findAnchor(selection.anchorId)?.textPreview || "",
|
|
146
|
+
prefix: "",
|
|
147
|
+
suffix: ""
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
selectionId: selection.selectionId || `sel_${action.actionId}`,
|
|
151
|
+
anchorId: selection.anchorId,
|
|
152
|
+
quote: selection.quote || range.quote || range.text || "",
|
|
153
|
+
range: { ...range, anchorId: selection.anchorId },
|
|
154
|
+
rect: selection.rect || null,
|
|
155
|
+
selectedAt: selection.selectedAt || action.requestedAt || new Date().toISOString()
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function openNoteOverlay(action, context, selection) {
|
|
160
|
+
const overlay = context.openOverlay({
|
|
161
|
+
appId: STICKY_NOTES_APP_ID,
|
|
162
|
+
kind: "form",
|
|
163
|
+
title: "Add note",
|
|
164
|
+
anchorId: selection.anchorId,
|
|
165
|
+
range: selection.range,
|
|
166
|
+
anchorRect: selection.rect,
|
|
167
|
+
fields: [{
|
|
168
|
+
fieldId: "body",
|
|
169
|
+
kind: "textarea",
|
|
170
|
+
label: "Add note",
|
|
171
|
+
value: action.payload?.body || ""
|
|
172
|
+
}],
|
|
173
|
+
actions: [{
|
|
174
|
+
label: "Save",
|
|
175
|
+
type: "sticky.note.create",
|
|
176
|
+
payload: { selection }
|
|
177
|
+
}]
|
|
178
|
+
}, { broadcast: false }).overlay;
|
|
179
|
+
return applied(action, { overlay });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeNote(input) {
|
|
183
|
+
const now = input.requestedAt || new Date().toISOString();
|
|
184
|
+
const quote = input.selection.quote || input.selection.range?.text || "";
|
|
185
|
+
return {
|
|
186
|
+
noteId: input.noteId || `note_${hashId(`${input.selection.anchorId}:${quote}:${input.body}:${now}`)}`,
|
|
187
|
+
appId: STICKY_NOTES_APP_ID,
|
|
188
|
+
anchorId: input.selection.anchorId,
|
|
189
|
+
quote,
|
|
190
|
+
range: input.selection.range,
|
|
191
|
+
rect: input.selection.rect || null,
|
|
192
|
+
body: input.body,
|
|
193
|
+
file: input.file || null,
|
|
194
|
+
attachment: input.attachment || null,
|
|
195
|
+
status: input.status || "queued",
|
|
196
|
+
createdAt: now,
|
|
197
|
+
updatedAt: now
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function persistNoteFile(note, context) {
|
|
202
|
+
if (!context.writeRuntimeFile) return note;
|
|
203
|
+
const file = context.writeRuntimeFile({
|
|
204
|
+
fileId: `sticky-note:${note.noteId}`,
|
|
205
|
+
directory: "notes",
|
|
206
|
+
filename: noteFilename(note),
|
|
207
|
+
mimeType: "text/markdown; charset=utf-8",
|
|
208
|
+
content: noteMarkdown(note)
|
|
209
|
+
});
|
|
210
|
+
return { ...note, file, updatedAt: note.updatedAt || file.createdAt };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function attachNoteFile(notes, noteId, context) {
|
|
214
|
+
const note = notes.get(noteId);
|
|
215
|
+
if (!note?.file || !context.attachRuntimeFiles) return;
|
|
216
|
+
const pending = { ok: null, status: "pending", requestedAt: new Date().toISOString() };
|
|
217
|
+
notes.set(noteId, { ...note, attachment: pending });
|
|
218
|
+
context.attachRuntimeFiles([note.file], { broadcast: false })
|
|
219
|
+
.then((attachment) => {
|
|
220
|
+
const current = notes.get(noteId);
|
|
221
|
+
if (!current) return;
|
|
222
|
+
notes.set(noteId, { ...current, attachment, updatedAt: new Date().toISOString() });
|
|
223
|
+
context.requestStateBroadcast?.();
|
|
224
|
+
})
|
|
225
|
+
.catch((err) => {
|
|
226
|
+
const current = notes.get(noteId);
|
|
227
|
+
if (!current) return;
|
|
228
|
+
notes.set(noteId, { ...current, attachment: { ok: false, error: err.message }, updatedAt: new Date().toISOString() });
|
|
229
|
+
context.requestStateBroadcast?.();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function noteMarkdown(note) {
|
|
234
|
+
return [
|
|
235
|
+
"# Sticky Note",
|
|
236
|
+
"",
|
|
237
|
+
`Note ID: ${note.noteId}`,
|
|
238
|
+
`Status: ${note.status}`,
|
|
239
|
+
`Anchor: ${note.anchorId}`,
|
|
240
|
+
`Created: ${note.createdAt}`,
|
|
241
|
+
`Updated: ${note.updatedAt}`,
|
|
242
|
+
"",
|
|
243
|
+
"## Note",
|
|
244
|
+
"",
|
|
245
|
+
note.body,
|
|
246
|
+
"",
|
|
247
|
+
"## Highlighted Text",
|
|
248
|
+
"",
|
|
249
|
+
blockquote(note.quote),
|
|
250
|
+
"",
|
|
251
|
+
"## Range",
|
|
252
|
+
"",
|
|
253
|
+
"```json",
|
|
254
|
+
JSON.stringify(note.range || null, null, 2),
|
|
255
|
+
"```",
|
|
256
|
+
""
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function blockquote(text) {
|
|
261
|
+
return String(text || "")
|
|
262
|
+
.split(/\r?\n/)
|
|
263
|
+
.map((line) => `> ${line}`)
|
|
264
|
+
.join("\n");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function noteContextItem(note) {
|
|
268
|
+
return {
|
|
269
|
+
itemId: contextItemId(note.noteId),
|
|
270
|
+
appId: STICKY_NOTES_APP_ID,
|
|
271
|
+
label: labelFromQuote(note.quote),
|
|
272
|
+
body: note.body,
|
|
273
|
+
anchorId: note.anchorId,
|
|
274
|
+
range: note.range,
|
|
275
|
+
status: note.status === "resolved" ? "resolved" : "queued"
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function selectionContextItem(action, selection) {
|
|
280
|
+
return {
|
|
281
|
+
itemId: `selection_${hashId(`${selection.anchorId}:${selection.quote}:${action.actionId}`)}`,
|
|
282
|
+
appId: STICKY_NOTES_APP_ID,
|
|
283
|
+
label: labelFromQuote(selection.quote || selection.range?.text || selection.anchorId),
|
|
284
|
+
body: selection.quote || selection.range?.text || "",
|
|
285
|
+
anchorId: selection.anchorId,
|
|
286
|
+
range: selection.range,
|
|
287
|
+
status: "queued"
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function composePrompt(context, userText) {
|
|
292
|
+
const items = context.state.composer.contextItems.filter((item) => item.appId === STICKY_NOTES_APP_ID);
|
|
293
|
+
const sections = items.map((item, index) => {
|
|
294
|
+
const quote = item.range?.quote || item.range?.text || item.label;
|
|
295
|
+
return `Context ${index + 1}: ${item.label}\nSelected text: ${quote}\nNote: ${item.body || quote}`;
|
|
296
|
+
});
|
|
297
|
+
return [
|
|
298
|
+
"Use these selected transcript notes as context:",
|
|
299
|
+
"",
|
|
300
|
+
...sections.flatMap((section) => [section, ""]),
|
|
301
|
+
"User request:",
|
|
302
|
+
String(userText || "").trim()
|
|
303
|
+
].join("\n").trim();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function annotationsForNotes(notes, context) {
|
|
307
|
+
const selectedAnchorId = context.selection?.anchorId;
|
|
308
|
+
return notes
|
|
309
|
+
.filter((note) => note.status !== "resolved")
|
|
310
|
+
.map((note) => ({
|
|
311
|
+
appId: STICKY_NOTES_APP_ID,
|
|
312
|
+
anchorId: note.anchorId,
|
|
313
|
+
placement: "leading-rail",
|
|
314
|
+
priority: 70,
|
|
315
|
+
markers: [{
|
|
316
|
+
markerId: `sticky:${note.noteId}`,
|
|
317
|
+
glyph: "N",
|
|
318
|
+
label: note.body,
|
|
319
|
+
tone: note.anchorId === selectedAnchorId ? "accent" : "warning",
|
|
320
|
+
shape: "pill",
|
|
321
|
+
entryId: `sticky:${note.noteId}`,
|
|
322
|
+
action: {
|
|
323
|
+
type: "sticky.note.open",
|
|
324
|
+
payload: { noteId: note.noteId, selection: { anchorId: note.anchorId, quote: note.quote, range: note.range } }
|
|
325
|
+
}
|
|
326
|
+
}]
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function noteEntry(note) {
|
|
331
|
+
return {
|
|
332
|
+
entryId: `sticky:${note.noteId}`,
|
|
333
|
+
appId: STICKY_NOTES_APP_ID,
|
|
334
|
+
anchorId: note.anchorId,
|
|
335
|
+
title: labelFromQuote(note.quote),
|
|
336
|
+
summary: note.body,
|
|
337
|
+
category: "note",
|
|
338
|
+
tone: note.status === "resolved" ? "muted" : "warning",
|
|
339
|
+
status: note.status,
|
|
340
|
+
detail: {
|
|
341
|
+
quote: note.quote,
|
|
342
|
+
file: note.file,
|
|
343
|
+
attachment: note.attachment
|
|
344
|
+
},
|
|
345
|
+
occurredAt: note.updatedAt
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function selectionAction(type, label, icon) {
|
|
350
|
+
return {
|
|
351
|
+
actionId: type,
|
|
352
|
+
appId: STICKY_NOTES_APP_ID,
|
|
353
|
+
type,
|
|
354
|
+
label,
|
|
355
|
+
icon,
|
|
356
|
+
appliesTo: "text-selection",
|
|
357
|
+
enabled: true
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function applied(action, data) {
|
|
362
|
+
return {
|
|
363
|
+
actionId: action.actionId,
|
|
364
|
+
appId: STICKY_NOTES_APP_ID,
|
|
365
|
+
ok: true,
|
|
366
|
+
status: "applied",
|
|
367
|
+
data,
|
|
368
|
+
completedAt: new Date().toISOString()
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function requireNoteId(action, context) {
|
|
373
|
+
const noteId = action.payload?.noteId;
|
|
374
|
+
if (!noteId) throw context.httpError(400, "bad_request", "noteId is required");
|
|
375
|
+
return noteId;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function requireNote(notes, noteId, context) {
|
|
379
|
+
const note = notes.get(noteId);
|
|
380
|
+
if (!note) throw context.httpError(404, "not_found", "note not found");
|
|
381
|
+
return note;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function contextItemId(noteId) {
|
|
385
|
+
return `sticky:${noteId}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function labelFromQuote(quote) {
|
|
389
|
+
const text = String(quote || "selected text").replace(/\s+/g, " ").trim();
|
|
390
|
+
return text.length > 28 ? `${text.slice(0, 25)}...` : text;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function noteFilename(note) {
|
|
394
|
+
const words = String(note.body || "")
|
|
395
|
+
.toLowerCase()
|
|
396
|
+
.replace(/['"]/g, "")
|
|
397
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
398
|
+
.trim()
|
|
399
|
+
.split(/\s+/)
|
|
400
|
+
.filter(Boolean)
|
|
401
|
+
.slice(0, 6);
|
|
402
|
+
const slug = words.join("-") || "untitled";
|
|
403
|
+
const suffix = hashId(note.noteId).slice(0, 5);
|
|
404
|
+
return `note_${slug}_${suffix}.md`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function hashId(value) {
|
|
408
|
+
let hash = 0;
|
|
409
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
410
|
+
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
|
|
411
|
+
}
|
|
412
|
+
return Math.abs(hash).toString(36);
|
|
413
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# OneWhack Vim Navigator
|
|
2
|
+
|
|
3
|
+
OneWhack is an independent OneWill project compatible with OpenAI Codex
|
|
4
|
+
Desktop. It is not affiliated with or endorsed by OpenAI.
|
|
5
|
+
|
|
6
|
+
Vim Navigator is the public reference app for OneWhack `0.1`. It demonstrates
|
|
7
|
+
how an app registers with a central OneWhack host and uses transcript anchors,
|
|
8
|
+
annotations, selection, and app-scoped actions without owning CDP directly.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
cd onewhack
|
|
14
|
+
npm start
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The command starts a loopback OneWhack host on an ephemeral `127.0.0.1` port,
|
|
18
|
+
launches Codex Desktop with CDP, injects transcript markers, and serves the Vim
|
|
19
|
+
Navigator panel at `/apps/onewill.vim-nav/`.
|
|
20
|
+
|
|
21
|
+
For protocol-only mock mode:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm run start:mock
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Test
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm test
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The test suite runs without Codex Desktop. It verifies the host manifest, public
|
|
34
|
+
state, app registry, app manifest/state, initial SSE state event, action
|
|
35
|
+
execution, duplicate action idempotency, action result lookup, stale selection
|
|
36
|
+
rejection, scroll/highlight behavior, request validation, unknown app/anchor
|
|
37
|
+
errors, HTTP status/envelope alignment, and bearer-token behavior.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
Codex Desktop routes normal typing into the prompt composer, so enter transcript
|
|
42
|
+
navigation mode first:
|
|
43
|
+
|
|
44
|
+
- `Ctrl+Alt`: toggle Vim transcript mode
|
|
45
|
+
- `Escape` or `i`: exit Vim transcript mode
|
|
46
|
+
|
|
47
|
+
While Vim transcript mode is active, plain Vim keys are captured for transcript
|
|
48
|
+
navigation:
|
|
49
|
+
|
|
50
|
+
- `j` / `k`: next or previous transcript anchor, aligned so the transcript number is visible at the top
|
|
51
|
+
- `gg` / `G`: transcript item `1` or the last known transcript number
|
|
52
|
+
- `42G`: jump to transcript item `42`; oversized numbers clamp to the last known transcript number
|
|
53
|
+
- `[` / `]`: align the current transcript item to the top or bottom
|
|
54
|
+
- `{` / `}`: previous or next user message
|
|
55
|
+
|
|
56
|
+
The injected mode badge appears in the bottom-right corner while the mode is
|
|
57
|
+
active.
|
|
58
|
+
|
|
59
|
+
The focus-safe chords remain available as a fallback:
|
|
60
|
+
|
|
61
|
+
- `Ctrl+Alt+j` or `Ctrl+Alt+Down`: next transcript anchor
|
|
62
|
+
- `Ctrl+Alt+k` or `Ctrl+Alt+Up`: previous transcript anchor
|
|
63
|
+
- `Ctrl+Alt+g` or `Ctrl+Alt+Home`: first transcript anchor
|
|
64
|
+
- `Ctrl+Alt+G` or `Ctrl+Alt+End`: last transcript anchor
|
|
65
|
+
- `Ctrl+Alt+{` or `Ctrl+Alt+PageUp`: previous user message
|
|
66
|
+
- `Ctrl+Alt+}` or `Ctrl+Alt+PageDown`: next user message
|
|
67
|
+
|
|
68
|
+
Panel commands support:
|
|
69
|
+
|
|
70
|
+
- `/term`: search visible transcript text
|
|
71
|
+
- `n` / `N`: next or previous search match
|
|
72
|
+
- `:42` or `42`: jump by visible ruler number
|
|
73
|
+
- `:latest assistant` / `:latest user`: jump by role
|
|
74
|
+
|
|
75
|
+
These commands are implemented as OneWhack app actions, not only as panel-local
|
|
76
|
+
keyboard parsing. External clients can call the same actions through
|
|
77
|
+
`POST /onewhack/apps/onewill.vim-nav/actions`.
|
|
78
|
+
|
|
79
|
+
## Writing Another App
|
|
80
|
+
|
|
81
|
+
Create an object with:
|
|
82
|
+
|
|
83
|
+
- `appId`
|
|
84
|
+
- `name`
|
|
85
|
+
- `publicDir`
|
|
86
|
+
- `getManifest(context)`
|
|
87
|
+
- `getState(context)`
|
|
88
|
+
- `handleAction(action, context)`
|
|
89
|
+
|
|
90
|
+
Then register it with one host:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { OneWhackHost, createMockTranscriptAdapter } from "../../host/src/index.js";
|
|
94
|
+
|
|
95
|
+
const host = new OneWhackHost({
|
|
96
|
+
transcriptAdapter: createMockTranscriptAdapter({ defaultAppId: "example.app" })
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
host.registerApp(exampleApp);
|
|
100
|
+
await host.start();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The host serves all mounted apps through one central server and routes commands
|
|
104
|
+
through `/onewhack/apps/:appId/actions`.
|
|
105
|
+
|
|
106
|
+
## Real Desktop Validation
|
|
107
|
+
|
|
108
|
+
The default `npm test` path is mock/protocol-only and does not launch Codex
|
|
109
|
+
Desktop. To run the real Desktop regression harness:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
cd onewhack
|
|
113
|
+
npm run test:vim-nav:desktop-real
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The harness starts the Vim Navigator host, launches Codex Desktop with CDP,
|
|
117
|
+
creates a fresh chat, seeds deterministic transcript turns, injects the host
|
|
118
|
+
renderer bridge, and verifies:
|
|
119
|
+
|
|
120
|
+
- `Ctrl+Alt` toggles Vim transcript mode without moving scroll
|
|
121
|
+
- `G` lands on the last known transcript item
|
|
122
|
+
- repeated `k` walks to transcript item `1` without skipping or renumbering
|
|
123
|
+
- `12G`, `gg`, and oversized `number+G` land on the expected anchors
|
|
124
|
+
|
|
125
|
+
It writes diagnostics to `apps/vim-nav/run/desktop-real-validation/result.json`
|
|
126
|
+
and cleans up the launched Codex/Desktop process before exiting.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"oneWhackVersion": "0.1",
|
|
3
|
+
"appId": "onewill.vim-nav",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"name": "OneWill VimNav",
|
|
6
|
+
"description": "Public reference app for keyboard transcript navigation in Codex Desktop.",
|
|
7
|
+
"distribution": {
|
|
8
|
+
"kind": "local",
|
|
9
|
+
"source": ".",
|
|
10
|
+
"integrity": "dev-local",
|
|
11
|
+
"update": {
|
|
12
|
+
"channel": "local"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"entrypoint": {
|
|
16
|
+
"kind": "module",
|
|
17
|
+
"module": "./src/vim-nav-app.js",
|
|
18
|
+
"factory": "createVimNavigatorApp",
|
|
19
|
+
"publicDir": "./public"
|
|
20
|
+
},
|
|
21
|
+
"platform": {
|
|
22
|
+
"os": [
|
|
23
|
+
"darwin",
|
|
24
|
+
"linux",
|
|
25
|
+
"win32"
|
|
26
|
+
],
|
|
27
|
+
"arch": [
|
|
28
|
+
"any"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"capabilities": {
|
|
32
|
+
"panel": true,
|
|
33
|
+
"annotations": true,
|
|
34
|
+
"commands": true,
|
|
35
|
+
"actions": true,
|
|
36
|
+
"appState": true,
|
|
37
|
+
"rendererBridge": false
|
|
38
|
+
},
|
|
39
|
+
"permissions": {
|
|
40
|
+
"transcriptRead": true,
|
|
41
|
+
"transcriptAnnotate": true,
|
|
42
|
+
"transcriptNavigate": true,
|
|
43
|
+
"appServerRead": false,
|
|
44
|
+
"appServerApprove": false,
|
|
45
|
+
"appServerRollback": false
|
|
46
|
+
},
|
|
47
|
+
"panel": {
|
|
48
|
+
"title": "Vim Nav",
|
|
49
|
+
"reloadPolicy": "preserve",
|
|
50
|
+
"preferredWidth": 360
|
|
51
|
+
},
|
|
52
|
+
"lifecycle": {
|
|
53
|
+
"install": {
|
|
54
|
+
"kind": "noop"
|
|
55
|
+
},
|
|
56
|
+
"start": {
|
|
57
|
+
"kind": "in-process"
|
|
58
|
+
},
|
|
59
|
+
"stop": {
|
|
60
|
+
"kind": "in-process"
|
|
61
|
+
},
|
|
62
|
+
"update": {
|
|
63
|
+
"kind": "replace-manifest"
|
|
64
|
+
},
|
|
65
|
+
"remove": {
|
|
66
|
+
"kind": "forget"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "onewhack-vim-nav",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Public OneWhack reference app for Vim-like Codex Desktop transcript navigation.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.mjs",
|
|
9
|
+
"start:mock": "node server.mjs --mock",
|
|
10
|
+
"test": "node test-onewhack.mjs",
|
|
11
|
+
"test:desktop-real": "node test-desktop-real.mjs"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
}
|
|
16
|
+
}
|