@zenera/cli 1.1.10 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +49 -15
  2. package/dist/cache.d.ts +98 -0
  3. package/dist/cache.js +301 -0
  4. package/dist/catalog.d.ts +3 -0
  5. package/dist/catalog.js +35 -11
  6. package/dist/commands/cache.d.ts +7 -0
  7. package/dist/commands/cache.js +245 -0
  8. package/dist/commands/check.js +6 -3
  9. package/dist/commands/index.js +2 -0
  10. package/dist/commands/key.js +68 -14
  11. package/dist/commands/models.js +17 -1
  12. package/dist/commands/run.js +11 -3
  13. package/dist/commands/sandbox.js +70 -23
  14. package/dist/home.d.ts +2 -2
  15. package/dist/home.js +2 -2
  16. package/dist/keys.d.ts +22 -0
  17. package/dist/keys.js +105 -2
  18. package/dist/lib.d.ts +1 -0
  19. package/dist/lib.js +1 -0
  20. package/dist/liveness.js +11 -0
  21. package/dist/resolve.d.ts +4 -0
  22. package/dist/resolve.js +43 -17
  23. package/dist/term.d.ts +23 -2
  24. package/dist/term.js +215 -8
  25. package/dist/tui/app.d.ts +10 -0
  26. package/dist/tui/app.js +470 -54
  27. package/dist/tui/theme.d.ts +6 -2
  28. package/dist/tui/theme.js +14 -8
  29. package/dist/tui/wrap.d.ts +92 -0
  30. package/dist/tui/wrap.js +147 -2
  31. package/dist/validate.d.ts +2 -0
  32. package/dist/validate.js +87 -2
  33. package/package.json +2 -2
  34. package/templates/editor/.github/copilot-instructions.md +50 -13
  35. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  36. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +4 -0
  37. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  38. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  39. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  40. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +2 -2
  41. package/templates/editor/.vscode/settings.json +1 -1
@@ -2,8 +2,7 @@ import { readProjectConfig } from '@zenera/neo';
2
2
  import { parse } from "../args.js";
3
3
  import { resolveBuild } from "../image.js";
4
4
  import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
5
- import { dirSize, isProjectDir, Registry, sessionIds } from "../projects.js";
6
- import { project as findProject } from "../resolve.js";
5
+ import { current as currentProject, dirSize, isProjectDir, open as openProject, Registry, sessionIds, } from "../projects.js";
7
6
  import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
8
7
  const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
9
8
  /** Enough to see the pattern; the rest are a number. */
@@ -29,9 +28,13 @@ export const sandbox = {
29
28
  ' clean Remove every container this CLI created.',
30
29
  ' disk What the engine and every known project occupy.',
31
30
  '',
32
- ' --project <name|dir> Which project the image comes from.',
31
+ ' --project <name|dir> Which project the image and containers belong to.',
33
32
  ' --image <ref> Use this image instead of the project\u2019s.',
34
33
  '',
34
+ 'Without --project this is the project you are standing in, and standing',
35
+ 'nowhere is an answer: `status` then reports the engine and every container',
36
+ 'on the machine, since that is all there is to say.',
37
+ '',
35
38
  'None of this is required. A run does all of it on its own, the first',
36
39
  'time an agent that can reach a shell is about to start one.',
37
40
  ],
