kennel 0.2.41 → 0.2.42

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.
@@ -242,7 +242,7 @@ function repositoryName(folder) {
242
242
  return;
243
243
  }
244
244
  }
245
- function previewTerminal(url, target, startShare) {
245
+ function previewTerminal(url, target, startShare, views) {
246
246
  url = new URL(url).href;
247
247
  const input = process.stdin;
248
248
  const output = process.stdout;
@@ -253,8 +253,9 @@ function previewTerminal(url, target, startShare) {
253
253
  const repository = interactive ? repositoryName(target.root) : undefined;
254
254
  const color = terminal && process.env.NO_COLOR === undefined && output.hasColors();
255
255
  const accent = (text) => color ? `\x1B[32m${text}\x1B[39m` : text;
256
- const link = (label) => terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
257
- ${url}`;
256
+ const linkTo = (label, href, plain = `${label}
257
+ ${href}`) => terminal ? `\x1B]8;;${href}\x07${accent(label)}\x1B]8;;\x07` : plain;
258
+ const link = (label) => linkTo(label, url);
258
259
  const wasRaw = Boolean(input.isRaw);
259
260
  const wasFlowing = input.readableFlowing === true;
260
261
  let resolveClosed;
@@ -271,27 +272,28 @@ function previewTerminal(url, target, startShare) {
271
272
  return;
272
273
  cursorTo(output, 0, 0);
273
274
  clearScreenDown(output);
274
- const width = Math.max(1, (output.columns || 80) - 5);
275
+ const width = Math.max(1, (output.columns || 80) - 1);
275
276
  const fit = (text) => {
276
277
  const characters = Array.from(terminalText(text));
277
278
  return characters.length > width ? characters.slice(0, width - 1).join("") + "…" : characters.join("");
278
279
  };
279
- const count = Math.max(1, Math.min(8, (output.rows || 24) - 12));
280
- const start = Math.max(0, Math.min(selected - Math.floor(count / 2), actions.length - count));
281
- const choices = actions.slice(start, start + count).map((item, index) => {
280
+ const choices = [];
281
+ actions.forEach((item, index) => {
282
282
  const label = fit(item.shortcut ? `[${item.shortcut}] ${item.label}` : item.label);
283
- return start + index === selected ? accent(` › ${label}`) : ` ${label}`;
283
+ let text = index === selected ? accent(`› ${label}`) : ` ${label}`;
284
+ if (item.view !== undefined)
285
+ text = linkTo(text, item.view, accent(text));
286
+ choices.push(text);
284
287
  });
285
288
  output.write([
286
- ` \x1B[1m${link(fit(title))}\x1B[22m`,
287
- ...repository ? [` ${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
288
- ...target.target ? [` File: ${fit(name)}`] : [],
289
+ `\x1B[1m${link(fit(title))}\x1B[22m`,
290
+ ...repository ? [`${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
291
+ ...target.target ? [`File: ${fit(name)}`] : [],
289
292
  "",
290
293
  ...choices,
291
294
  "",
292
- " ↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
293
- ...actions.length > count ? [` ${selected + 1} / ${actions.length}`] : [],
294
- ...status ? ["", ` ${status}`] : [],
295
+ "↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
296
+ ...status ? ["", status] : [],
295
297
  ""
296
298
  ].join(`
297
299
  `));
@@ -356,6 +358,10 @@ function previewTerminal(url, target, startShare) {
356
358
  resolveClosed();
357
359
  };
358
360
  const actions = [
361
+ ...(views ?? []).map((view) => ({ label: view.id, run: () => {
362
+ openPreview(view.url);
363
+ status = "Browser open requested.";
364
+ }, view: view.url })),
359
365
  { label: "Open in browser", shortcut: "o", run: () => {
360
366
  openPreview(url);
361
367
  status = "Browser open requested.";
@@ -415,7 +421,7 @@ function previewTerminal(url, target, startShare) {
415
421
  render();
416
422
  else
417
423
  console.log(`
418
- ${link(name)}
424
+ ${link(name)}
419
425
  `);
420
426
  return { close, closed, interactive };
421
427
  }
@@ -25,6 +25,11 @@ export type ExternalPluginEntry = {
25
25
  digest: string;
26
26
  };
27
27
  };
28
+ /** Manifest-declared subviews, recorded at install time. */
29
+ views?: {
30
+ subpath: string;
31
+ label: string;
32
+ }[];
28
33
  };
29
34
  export declare function kennelConfigPath(): string;
30
35
  export declare const PLUGIN_SOURCE_IGNORED: Set<string>;
@@ -373,6 +373,12 @@ async function validateImports(entry) {
373
373
  if (typeof apiModule.default !== "function")
374
374
  throw new Error("API default export missing handler");
375
375
  }
376
+ if (Array.isArray(manifest.views)) {
377
+ entry.views = manifest.views.map((view) => ({
378
+ subpath: view.subpath,
379
+ label: view.label,
380
+ }));
381
+ }
376
382
  }
377
383
  finally {
378
384
  await rm(dir, { force: true, recursive: true });
@@ -6,7 +6,7 @@ import { createConnection } from 'node:net';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { dirname, relative, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { popUrl } from './viewer.js';
9
+ import { popUrl, pluginViewLinks } from './viewer.js';
10
10
  import { createKennelClient } from '../core/api-client.js';
11
11
  import { pluginSetKey } from '../plugins/external.js';
12
12
  import { openPreview, previewTerminal } from './terminal.js';
@@ -179,7 +179,7 @@ export async function hotViewer(options, restarting = false) {
179
179
  return hotViewer(options, true);
180
180
  throw new Error(stale);
181
181
  }
182
- const terminal = previewTerminal(viewerUrl, options);
182
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
183
183
  if (options.open)
184
184
  openPreview(viewerUrl);
185
185
  try {
@@ -245,7 +245,7 @@ export async function hotViewer(options, restarting = false) {
245
245
  },
246
246
  stdio: ['ignore', 'inherit', 'inherit'],
247
247
  });
248
- const terminal = previewTerminal(viewerUrl, options);
248
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
249
249
  void terminal.closed.then(() => {
250
250
  if (child.exitCode === null && child.signalCode === null)
251
251
  child.kill('SIGTERM');
@@ -1,8 +1,13 @@
1
1
  export { openPreview } from "kennel-preview/terminal";
2
+ /** Extra list entries rendered as hyperlinked plugin view links. */
3
+ export type PluginView = {
4
+ id: string;
5
+ url: string;
6
+ };
2
7
  export declare function previewTerminal(url: string, target: {
3
8
  root: string;
4
9
  target?: string;
5
- }): {
10
+ }, views: PluginView[]): {
6
11
  close: () => void;
7
12
  closed: Promise<void>;
8
13
  interactive: boolean;
@@ -31,7 +31,7 @@ function repositoryName(folder) {
31
31
  return;
32
32
  }
33
33
  }
34
- function previewTerminal(url, target, startShare) {
34
+ function previewTerminal(url, target, startShare, views) {
35
35
  url = new URL(url).href;
36
36
  const input = process.stdin;
37
37
  const output = process.stdout;
@@ -42,8 +42,9 @@ function previewTerminal(url, target, startShare) {
42
42
  const repository = interactive ? repositoryName(target.root) : undefined;
43
43
  const color = terminal && process.env.NO_COLOR === undefined && output.hasColors();
44
44
  const accent = (text) => color ? `\x1B[32m${text}\x1B[39m` : text;
45
- const link = (label) => terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
46
- ${url}`;
45
+ const linkTo = (label, href, plain = `${label}
46
+ ${href}`) => terminal ? `\x1B]8;;${href}\x07${accent(label)}\x1B]8;;\x07` : plain;
47
+ const link = (label) => linkTo(label, url);
47
48
  const wasRaw = Boolean(input.isRaw);
48
49
  const wasFlowing = input.readableFlowing === true;
49
50
  let resolveClosed;
@@ -60,27 +61,28 @@ function previewTerminal(url, target, startShare) {
60
61
  return;
61
62
  cursorTo(output, 0, 0);
62
63
  clearScreenDown(output);
63
- const width = Math.max(1, (output.columns || 80) - 5);
64
+ const width = Math.max(1, (output.columns || 80) - 1);
64
65
  const fit = (text) => {
65
66
  const characters = Array.from(terminalText(text));
66
67
  return characters.length > width ? characters.slice(0, width - 1).join("") + "…" : characters.join("");
67
68
  };
68
- const count = Math.max(1, Math.min(8, (output.rows || 24) - 12));
69
- const start = Math.max(0, Math.min(selected - Math.floor(count / 2), actions.length - count));
70
- const choices = actions.slice(start, start + count).map((item, index) => {
69
+ const choices = [];
70
+ actions.forEach((item, index) => {
71
71
  const label = fit(item.shortcut ? `[${item.shortcut}] ${item.label}` : item.label);
72
- return start + index === selected ? accent(` › ${label}`) : ` ${label}`;
72
+ let text = index === selected ? accent(`› ${label}`) : ` ${label}`;
73
+ if (item.view !== undefined)
74
+ text = linkTo(text, item.view, accent(text));
75
+ choices.push(text);
73
76
  });
74
77
  output.write([
75
- ` \x1B[1m${link(fit(title))}\x1B[22m`,
76
- ...repository ? [` ${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
77
- ...target.target ? [` File: ${fit(name)}`] : [],
78
+ `\x1B[1m${link(fit(title))}\x1B[22m`,
79
+ ...repository ? [`${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
80
+ ...target.target ? [`File: ${fit(name)}`] : [],
78
81
  "",
79
82
  ...choices,
80
83
  "",
81
- " ↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
82
- ...actions.length > count ? [` ${selected + 1} / ${actions.length}`] : [],
83
- ...status ? ["", ` ${status}`] : [],
84
+ "↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
85
+ ...status ? ["", status] : [],
84
86
  ""
85
87
  ].join(`
86
88
  `));
@@ -145,6 +147,10 @@ function previewTerminal(url, target, startShare) {
145
147
  resolveClosed();
146
148
  };
147
149
  const actions = [
150
+ ...(views ?? []).map((view) => ({ label: view.id, run: () => {
151
+ openPreview(view.url);
152
+ status = "Browser open requested.";
153
+ }, view: view.url })),
148
154
  { label: "Open in browser", shortcut: "o", run: () => {
149
155
  openPreview(url);
150
156
  status = "Browser open requested.";
@@ -204,17 +210,17 @@ function previewTerminal(url, target, startShare) {
204
210
  render();
205
211
  else
206
212
  console.log(`
207
- ${link(name)}
213
+ ${link(name)}
208
214
  `);
209
215
  return { close, closed, interactive };
210
216
  }
211
217
 
212
218
  // packages/cli/src/site/terminal.ts
213
- function previewTerminal2(url, target) {
219
+ function previewTerminal2(url, target, views) {
214
220
  return previewTerminal(url, target, async (password = false) => {
215
221
  const { startPreviewShare } = await import(new URL("../package/preview.js", import.meta.url).href);
216
222
  return startPreviewShare({ ...target, ...password ? { password: true } : {} });
217
- });
223
+ }, views);
218
224
  }
219
225
  export {
220
226
  openPreview,
@@ -1,3 +1,6 @@
1
+ import { type PluginView } from "./terminal.js";
2
+ /** Installed-plugin view links for the terminal menu; each is hyperlinked. */
3
+ export declare function pluginViewLinks(viewerUrl: string, root: string): PluginView[];
1
4
  export type ViewerOptions = {
2
5
  dryRun: boolean;
3
6
  host: string;
@@ -2,8 +2,25 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, realpathSync, statSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { readExternalPlugins } from "../plugins/external.js";
5
6
  import { previewKind } from "./kinds.js";
6
7
  import { openPreview, previewTerminal } from "./terminal.js";
8
+ /** Installed-plugin view links for the terminal menu; each is hyperlinked. */
9
+ export function pluginViewLinks(viewerUrl, root) {
10
+ return readExternalPlugins().flatMap((plugin) => {
11
+ const view = (subpath) => {
12
+ const url = new URL(viewerUrl);
13
+ url.pathname = `/folder/${encodeBase64Url(root)}/views/${encodeURIComponent(plugin.id)}${subpath ? `/${encodeURIComponent(subpath)}` : ""}`;
14
+ // A query `folder` makes the route parser ignore the path and select Notes.
15
+ url.searchParams.delete("folder");
16
+ return url.href;
17
+ };
18
+ const views = [{ id: plugin.id, url: view() }];
19
+ for (const item of plugin.views ?? [])
20
+ views.push({ id: `${plugin.id} · ${item.label}`, url: view(item.subpath) });
21
+ return views;
22
+ });
23
+ }
7
24
  export function viewerFolder(root, value) {
8
25
  return resolve(value ?? root);
9
26
  }
@@ -312,7 +329,7 @@ export async function viewer(input, plugin) {
312
329
  existing.kernelKey === plugins.key &&
313
330
  isSameOrDescendant(existing.root, options.root)))) {
314
331
  const viewerUrl = popUrl(displayHost, options.port, options);
315
- const terminal = previewTerminal(viewerUrl, options);
332
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
316
333
  if (options.open)
317
334
  openPreview(viewerUrl);
318
335
  try {
@@ -342,7 +359,7 @@ export async function viewer(input, plugin) {
342
359
  execFileSync("tailscale", ["serve", "--bg", "--yes", options.port]);
343
360
  }
344
361
  const viewerUrl = popUrl(displayHost, options.port, options);
345
- const terminal = previewTerminal(viewerUrl, options);
362
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
346
363
  if (options.open)
347
364
  openPreview(viewerUrl);
348
365
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kennel",
3
- "version": "0.2.41",
3
+ "version": "0.2.42",
4
4
  "description": "Open-source pop-up previews for AI output: markdown, HTML artifacts, email, images, code, tables, notes, and calendars, from the terminal or your agent session.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -100,6 +100,20 @@
100
100
  "pattern": "^(?!\\.{1,2}$)[^/\\\\]+$"
101
101
  },
102
102
  "skill": { "$ref": "#/$defs/pluginSkill" },
103
+ "views": {
104
+ "description": "Undirected views declared by the plugin manifest, recorded at install time.",
105
+ "type": "array",
106
+ "uniqueItems": true,
107
+ "items": {
108
+ "type": "object",
109
+ "additionalProperties": false,
110
+ "required": ["subpath", "label"],
111
+ "properties": {
112
+ "subpath": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
113
+ "label": { "type": "string", "minLength": 1 }
114
+ }
115
+ }
116
+ },
103
117
  "entries": {
104
118
  "description": "Resolved absolute convention entry files. api is optional.",
105
119
  "oneOf": [