flowviant 0.72.0 → 0.73.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/bin/cli.mjs CHANGED
@@ -244,7 +244,7 @@ if (process.argv[2] === 'env') {
244
244
  // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
245
245
  // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
246
246
  // stopped process runs no timers). See tty.mjs.
247
- const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
247
+ const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
248
248
  const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
249
249
 
250
250
  /** How long the one-time binding confirm waits before serving unbound. A person
@@ -285,6 +285,8 @@ if (!FLEET_TOKEN) {
285
285
  }
286
286
  if (CREDENTIAL.choices?.length && interactive) {
287
287
  const creds = await import('./lib/credentials.mjs');
288
+ const { originSlug } = await import('./lib/git.mjs');
289
+ const { basename } = await import('node:path');
288
290
  const { choices, repoRoot } = CREDENTIAL;
289
291
  console.log(
290
292
  CREDENTIAL.reason === 'outside-repo'
@@ -293,27 +295,78 @@ if (!FLEET_TOKEN) {
293
295
  ? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
294
296
  : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
295
297
  );
296
- console.log(listLines(choices, creds));
297
- console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
298
+
299
+ const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
300
+ // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
301
+ // refuses to serve a project the repo PATH did not name (the skadooble law);
302
+ // this only decides which row the cursor starts on, using the repo's folder
303
+ // name and its github repo-name against the stored project names. A unique
304
+ // match becomes ONE keypress; a wrong guess costs nothing, because the human
305
+ // still confirms. `multiple-bound` gets no hint — every choice already names
306
+ // this repo, so nothing distinguishes them.
307
+ const slug = repoRoot ? originSlug(repoRoot) : null;
308
+ const likely =
309
+ CREDENTIAL.reason === 'multiple-bound'
310
+ ? -1
311
+ : creds.likelyChoiceIndex(choices, {
312
+ repoBasename: repoRoot ? basename(repoRoot) : null,
313
+ repoSlugName: slug ? slug.split('/')[1] : null,
314
+ });
315
+
298
316
  // Bounded like the confirm below, and for the same reason — but silence
299
317
  // means something DIFFERENT here and the difference is load-bearing. There
300
318
  // is a real ambiguity to resolve; serving a guess is the skadooble bug.
301
319
  // So no answer REFUSES, which is exactly what this branch already does
302
320
  // headless, and the message says how to answer without being present.
303
- const raw = await askWithTimeout(
304
- `Which project should this daemon serve? [1-${choices.length + 1}] `,
305
- PICK_TIMEOUT_MS
306
- );
307
- if (raw === null) {
308
- console.error(
309
- `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s nothing started. ` +
310
- `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
321
+ let chosen = null; // 0-based into [...choices, login]
322
+ if (menuSupported()) {
323
+ const rowLabel = (e) =>
324
+ creds.projectLabel(e) + (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
325
+ const options = [...choices.map(rowLabel), loginLabel];
326
+ // Say WHY the cursor starts where it does — "intuitive" made visible.
327
+ if (likely >= 0) options[likely] += ' ← looks like this repo';
328
+ const res = await selectMenu({
329
+ options,
330
+ defaultIndex: likely >= 0 ? likely : 0,
331
+ timeoutMs: PICK_TIMEOUT_MS,
332
+ });
333
+ if (res.timedOut) {
334
+ console.error(
335
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
336
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
337
+ );
338
+ process.exit(1);
339
+ }
340
+ if (res.cancelled) {
341
+ console.error('nothing chosen — nothing started.');
342
+ process.exit(1);
343
+ }
344
+ if (!res.unsupported) chosen = res.index;
345
+ }
346
+ if (chosen === null) {
347
+ // Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
348
+ // takes the likely default when there is one, so it is one keystroke here
349
+ // too.
350
+ console.log(listLines(choices, creds));
351
+ console.log(` ${choices.length + 1}. ${loginLabel}`);
352
+ const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
353
+ const raw = await askWithTimeout(
354
+ `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `,
355
+ PICK_TIMEOUT_MS
311
356
  );
312
- process.exit(1);
357
+ if (raw === null) {
358
+ console.error(
359
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
360
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
361
+ );
362
+ process.exit(1);
363
+ }
364
+ const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
365
+ chosen = Number.isInteger(n) ? n - 1 : -1;
313
366
  }
314
- const n = Number.parseInt(raw, 10);
315
- if (n === choices.length + 1) await reexecAfterLogin();
316
- const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
367
+
368
+ if (chosen === choices.length) await reexecAfterLogin();
369
+ const picked = chosen >= 0 ? choices[chosen] : undefined;
317
370
  if (!picked) {
318
371
  console.error('nothing chosen — nothing started.');
319
372
  process.exit(1);
@@ -136,6 +136,40 @@ export function projectLabel(e) {
136
136
  return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
137
137
  }
138
138
 
139
+ /** Collapse a name or slug for loose comparison: "My Project", "my-project"
140
+ * and "myproject" all become "myproject". */
141
+ function normalizeName(s) {
142
+ return String(s ?? '')
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]/g, '');
145
+ }
146
+
147
+ /**
148
+ * WHICH stored project most likely goes with this repo — a HINT for the
149
+ * picker's default, and DELIBERATELY not a licence to serve it. `resolveStored
150
+ * Credential` matches by repo PATH and refuses to guess past that, because
151
+ * serving a project the path did not name is the "it said skadooble in my
152
+ * calendar repo" surprise this whole file exists to prevent. A name that
153
+ * matches the repo's folder or its github repo-name is softer evidence — good
154
+ * enough to put the cursor on that row so the answer is one keypress, never
155
+ * good enough to skip the human. Two names can collide (a project "api" and a
156
+ * repo "api"), which is exactly why this only pre-selects.
157
+ *
158
+ * Returns the index of a UNIQUE match, or -1 when nothing matches or more than
159
+ * one does — an ambiguous hint is not a hint, and a default that is as likely
160
+ * wrong as right is worse than starting at the top.
161
+ */
162
+ export function likelyChoiceIndex(choices, { repoBasename, repoSlugName } = {}) {
163
+ const wants = new Set([normalizeName(repoBasename), normalizeName(repoSlugName)].filter(Boolean));
164
+ if (wants.size === 0) return -1;
165
+ const hits = [];
166
+ choices.forEach((e, i) => {
167
+ const n = normalizeName(e?.name);
168
+ if (n && wants.has(n)) hits.push(i);
169
+ });
170
+ return hits.length === 1 ? hits[0] : -1;
171
+ }
172
+
139
173
  function mutate(fn) {
140
174
  const f = readFile() ?? {};
141
175
  if (!f.projects || typeof f.projects !== 'object') f.projects = {};
package/bin/lib/tty.mjs CHANGED
@@ -109,3 +109,126 @@ export async function askWithTimeout(query, timeoutMs) {
109
109
  process.off('SIGTTOU', noop);
110
110
  }
111
111
  }