@@ -47,18 +50,21 @@ export const sandbox = {
47
50
  if (positionals.length > 1) {
48
51
  throw usageError('one subcommand at a time', USAGE);
49
52
  }
50
- // `clean` and `disk` are machine-wide questions, so asking which
51
- // project they mean would be asking something they do not use.
53
+ // `clean` and `disk` are machine-wide questions, so looking for a
54
+ // project they do not use would be reading something for nothing.
52
55
  const scoped = what === 'status' || what === 'up' || what === 'pull';
53
- const found = values.image || !scoped ? undefined : await projectSandbox(ctx.cwd, values);
56
+ const found = scoped ? await projectSandbox(ctx.cwd, values) : undefined;
54
57
  const image = values.image ?? found?.image;
55
- const build = found?.build;
58
+ const build = values.image ? undefined : found?.build;
56
59
  switch (what) {
57
60
  case 'status':
58
- return status(image, build, ctx.json);
61
+ return status(found, image, ctx.json);
59
62
  case 'up':
60
63
  return up(image, build, ctx.json, ctx.json);
61
64
  case 'pull':
65
+ if (!image) {
66
+ throw usageError('no project here, so there is no image to pull', 'name one with --project, or an image with --image');
67
+ }
62
68
  return up(image, build, true, ctx.json, true);
63
69
  case 'clean':
64
70
  return clean(ctx.json);
@@ -68,27 +74,51 @@ export const sandbox = {
68
74
  },
69
75
  };
70
76
  /**
71
- * The project's image, when there is a project to ask. `zen sandbox status`
72
- * run from anywhere at all is still a useful thing, so failing to find one is
73
- * not a failure it just means there is no image to report on. Notably this
74
- * does *not* go through `target`: reading a setting must not create a session.
77
+ * Which project this is about: the flag, or the directory the command was run
78
+ * in. It deliberately never asks. `zen sandbox status` run from anywhere at
79
+ * all is a useful thing, so standing outside every project is an answer the
80
+ * engine is still there to report on and a question with a list of every
81
+ * project on the machine is not one a *reading* has any business asking.
82
+ *
83
+ * Notably it also does not go through `target`: reading a setting must not
84
+ * create a session.
75
85
  */
76
86
  async function projectSandbox(cwd, values) {
87
+ const found = values.project ? await openProject(values.project) : await currentProject(cwd);
88
+ if (!found) {
89
+ return undefined;
90
+ }
77
91
  try {
78
- const found = await findProject({ cwd, project: values.project, yes: true });
79
92
  const { root, config } = readProjectConfig(found.dir);
80
93
  const build = resolveBuild(root, config.sandbox);
81
- return { image: build?.tag ?? config.sandbox?.image, build };
94
+ return {
95
+ dir: found.dir,
96
+ name: found.name,
97
+ image: build?.tag ?? config.sandbox?.image,
98
+ build,
99
+ };
82
100
  }
83
101
  catch {
84
- return undefined;
102
+ // A project whose configuration does not read is `zen check`'s to
103
+ // report; here it only means there is no image to name.
104
+ return { dir: found.dir, name: found.name };
85
105
  }
86
106
  }
87
- async function status(image, build, asJson) {
107
+ async function status(project, image, asJson) {
108
+ const build = project?.build;
88
109
  const found = await podmanStatus({ image });
89
- const containers = found.ready ? await ownedContainers(found.engine) : [];
110
+ const all = found.ready ? await ownedContainers(found.engine) : [];
111
+ // A container belongs to a session, and a session belongs to a project, so
112
+ // a report about one project must not list another's — or the faker's,
113
+ // which wears the same label and belongs to no project at all.
114
+ const containers = project ? ofProject(project.dir, all) : all;
90
115
  if (asJson) {
91
- json({ ...found, dockerfile: build?.dockerfile ?? null, containers });
116
+ json({
117
+ ...found,
118
+ project: project?.name ?? null,
119
+ dockerfile: build?.dockerfile ?? null,
120
+ containers,
121
+ });
92
122
  return;
93
123
  }
94
124
  const mark = (ok) => (ok ? green('ok') : red('no'));
@@ -98,18 +128,31 @@ async function status(image, build, asJson) {
98
128
  write(`${bold('machine')} ${found.machine.name} ${state}`);
99
129
  }
100
130
  write(`${bold('responds')} ${mark(found.ready)}`);
131
+ if (project) {
132
+ write(`${bold('project')} ${project.name} ${dim(project.dir)}`);
133
+ }
101
134
  if (found.image) {
102
135
  write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
103
136
  }
104
137
  if (build) {
105
138
  write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
106
139
  }
107
- writeAll(containerLines(containers));
140
+ writeAll(containerLines(containers, project?.name));
108
141
  if (!found.installed || !found.ready) {
109
142
  note('');
110
143
  note(dim('run `zen sandbox up` to fix what can be fixed.'));
111
144
  }
112
145
  }
146
+ /**
147
+ * The containers this project's sessions made. A container carries the session
148
+ * id that made it in a label, and a session id is a directory name under the
149
+ * project — the same attribution `disk` does for every project at once, so the
150
+ * two cannot disagree.
151
+ */
152
+ function ofProject(dir, containers) {
153
+ const sessions = new Set(sessionIds(dir));
154
+ return containers.filter((c) => c.key !== undefined && sessions.has(c.key));
155
+ }
113
156
  /**
114
157
  * One per line rather than one long line, because there is normally more than
115
158
  * one and the interesting part — how old, and whether anything is still up —
@@ -117,11 +160,15 @@ async function status(image, build, asJson) {
117
160
  *
118
161
  * The trailing note is there because the count surprises people: a container
119
162
  * is per *session*, not per project, and `persist: true` is what leaves the
120
- * stopped ones behind.
163
+ * stopped ones behind. `scope` names the project these belong to, and saying
164
+ * so is half the answer — the other half is that there are more elsewhere.
121
165
  */
122
- function containerLines(containers) {
166
+ function containerLines(containers, scope) {
167
+ const tail = scope
168
+ ? `one per session in ${scope}, kept by \`persist: true\` — all of them: zen sandbox disk`
169
+ : 'one per session, kept by `persist: true` — see: zen sandbox disk';
123
170
  if (containers.length === 0) {
124
- return [`${bold('containers')} ${dim('none')}`];
171
+ return [`${bold('containers')} ${dim(scope ? `none in ${scope}` : 'none')}`];
125
172
  }
126
173
  const running = containers.filter((c) => c.state === 'running').length;
127
174
  const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
@@ -138,7 +185,7 @@ function containerLines(containers) {
138
185
  head,
139
186
  ...table(rows),
140
187
  ...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
141
- `${INDENT}${dim('one per session, kept by `persist: true` — see: zen sandbox disk')}`,
188
+ `${INDENT}${dim(tail)}`,
142
189
  ];
143
190
  }
144
191
  async function up(image, build, yes, asJson, rebuild = false) {
package/dist/home.d.ts CHANGED
@@ -5,8 +5,8 @@ export declare const paths: {
5
5
  keys: () => string;
6
6
  keyDir: () => string;
7
7
  faker: () => string;
8
- /** cached model listings, one file per provider — public data, not secrets */
9
- catalog: () => string;
8
+ /** work already done and worth keeping: vectors, parses, model listings */
9
+ cache: () => string;
10
10
  };
11
11
  /** Creates a directory owner-only, and leaves an existing one's mode alone. */
12
12
  export declare function ensureDir(dir: string, mode?: number): string;
package/dist/home.js CHANGED
@@ -26,8 +26,8 @@ export const paths = {
26
26
  keys: () => join(home(), 'keys.json'),
27
27
  keyDir: () => join(home(), 'keys'),
28
28
  faker: () => join(home(), 'faker'),
29
- /** cached model listings, one file per provider — public data, not secrets */
30
- catalog: () => join(home(), 'catalog'),
29
+ /** work already done and worth keeping: vectors, parses, model listings */
30
+ cache: () => join(home(), 'cache'),
31
31
  };
32
32
  /** Creates a directory owner-only, and leaves an existing one's mode alone. */
33
33
  export function ensureDir(dir, mode = DIR_MODE) {
package/dist/keys.d.ts CHANGED
@@ -103,6 +103,17 @@ export declare function parseRef(ref: string): {
103
103
  provider: KeyOwner;
104
104
  name?: string;
105
105
  };
106
+ /** Which of a provider's forms a raw value is, decided by the value itself. */
107
+ export declare function formOf(provider: KeyOwner, raw: string): CredentialForm;
108
+ /**
109
+ * Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
110
+ * or `us-central` with its digit missing — because those are 404s at the first
111
+ * call, a long way from the command that caused them. Returns false for a
112
+ * well-formed name that is merely unrecognised, which the caller reports and
113
+ * stores anyway: Google adds regions, and a list compiled here would be wrong
114
+ * before it was old.
115
+ */
116
+ export declare function checkRegion(region: string): boolean;
106
117
  /** What a credential cannot say about itself. */
107
118
  export interface KeyMeta {
108
119
  /** GCP project id, for a Vertex service account */
@@ -144,6 +155,17 @@ export declare class KeyStore {
144
155
  fileOf(entry: KeyEntry): string;
145
156
  /** The plaintext an entry stands for — the only way out of the store. */
146
157
  reveal(entry: KeyEntry): string;
158
+ /**
159
+ * Which GCP project a Vertex entry addresses, and how that was decided —
160
+ * the same order the library resolves it in, so what is shown is what will
161
+ * be called. A service-account file states its own project, and nothing was
162
+ * ever stored for it, so an entry with no `project` is not an entry with no
163
+ * project: it is one whose project is only knowable by reading the file.
164
+ */
165
+ projectOf(entry: KeyEntry): {
166
+ id: string;
167
+ from: 'env' | 'stored' | 'file';
168
+ } | undefined;
147
169
  /**
148
170
  * What the library would see. Real environment variables win, so CI,
149
171
  * `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
package/dist/keys.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EXA_API_KEY_ENV } from '@zenera/neo';
2
- import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
2
+ import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
@@ -169,7 +169,7 @@ export function parseRef(ref) {
169
169
  return { provider, name };
170
170
  }
171
171
  /** Which of a provider's forms a raw value is, decided by the value itself. */
172
- function formOf(provider, raw) {
172
+ export function formOf(provider, raw) {
173
173
  const { forms } = SHAPES[provider];
174
174
  if (forms.length === 1) {
175
175
  return forms[0];
@@ -177,6 +177,71 @@ function formOf(provider, raw) {
177
177
  const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
178
178
  return forms.find((f) => f.holds === wanted) ?? forms[0];
179
179
  }
180
+ const REGION = /^[a-z]+-[a-z]+[0-9]+$/;
181
+ const REGIONS = new Set([
182
+ 'africa-south1',
183
+ 'asia-east1',
184
+ 'asia-east2',
185
+ 'asia-northeast1',
186
+ 'asia-northeast2',
187
+ 'asia-northeast3',
188
+ 'asia-south1',
189
+ 'asia-south2',
190
+ 'asia-southeast1',
191
+ 'asia-southeast2',
192
+ 'asia-southeast3',
193
+ 'australia-southeast1',
194
+ 'australia-southeast2',
195
+ 'europe-central2',
196
+ 'europe-north1',
197
+ 'europe-southwest1',
198
+ 'europe-west1',
199
+ 'europe-west2',
200
+ 'europe-west3',
201
+ 'europe-west4',
202
+ 'europe-west6',
203
+ 'europe-west8',
204
+ 'europe-west9',
205
+ 'europe-west10',
206
+ 'europe-west12',
207
+ 'me-central1',
208
+ 'me-central2',
209
+ 'me-west1',
210
+ 'northamerica-northeast1',
211
+ 'northamerica-northeast2',
212
+ 'northamerica-south1',
213
+ 'southamerica-east1',
214
+ 'southamerica-west1',
215
+ 'us-central1',
216
+ 'us-east1',
217
+ 'us-east4',
218
+ 'us-east5',
219
+ 'us-south1',
220
+ 'us-west1',
221
+ 'us-west2',
222
+ 'us-west3',
223
+ 'us-west4',
224
+ ]);
225
+ /**
226
+ * Endpoints that route across regions rather than naming one. `us` and `eu` keep
227
+ * processing inside their territory; `global` does not promise that, and takes
228
+ * whatever capacity is free.
229
+ */
230
+ const ENDPOINTS = new Set(['global', 'us', 'eu']);
231
+ /**
232
+ * Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
233
+ * or `us-central` with its digit missing — because those are 404s at the first
234
+ * call, a long way from the command that caused them. Returns false for a
235
+ * well-formed name that is merely unrecognised, which the caller reports and
236
+ * stores anyway: Google adds regions, and a list compiled here would be wrong
237
+ * before it was old.
238
+ */
239
+ export function checkRegion(region) {
240
+ if (!ENDPOINTS.has(region) && !REGION.test(region)) {
241
+ throw usageError(`"${region}" is not a region`, 'give a region such as us-central1, or the endpoints global, us or eu');
242
+ }
243
+ return ENDPOINTS.has(region) || REGIONS.has(region);
244
+ }
180
245
  // ---------------------------------------------------------------------------
181
246
  // Store
182
247
  // ---------------------------------------------------------------------------
@@ -300,6 +365,30 @@ export class KeyStore {
300
365
  reveal(entry) {
301
366
  return entry.holds === 'file' ? this.fileOf(entry) : entry.value;
302
367
  }
368
+ /**
369
+ * Which GCP project a Vertex entry addresses, and how that was decided —
370
+ * the same order the library resolves it in, so what is shown is what will
371
+ * be called. A service-account file states its own project, and nothing was
372
+ * ever stored for it, so an entry with no `project` is not an entry with no
373
+ * project: it is one whose project is only knowable by reading the file.
374
+ */
375
+ projectOf(entry) {
376
+ if (entry.provider !== 'vertex') {
377
+ return undefined;
378
+ }
379
+ const fromEnv = process.env.GOOGLE_CLOUD_PROJECT;
380
+ if (fromEnv) {
381
+ return { id: fromEnv, from: 'env' };
382
+ }
383
+ if (entry.project) {
384
+ return { id: entry.project, from: 'stored' };
385
+ }
386
+ if (entry.holds !== 'file') {
387
+ return undefined;
388
+ }
389
+ const id = projectIdOf(this.fileOf(entry));
390
+ return id ? { id, from: 'file' } : undefined;
391
+ }
303
392
  /**
304
393
  * What the library would see. Real environment variables win, so CI,
305
394
  * `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
@@ -350,6 +439,20 @@ export class KeyStore {
350
439
  return target;
351
440
  }
352
441
  }
442
+ /**
443
+ * `project_id` out of a service-account file. Express-mode keys, gcloud user
444
+ * ADC and metadata credentials carry none, so finding nothing is an answer.
445
+ */
446
+ function projectIdOf(path) {
447
+ try {
448
+ const key = JSON.parse(readFileSync(path, 'utf8'));
449
+ const id = key.project_id;
450
+ return typeof id === 'string' && id.trim() ? id.trim() : undefined;
451
+ }
452
+ catch {
453
+ return undefined;
454
+ }
455
+ }
353
456
  // ---------------------------------------------------------------------------
354
457
  // Display
355
458
  // ---------------------------------------------------------------------------
package/dist/lib.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { extract, invokedAs, one, parse, split, type Parsed, type Split } from './args.ts';
2
2
  export { printBanner, type BannerText } from './banner.ts';
3
+ export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, type CacheEntry, type CacheItem, type CacheKind, type CacheOptions, type CacheStore, type Swept, } from './cache.ts';
3
4
  export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, type Catalog, type CatalogEntry, type CatalogOptions, type Filters, type Role, } from './catalog.ts';
4
5
  export type { Command, Context } from './command.ts';
5
6
  export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
package/dist/lib.js CHANGED
@@ -23,6 +23,7 @@
23
23
  // ---------------------------------------------------------------------------
24
24
  export { extract, invokedAs, one, parse, split } from "./args.js";
25
25
  export { printBanner } from "./banner.js";
26
+ export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, } from "./cache.js";
26
27
  export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, } from "./catalog.js";
27
28
  export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
28
29
  export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
package/dist/liveness.js CHANGED
@@ -107,6 +107,17 @@ export function classify(err) {
107
107
  if (UNREACHED.some((needle) => haystack.includes(needle))) {
108
108
  return { state: 'unknown', at, detail: 'could not reach the provider' };
109
109
  }
110
+ // A json api that answers in html was never reached: the hostname was
111
+ // wrong. Vertex builds its hostname out of the location, so a misspelt
112
+ // region lands here — as `<!DOCTYPE html>`, which explains nothing.
113
+ if (haystack.includes('<!doctype html') || haystack.includes('<html')) {
114
+ return {
115
+ state: 'unknown',
116
+ at,
117
+ detail: 'a web page came back, not the api — the endpoint does not exist',
118
+ fix: 'check the location: vertex builds its hostname from it',
119
+ };
120
+ }
110
121
  // 429 means the credential authenticated and then got rate limited, which
111
122
  // is a live key having a bad day.
112
123
  if (status === 429) {
package/dist/resolve.d.ts CHANGED
@@ -13,6 +13,10 @@ export interface Target {
13
13
  session: SessionPaths;
14
14
  /** true when this call created the session */
15
15
  created: boolean;
16
+ /** absolute path the agent's file tools are rooted at */
17
+ workspace: string;
18
+ /** true when that directory is the session's own, made by this call */
19
+ freshWorkspace: boolean;
16
20
  }
17
21
  export declare function project(want: Wanted): Promise<Projects.Project>;
18
22
  export declare function target(want: Wanted): Promise<Target>;
package/dist/resolve.js CHANGED
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs';
2
2
  import { relative, resolve, sep } from 'node:path';
3
3
  import { stamp } from "./ids.js";
4
4
  import * as Projects from "./projects.js";
5
- import { createSession, listSessions, requireSession, sessionPaths, } from "./session.js";
6
- import { ago, choose, confirm, dim, isInteractive, usageError, warn, yellow } from "./term.js";
5
+ import { createSession, listSessions, readSessionMeta, requireSession, sessionPaths, } from "./session.js";
6
+ import { ago, bold, choose, confirm, cyan, dim, isInteractive, usageError, warn, yellow, } from "./term.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Project
9
9
  // ---------------------------------------------------------------------------
@@ -36,44 +36,70 @@ export async function target(want) {
36
36
  throw usageError('--session and --new contradict each other');
37
37
  }
38
38
  if (want.session) {
39
- return {
40
- project: found,
41
- session: requireSession(dir, want.session),
42
- created: false,
43
- };
39
+ return await resumed(found, requireSession(dir, want.session));
44
40
  }
45
41
  if (!want.fresh) {
46
42
  const existing = await pickExisting(dir);
47
43
  if (existing) {
48
- return { project: found, session: existing, created: false };
44
+ return await resumed(found, existing);
49
45
  }
50
46
  }
51
- return { project: found, session: await create(dir, want), created: true };
47
+ const made = await create(dir, want);
48
+ return {
49
+ project: found,
50
+ session: made.session,
51
+ created: true,
52
+ workspace: made.workspace,
53
+ freshWorkspace: contains(made.session.dir, made.workspace),
54
+ };
52
55
  }
56
+ /** A session that already exists brings its own workspace; it is never re-asked. */
57
+ async function resumed(project, session) {
58
+ const meta = await readSessionMeta(session);
59
+ return {
60
+ project,
61
+ session,
62
+ created: false,
63
+ workspace: resolve(meta.workspace),
64
+ freshWorkspace: false,
65
+ };
66
+ }
67
+ /**
68
+ * Past this many, you are looking for a session by name rather than scrolling
69
+ * to it — and `--session <id>` is how you say a name.
70
+ */
71
+ const MAX_SESSIONS = 25;
53
72
  /**
54
- * Resuming is the default: the common case is continuing what you were doing.
55
- * With one session it is taken without asking; with several, the others are
56
- * offered, because "the most recent" is only usually what you meant.
73
+ * Starting fresh is first, and it is the default: `zen run` followed by Enter
74
+ * should be a new conversation in a new directory, because that is what the
75
+ * command is asked for most often and the only answer that cannot go wrong.
76
+ * Everything already here is offered under it, newest first.
57
77
  *
58
78
  * A session that has never run has nothing to continue — it is indistinguishable
59
79
  * from a fresh one, so it is left out rather than offered as a choice.
60
80
  */
61
81
  async function pickExisting(projectDir) {
62
- const sessions = (await listSessions(projectDir)).filter((s) => s.runs > 0 || s.busy);
63
- if (sessions.length === 0) {
82
+ const all = (await listSessions(projectDir)).filter((s) => s.runs > 0 || s.busy);
83
+ if (all.length === 0) {
64
84
  return undefined;
65
85
  }
66
86
  if (!isInteractive()) {
67
- return sessionPaths(projectDir, sessions[0].id);
87
+ return sessionPaths(projectDir, all[0].id);
68
88
  }
89
+ const sessions = all.slice(0, MAX_SESSIONS);
69
90
  const choice = await choose('Session', [
91
+ {
92
+ key: '0',
93
+ label: bold(cyan('New session…')),
94
+ detail: dim('a fresh conversation, in a directory of its own'),
95
+ value: undefined,
96
+ },
70
97
  ...sessions.map((s) => ({
71
98
  label: s.title ?? s.id,
72
99
  detail: `${s.id} ${ago(s.lastRunAt ?? s.createdAt)} ` +
73
100
  `${s.runs} run${s.runs === 1 ? '' : 's'}${s.busy ? yellow(' running') : ''}`,
74
101
  value: s.id,
75
102
  })),
76
- { key: '0', label: dim('New session…'), value: undefined },
77
103
  ]);
78
104
  return choice ? sessionPaths(projectDir, choice) : undefined;
79
105
  }
@@ -86,7 +112,7 @@ async function create(projectDir, want) {
86
112
  const id = stamp();
87
113
  const planned = sessionPaths(projectDir, id);
88
114
  const workspace = await chooseWorkspace(planned, want);
89
- return createSession(projectDir, id, workspace);
115
+ return { session: createSession(projectDir, id, workspace), workspace };
90
116
  }
91
117
  // ---------------------------------------------------------------------------
92
118
  // Workspace
package/dist/term.d.ts CHANGED
@@ -62,8 +62,29 @@ export interface Choice<T> {
62
62
  /** Listed and picked by this instead of its position, e.g. `0` for an escape hatch. */
63
63
  key?: string;
64
64
  }
65
- /** A numbered list. The pretty picker is the TUI's; this is the fallback. */
66
- export declare function choose<T>(title: string, choices: readonly Choice<T>[]): Promise<T>;
65
+ export interface ChooseOptions {
66
+ /**
67
+ * The choice Enter takes, as an index. It is marked `*`, because a default
68
+ * nobody can see is a default nobody uses.
69
+ */
70
+ initial?: number;
71
+ /** How many rows stay on screen at once; anything beyond them scrolls. */
72
+ window?: number;
73
+ }
74
+ /**
75
+ * A list, arrow-driven where the terminal allows it and numbered where it does
76
+ * not. Both forms agree on the two things that matter: every row has a stable
77
+ * number you can type, and one of them is the default Enter takes.
78
+ */
79
+ export declare function choose<T>(title: string, choices: readonly Choice<T>[], options?: ChooseOptions): Promise<T>;
80
+ /** A chunk of raw input, split into one string per keystroke. */
81
+ export declare function keysIn(data: string): Generator<string>;
82
+ /**
83
+ * Cut to a visible width, carrying the style codes over. Styling is invisible
84
+ * to the terminal's column count but not to `String.length`, so a naive slice
85
+ * either cuts too early or leaves a colour turned on.
86
+ */
87
+ export declare function cut(s: string, max: number): string;
67
88
  /** Piped input, or undefined when stdin is a terminal (i.e. nobody piped). */
68
89
  export declare function readStdin(): Promise<string | undefined>;
69
90
  export declare function ago(iso: string | undefined): string;