@dotdrelle/wiki-manager 0.15.50 → 0.15.53

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.
@@ -16,7 +16,7 @@ const SYMBOLS = {
16
16
  skipped: '–',
17
17
  };
18
18
 
19
- const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
19
+ export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
20
20
 
21
21
  // Objectives are whole paragraphs; a chain view needs a line. Keep the first
22
22
  // sentence, drop the parameter block the compiler appends, and never cut a word
@@ -39,19 +39,24 @@ export function projectSkillChains(controlQueue = []) {
39
39
  byChain.get(item.chainId).push(item);
40
40
  }
41
41
  return [...byChain.entries()].map(([chainId, chainItems]) => {
42
- const steps = chainItems
43
- .slice()
44
- .sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0))
45
- .map((item) => ({
46
- id: item.id,
47
- sequence: Number(item.chainSequence ?? 0),
48
- label: chainStepLabel(item.input),
49
- status: String(item.status ?? 'queued'),
50
- symbol: SYMBOLS[String(item.status ?? 'queued')] ?? '○',
51
- optional: item.optional === true,
52
- ...(item.skipReason ? { skipReason: item.skipReason } : {}),
53
- ...(item.runId ? { runId: item.runId } : {}),
54
- }));
42
+ const sorted = chainItems.slice().sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0));
43
+ const total = sorted.length;
44
+ // Compiled objectives are private execution material and never reach the
45
+ // projection, so a step cannot be labelled with its intent prose. When the
46
+ // chain has several steps, the public input is identical for all of them —
47
+ // labelling them all "/skill args" is what read as six tasks with the same
48
+ // name. Distinguish them by position instead; the skill name already sits
49
+ // in the chain head.
50
+ const steps = sorted.map((item, index) => ({
51
+ id: item.id,
52
+ sequence: Number(item.chainSequence ?? 0),
53
+ label: total > 1 ? `Step ${index + 1}/${total}` : chainStepLabel(item.input),
54
+ status: String(item.status ?? 'queued'),
55
+ symbol: SYMBOLS[String(item.status ?? 'queued')] ?? '○',
56
+ optional: item.optional === true,
57
+ ...(item.skipReason ? { skipReason: item.skipReason } : {}),
58
+ ...(item.runId ? { runId: item.runId } : {}),
59
+ }));
55
60
  return {
56
61
  chainId,
57
62
  skillName: chainItems.find((item) => item.skillName)?.skillName ?? null,
@@ -19,7 +19,7 @@ test('a chain reads as ordered steps with one short label each', () => {
19
19
  assert.equal(chain.selectionKind, 'description_match');
20
20
  assert.equal(chain.status, 'running');
21
21
  assert.deepEqual(chain.steps.map((step) => step.symbol), ['✓', '●']);
22
- assert.equal(chain.steps[0].label, 'Export the requested Confluence source, or all…');
22
+ assert.equal(chain.steps[0].label, 'Step 1/2');
23
23
  assert.equal(chain.steps[1].runId, 'run-b');
24
24
  });
25
25
 
@@ -40,7 +40,7 @@ test('after a cancel the chain shows the cancelled step and the skipped remainde
40
40
  assert.equal(chain.status, 'cancelled');
41
41
  assert.equal(
42
42
  renderSkillChain(chain),
43
- ['wiki-sync', '', '✓ Export source', ' done', '× Ingest files', ' cancelled', '– Publish results', ' skipped · chain_cancelled'].join('\n'),
43
+ ['wiki-sync', '', '✓ Step 1/3', ' done', '× Step 2/3', ' cancelled', '– Step 3/3', ' skipped · chain_cancelled'].join('\n'),
44
44
  );
45
45
  });
46
46
 
@@ -9,7 +9,7 @@ import { formatSkillsForAgent, inspectSkills } from './skills.js';
9
9
  test('matchSkillInvocation resolves only a real workspace skill', () => {
10
10
  const root = mkdtempSync(join(tmpdir(), 'skill-invocation-'));
11
11
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
12
- writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
12
+ writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver.');
13
13
  const match = matchSkillInvocation({ workspacePath: root }, '/deliver "architecture" "improve security"');
14
14
  assert.equal(match.skill.name, 'deliver');
15
15
  assert.equal(match.rawArgs, '"architecture" "improve security"');
@@ -18,7 +18,7 @@ test('matchSkillInvocation resolves only a real workspace skill', () => {
18
18
 
19
19
  test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
20
20
  assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
21
- assert.deepEqual(parseSkillArguments({ params: ['template', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { template: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
21
+ assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { deliverable: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
22
22
  });
23
23
 
24
24
  test('legacy placeholders remain supported and are reported', () => {
@@ -66,8 +66,8 @@ test('catalog renders declared parameters and marks a missing description explic
66
66
  const root = mkdtempSync(join(tmpdir(), 'skill-catalog-'));
67
67
  const dir = join(root, '.wiki', 'skills');
68
68
  mkdirSync(dir, { recursive: true });
69
- writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
69
+ writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver.');
70
70
  const inspection = inspectSkills({ workspacePath: root });
71
71
  assert.deepEqual(inspection.warnings.map((item) => item.reason), ['missing_description']);
72
- assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<template> <polish>\]: workflow skill \[explicit name only\]/);
72
+ assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<deliverable> <polish>\]: workflow skill \[explicit name only\]/);
73
73
  });
@@ -14,6 +14,7 @@ import { assertValidatedFragment } from '../orchestrator/planValidator.js';
14
14
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
15
15
  import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
16
16
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
17
+ import { shortLogId } from '../core/runtimeLog.js';
17
18
 
18
19
  // 0 by default: automatic replans turn evaluator/replanner TEXT into
19
20
  // executable pseudo-tasks (no capability, no operation) that stall at 0%
@@ -713,7 +714,7 @@ export function skipImpossibleTasks(session, runId, { maxPasses = 50 } = {}) {
713
714
  taskId: skippedId,
714
715
  payload: { taskId: skippedId, status: 'skipped', reason: `dependency_failed:${because}` },
715
716
  }));
716
- emitRuntimeLog(session, `scheduler: skipping ${skippedId} (dependency failed: ${because})`);
717
+ emitRuntimeLog(session, `scheduler: skipping ${shortLogId(skippedId)} (dependency failed: ${because.split(', ').map((dep) => shortLogId(dep)).join(', ')})`);
717
718
  }
718
719
  total += changed;
719
720
  if (changed === 0) break;
@@ -1723,7 +1723,7 @@ test('POST /turn keeps informational skill and build questions conversational',
1723
1723
  test('POST /run accepts named skill arguments and deduplicates an explicit retry key', async (t) => {
1724
1724
  const root = mkdtempSync(join(tmpdir(), 'runtime-named-skill-'));
1725
1725
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
1726
- writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver the output.');
1726
+ writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver the output.');
1727
1727
  const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
1728
1728
  const context = { workspace: 'acme', session, running: false, currentAbortController: null };
1729
1729
  const persisted = new Map();
@@ -1746,7 +1746,7 @@ test('POST /run accepts named skill arguments and deduplicates an explicit retry
1746
1746
  try {
1747
1747
  const request = () => fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
1748
1748
  method: 'POST', headers: { 'content-type': 'application/json' },
1749
- body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: { template: 'Quarterly report' }, idempotencyKey: 'retry-1' }),
1749
+ body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: { deliverable: 'Quarterly report' }, idempotencyKey: 'retry-1' }),
1750
1750
  });
1751
1751
  const first = await (await request()).json();
1752
1752
  const second = await (await request()).json();
@@ -1756,7 +1756,7 @@ test('POST /run accepts named skill arguments and deduplicates an explicit retry
1756
1756
  assert.equal(session.controlQueue.length, 1);
1757
1757
  assert.equal('input' in first.items[0], false);
1758
1758
  assert.equal('objectives' in first, false);
1759
- assert.equal(session.controlQueue[0].input, '/deliver template="Quarterly report"');
1759
+ assert.equal(session.controlQueue[0].input, '/deliver deliverable="Quarterly report"');
1760
1760
  } finally {
1761
1761
  context.currentAbortController?.abort();
1762
1762
  await handle.close();
@@ -2,24 +2,24 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { formatPublicSkillInvocation, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
4
4
 
5
- const skill = { name: 'deliver', params: ['template', 'polish'], body: 'Deliver the requested output.' };
5
+ const skill = { name: 'deliver', params: ['deliverable', 'polish'], body: 'Deliver the requested output.' };
6
6
 
7
7
  test('named skill arguments preserve spaces and fill omitted declarations with empty strings', () => {
8
- const args = validateNamedSkillArguments(skill, { template: 'Quarterly report' });
8
+ const args = validateNamedSkillArguments(skill, { deliverable: 'Quarterly report' });
9
9
  assert.equal(Object.getPrototypeOf(args), null);
10
- assert.deepEqual({ ...args }, { template: 'Quarterly report', polish: '' });
10
+ assert.deepEqual({ ...args }, { deliverable: 'Quarterly report', polish: '' });
11
11
  });
12
12
 
13
13
  test('named skill arguments reject undeclared, non-string, and oversized values', () => {
14
14
  assert.throws(() => validateNamedSkillArguments(skill, { target: 'x' }), { code: 'skill_arguments_invalid' });
15
- assert.throws(() => validateNamedSkillArguments(skill, { template: 42 }), { code: 'skill_arguments_invalid' });
16
- assert.throws(() => validateNamedSkillArguments(skill, { template: 'x'.repeat(2_001) }), { code: 'skill_arguments_invalid' });
15
+ assert.throws(() => validateNamedSkillArguments(skill, { deliverable: 42 }), { code: 'skill_arguments_invalid' });
16
+ assert.throws(() => validateNamedSkillArguments(skill, { deliverable: 'x'.repeat(2_001) }), { code: 'skill_arguments_invalid' });
17
17
  });
18
18
 
19
19
  test('runSkillChain enqueues named arguments without exposing a skill body field', async () => {
20
20
  const queued = [];
21
21
  const result = await runSkillChain({ session: {} }, skill, {
22
- args: { template: 'Quarterly report' },
22
+ args: { deliverable: 'Quarterly report' },
23
23
  enqueueControlRequest(_context, input, metadata) {
24
24
  const item = { id: `item-${queued.length}`, input, status: 'queued', ...metadata };
25
25
  queued.push(item);
@@ -28,15 +28,15 @@ test('runSkillChain enqueues named arguments without exposing a skill body field
28
28
  drainControlQueue() {},
29
29
  });
30
30
  assert.equal(result.objectives, 1);
31
- assert.match(queued[0].input, /template: Quarterly report/);
32
- assert.equal(queued[0].publicInput, '/deliver template="Quarterly report"');
31
+ assert.match(queued[0].input, /deliverable: Quarterly report/);
32
+ assert.equal(queued[0].publicInput, '/deliver deliverable="Quarterly report"');
33
33
  assert.equal(queued[0].skillExecution, 'orchestrated');
34
34
  assert.equal('body' in queued[0], false);
35
35
  });
36
36
 
37
37
  test('public skill invocation contains arguments but never compiled objective prose', () => {
38
- const rendered = formatPublicSkillInvocation('deliver', { template: 'Quarterly report', polish: '' });
39
- assert.equal(rendered, '/deliver template="Quarterly report"');
38
+ const rendered = formatPublicSkillInvocation('deliver', { deliverable: 'Quarterly report', polish: '' });
39
+ assert.equal(rendered, '/deliver deliverable="Quarterly report"');
40
40
  assert.doesNotMatch(rendered, /Deliver the requested output/);
41
41
  });
42
42
 
@@ -5,6 +5,7 @@ import { useRenderer } from '@opentui/solid';
5
5
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
6
6
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
7
7
  import { normalizeExternalUrl } from './openExternal.js';
8
+ import { ActivityPanel } from './RightPane';
8
9
 
9
10
  const LEGACY_DONNA_ROLE = 'do' + 't';
10
11
 
@@ -806,6 +807,7 @@ export function LeftPane(props: {
806
807
  busy: boolean;
807
808
  chatMode: boolean;
808
809
  chatFocused: boolean;
810
+ activities: any[];
809
811
  setInput: (value: string) => void;
810
812
  submit: (value?: string) => void;
811
813
  conversationRows: number;
@@ -851,6 +853,15 @@ export function LeftPane(props: {
851
853
  onOpenLink={props.onOpenLink}
852
854
  />
853
855
  )}
856
+ {/*
857
+ Activity strip. The run status used to live only in the right pane,
858
+ one glance away from where the reader types. A compact 4-line strip
859
+ above the composer keeps the current job in view while composing; the
860
+ right pane keeps the full Plan/Queue/Logs detail.
861
+ */}
862
+ <box flexShrink={0} height={4} flexDirection="column" overflow="hidden">
863
+ <ActivityPanel activities={props.activities} width={props.width - 2} />
864
+ </box>
854
865
  <ChatInput
855
866
  width={props.width}
856
867
  prompt={props.prompt}
@@ -9,6 +9,7 @@ type QueueItem = {
9
9
  workspace?: string | null;
10
10
  status: string;
11
11
  args?: Record<string, any>;
12
+ label?: string;
12
13
  jobId?: string;
13
14
  error?: string;
14
15
  reason?: string;
@@ -170,9 +171,10 @@ function activityJobName(activity: any) {
170
171
  }
171
172
 
172
173
  function queueSummary(item: QueueItem) {
174
+ const label = item.label ? String(item.label).trim() : '';
173
175
  const args = item.args ?? {};
174
176
  const parts = [
175
- args.type ?? 'production',
177
+ label || args.type || 'production',
176
178
  Array.isArray(args.steps) && args.steps.length ? args.steps.join('+') : null,
177
179
  Array.isArray(args.templates) && args.templates.length ? `tpl:${args.templates.length}` : null,
178
180
  Array.isArray(args.deliverables) && args.deliverables.length ? `del:${args.deliverables.length}` : null,
@@ -180,10 +182,24 @@ function queueSummary(item: QueueItem) {
180
182
  return parts.join(' ');
181
183
  }
182
184
 
185
+ // A queue id is a long UUID (optionally `control-`-prefixed). The panel shows a
186
+ // short, recognisable prefix instead of the full string, which pushed the
187
+ // status and summary off the line and made the queue unreadable.
188
+ function shortQueueId(id: string) {
189
+ const value = String(id ?? '').replace(/^control-/, '');
190
+ return value.length > 12 ? `${value.slice(0, 12)}…` : value;
191
+ }
192
+
183
193
  export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string; summary?: string | null; spinnerFrame?: string }) {
184
194
  // Keep one column for the native vertical scrollbar when the plan is long.
185
195
  const lineWidth = () => Math.max(8, props.width - 3);
186
196
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
197
+ // A running step carries a thick left border, which costs one column: the
198
+ // wrap width is therefore one less for it. The same function feeds both the
199
+ // row-count memo and the render, so the viewport height can never undercount
200
+ // a running step's wrapped lines and clip the last one.
201
+ const isRunningStep = (step: PlanStep) => String(step.status ?? '').toLowerCase() === 'running';
202
+ const stepTextWidth = (step: PlanStep) => lineWidth() - (isRunningStep(step) ? 1 : 0);
187
203
  const icon = (rawStatus: string) => {
188
204
  const status = String(rawStatus ?? '').toLowerCase();
189
205
  if (DONE_STATUSES.includes(status)) return '[✓]';
@@ -195,7 +211,7 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
195
211
  return status === 'running' ? `[${props.spinnerFrame ?? '…'}]` : '[ ]';
196
212
  };
197
213
  const visualRows = createMemo(() => props.plan.reduce((total, step) =>
198
- total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
214
+ total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, stepTextWidth(step)).slice(0, 2).length, 0));
199
215
  const title = () => {
200
216
  const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
201
217
  return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
@@ -225,12 +241,14 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
225
241
  {(step) => {
226
242
  // Wrap step descriptions over up to 2 lines instead of truncating —
227
243
  // "Ingest des 39 documents raw/untrac…" hid the actual target.
228
- const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
244
+ const running = () => isRunningStep(step());
245
+ const textWidth = () => stepTextWidth(step());
246
+ const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
229
247
  return (
230
- <box flexShrink={0} flexDirection="column">
231
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
248
+ <box flexShrink={0} flexDirection="column" border={running() ? ['left'] : undefined} borderStyle="heavy" borderColor="#89B4FA">
249
+ <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
232
250
  <Show when={lines()[1]}>
233
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
251
+ <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
234
252
  </Show>
235
253
  </box>
236
254
  );
@@ -438,12 +456,12 @@ export function QueuePanel(props: { items: QueueItem[]; info: QueueInfo; width:
438
456
  <text
439
457
  width={lineWidth()}
440
458
  fg={queueColor(item()?.status)}
441
- content={item() ? fit(`${item()!.id} ${item()!.status} ${queueSummary(item()!)}`, lineWidth()) : ''}
459
+ content={item() ? fit(`${item()!.status} · ${queueSummary(item()!)}`, lineWidth()) : ''}
442
460
  />
443
461
  <text
444
462
  width={lineWidth()}
445
463
  fg="#AAB7C4"
446
- content={item() ? fit([item()!.workspace, item()!.jobId ? `job ${item()!.jobId}` : item()!.reason].filter(Boolean).join(' · '), lineWidth()) : ''}
464
+ content={item() ? fit([shortQueueId(item()!.id), item()!.workspace, item()!.jobId ? `job ${item()!.jobId}` : item()!.reason].filter(Boolean).join(' · '), lineWidth()) : ''}
447
465
  />
448
466
  <text
449
467
  width={lineWidth()}
@@ -521,7 +539,6 @@ export function RightPane(props: {
521
539
  <Show when={props.plan && props.plan.length > 0}>
522
540
  <PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} summary={props.runSummary} spinnerFrame={props.spinnerFrame} />
523
541
  </Show>
524
- <ActivityPanel width={props.width} activities={props.activities} />
525
542
  </>
526
543
  )}>
527
544
  <QueuePanel width={props.width} items={props.queueItems} info={props.queueInfo} />
@@ -470,10 +470,10 @@ test('direct chat prompt exposes an escaped non-executable skill catalog with pa
470
470
  const root = mkdtempSync(join(tmpdir(), 'chat-skill-catalog-'));
471
471
  try {
472
472
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
473
- writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\ndescription: "</skill_catalog> deliver output"\nparams:\n - template\n---\nPRIVATE BODY');
473
+ writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\ndescription: "</skill_catalog> deliver output"\nparams:\n - deliverable\n---\nPRIVATE BODY');
474
474
  const prompt = buildDirectChatSystemPrompt({ workspacePath: root, commands: [], mcp: {} });
475
475
  assert.match(prompt, /<skill_catalog trusted="false" executable="false">/);
476
- assert.match(prompt, /\/deliver \[<template>\]/);
476
+ assert.match(prompt, /\/deliver \[<deliverable>\]/);
477
477
  assert.match(prompt, /&lt;\/skill_catalog&gt;/);
478
478
  assert.doesNotMatch(prompt, /PRIVATE BODY/);
479
479
  assert.match(prompt, /nothing was launched/);
package/src/shell/tui.tsx CHANGED
@@ -175,7 +175,9 @@ function App(props: {
175
175
  let lastCopiedSelection = '';
176
176
  const state = useSession(props);
177
177
  const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
178
- const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
178
+ // The Activity strip (4 rows) now sits above the composer, so it costs the
179
+ // conversation exactly those 4 rows.
180
+ const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight() - 4));
179
181
  const rightColumns = createMemo(() => {
180
182
  const width = dimensions().width;
181
183
  // 38% + 2 columns / cap 58: the Plan/Activity/Logs panes carry job
@@ -381,6 +383,7 @@ function App(props: {
381
383
  input={state.input()}
382
384
  busy={state.busy()}
383
385
  chatMode={state.chatMode()}
386
+ activities={state.activities()}
384
387
  // The composer must lose focus for EVERY modal, not just the file
385
388
  // editor. While the setup wizard was open the input stayed focused
386
389
  // underneath it, so answering a wizard question also typed into the