112
+
113
+ /**
114
+ * Can we draw an arrow-navigable menu? Raw mode is what turns ↑/↓ into
115
+ * keystrokes we receive one at a time; without `setRawMode` (a pipe, a
116
+ * terminal that refuses raw input) there is nothing to drive, and the caller
117
+ * falls back to the numeric prompt rather than drawing a menu nobody can move.
118
+ * `canPrompt()` still gates whether we ask AT ALL — this only decides HOW.
119
+ */
120
+ export function menuSupported() {
121
+ return Boolean(
122
+ process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function'
123
+ );
124
+ }
125
+
126
+ /**
127
+ * The menu's key handling, PURE so it can be tested without a terminal. Given a
128
+ * keypress and the current cursor, returns exactly one intent:
129
+ * { index } move the highlight (arrows wrap; k/j vim-style; g/G ends)
130
+ * { choose } take a row (Enter takes the highlight; 1–9 jump-and-take)
131
+ * { cancel } Esc, q, or Ctrl-C — nothing chosen
132
+ * null a key we ignore
133
+ * A number past the end is ignored, not clamped: pressing 9 in a 3-row list
134
+ * must not silently select row 3.
135
+ */
136
+ export function menuKey(key, { index, count }) {
137
+ if (key === '\x03' || key === '\x1b' || key === 'q' || key === 'Q') return { cancel: true };
138
+ if (key === '\r' || key === '\n') return { choose: index };
139
+ if (key === '\x1b[A' || key === 'k') return { index: (index - 1 + count) % count };
140
+ if (key === '\x1b[B' || key === 'j') return { index: (index + 1) % count };
141
+ if (key === '\x1b[H' || key === 'g') return { index: 0 };
142
+ if (key === '\x1b[F' || key === 'G') return { index: count - 1 };
143
+ if (/^[1-9]$/.test(key)) {
144
+ const n = Number(key) - 1;
145
+ return n < count ? { choose: n } : null;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
151
+
152
+ /**
153
+ * An arrow-navigable picker for the start path. Resolves one of:
154
+ * { index } the row taken
155
+ * { cancelled } Esc/q/Ctrl-C
156
+ * { timedOut } nobody drove it within timeoutMs
157
+ * { unsupported } no raw mode — the caller uses the numeric prompt instead
158
+ *
159
+ * Held to this file's one law — no start-path prompt may hang the daemon — the
160
+ * same way `askWithTimeout` is: it keeps the timeout, installs the
161
+ * SIGTTIN/SIGTTOU no-ops that keep that timer alive, restores the terminal in
162
+ * every exit, and refuses (returns `unsupported`) rather than half-drawing when
163
+ * raw mode is not really there. The caller still gates on `canPrompt()` before
164
+ * ever reaching here.
165
+ */
166
+ export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
167
+ if (!menuSupported()) return { unsupported: true };
168
+ const stdin = process.stdin;
169
+ const out = process.stdout;
170
+ const count = options.length;
171
+ if (count === 0) return { unsupported: true };
172
+ let index = Math.min(Math.max(defaultIndex | 0, 0), count - 1);
173
+
174
+ const width = Math.max(24, (out.columns || 80) - 2);
175
+ const clip = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
176
+ const frame = () =>
177
+ options
178
+ .map((o, i) => (i === index ? `\x1b[7m> ${clip(o)}\x1b[0m` : ` ${clip(o)}`))
179
+ .join('\n') +
180
+ '\n' +
181
+ `\x1b[2m ${MENU_FOOTER}\x1b[0m`;
182
+ const lineCount = count + 1; // rows + footer
183
+
184
+ let rendered = false;
185
+ const draw = () => {
186
+ if (rendered) out.write(`\x1b[${lineCount}A`); // back to the top of our block
187
+ out.write('\r\x1b[0J'); // clear from here to the end of the screen
188
+ out.write(frame() + '\n');
189
+ rendered = true;
190
+ };
191
+
192
+ const noop = () => {};
193
+ return await new Promise((resolve) => {
194
+ let done = false;
195
+ const finish = (result) => {
196
+ if (done) return;
197
+ done = true;
198
+ clearTimeout(timer);
199
+ stdin.removeListener('data', onData);
200
+ try {
201
+ stdin.setRawMode(false);
202
+ } catch {
203
+ /* already restored / gone */
204
+ }
205
+ stdin.pause();
206
+ process.off('SIGTTIN', noop);
207
+ process.off('SIGTTOU', noop);
208
+ out.write('\x1b[?25h'); // show the cursor again
209
+ resolve(result);
210
+ };
211
+ const onData = (buf) => {
212
+ const action = menuKey(buf.toString('utf8'), { index, count });
213
+ if (!action) return;
214
+ if (action.cancel) return finish({ cancelled: true });
215
+ if (action.choose !== undefined) return finish({ index: action.choose });
216
+ if (action.index !== undefined) {
217
+ index = action.index;
218
+ draw();
219
+ }
220
+ };
221
+ process.on('SIGTTIN', noop);
222
+ process.on('SIGTTOU', noop);
223
+ const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs);
224
+ try {
225
+ stdin.setRawMode(true);
226
+ } catch {
227
+ return finish({ unsupported: true });
228
+ }
229
+ stdin.resume();
230
+ out.write('\x1b[?25l'); // hide the cursor while we own the block
231
+ draw();
232
+ stdin.on('data', onData);
233
+ });
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {