@dotdrelle/wiki-manager 0.11.4 → 0.11.7

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.
@@ -2,7 +2,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
2
  import { activityKey, sessionActivities, terminalFailures } from '../core/activity.js';
3
3
  import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
4
4
  import { formatPlanStatus } from '../core/plan.js';
5
- import { formatReadyTaskPrompt, readyPlanTasks } from '../core/planPatch.js';
5
+ import { formatReadyTaskPrompt, readyPlanTasks, sanitizePlanForExecution } from '../core/planPatch.js';
6
6
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
7
7
 
8
8
  const DEFAULT_MAX_REPLANS = 2;
@@ -92,6 +92,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
92
92
  let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
93
93
 
94
94
  while (true) {
95
+ sanitizeSessionPlanForExecution(session, runId);
95
96
  const result = shouldUseParallelScheduler(session.headlessPlan)
96
97
  ? await runRuntimeParallelPlan(agent, session, input, {
97
98
  signal,
@@ -134,7 +135,9 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
134
135
  return { ok: false, result };
135
136
  }
136
137
 
137
- const evaluation = await evaluateRuntimeRun(session, input, { runId, signal, evaluate });
138
+ const evaluation = session.headlessPlan
139
+ ? await evaluateRuntimeRun(session, input, { runId, signal, evaluate })
140
+ : null;
138
141
  if (evaluation) {
139
142
  dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
140
143
  origin: 'runtime',
@@ -147,6 +150,19 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
147
150
  },
148
151
  }));
149
152
  if (!evaluation.ok) {
153
+ if (isUndefinedObjectiveEvaluation(evaluation)) {
154
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
155
+ origin: 'runtime',
156
+ runId,
157
+ payload: { content: clarificationMessageForEvaluation(evaluation) },
158
+ }));
159
+ dispatchAgentEvent(session, createAgentEvent('run_done', {
160
+ origin: 'runtime',
161
+ runId,
162
+ payload: { runId },
163
+ }));
164
+ return { ok: true, evaluation, clarified: true };
165
+ }
150
166
  if (replansLeft > 0) {
151
167
  const trigger = {
152
168
  kind: 'evaluation',
@@ -209,6 +225,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
209
225
  previousPlanUpdate?.();
210
226
  abortCancelledActiveTasks(session, active);
211
227
  };
228
+ sanitizeSessionPlanForExecution(session, runId);
212
229
  ensurePlanProjection(session, runId);
213
230
  emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
214
231
 
@@ -264,6 +281,20 @@ export async function runRuntimeParallelPlan(agent, session, input, {
264
281
  }
265
282
  }
266
283
 
284
+ function sanitizeSessionPlanForExecution(session, runId = null) {
285
+ if (!session.headlessPlan) return;
286
+ const sanitized = sanitizePlanForExecution(session.headlessPlan);
287
+ if (sanitized.warnings.length === 0) return;
288
+ session.headlessPlan = sanitized.plan;
289
+ dispatchAgentEvent(session, createAgentEvent('runtime_log', {
290
+ origin: 'runtime',
291
+ runId,
292
+ payload: {
293
+ message: `plan warning: ${sanitized.warnings.join('; ')}`,
294
+ },
295
+ }));
296
+ }
297
+
267
298
  async function drainActive(active, locks) {
268
299
  if (active.size === 0) return;
269
300
  const entries = [...active.values()];
@@ -570,7 +601,9 @@ export async function finishRuntimeRun(session, input, {
570
601
  signal = null,
571
602
  evaluate = true,
572
603
  } = {}) {
573
- const evaluation = await evaluateRuntimeRun(session, input, { runId, signal, evaluate });
604
+ const evaluation = session.headlessPlan
605
+ ? await evaluateRuntimeRun(session, input, { runId, signal, evaluate })
606
+ : null;
574
607
  if (evaluation) {
575
608
  dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
576
609
  origin: 'runtime',
@@ -583,6 +616,19 @@ export async function finishRuntimeRun(session, input, {
583
616
  },
584
617
  }));
585
618
  if (!evaluation.ok) {
619
+ if (isUndefinedObjectiveEvaluation(evaluation)) {
620
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
621
+ origin: 'runtime',
622
+ runId,
623
+ payload: { content: clarificationMessageForEvaluation(evaluation) },
624
+ }));
625
+ dispatchAgentEvent(session, createAgentEvent('run_done', {
626
+ origin: 'runtime',
627
+ runId,
628
+ payload: { runId },
629
+ }));
630
+ return { ok: true, evaluation, clarified: true };
631
+ }
586
632
  dispatchAgentEvent(session, createAgentEvent('run_error', {
587
633
  origin: 'runtime',
588
634
  runId,
@@ -672,6 +718,21 @@ function normalizeEvaluation(value) {
672
718
  };
673
719
  }
674
720
 
721
+ function isUndefinedObjectiveEvaluation(evaluation) {
722
+ const text = `${evaluation?.reason ?? ''} ${evaluation?.suggestedAction ?? ''}`
723
+ .toLowerCase()
724
+ .normalize('NFD')
725
+ .replace(/[\u0300-\u036f]/g, '');
726
+ return /\b(vague|undefined|indefini|unclear|clarif|ambiguous|missing objective|no objective)\b/.test(text);
727
+ }
728
+
729
+ function clarificationMessageForEvaluation(evaluation) {
730
+ const reason = String(evaluation?.reason ?? '').trim();
731
+ return reason
732
+ ? `Je dois clarifier la demande avant d'agir : ${reason}`
733
+ : "Je dois clarifier la demande avant d'agir.";
734
+ }
735
+
675
736
  function fallbackEvaluation(reason) {
676
737
  return {
677
738
  ok: true,
@@ -723,6 +784,10 @@ export async function replanRuntimeRun(session, input, trigger, {
723
784
  steps: mergedSteps,
724
785
  },
725
786
  }));
787
+ if (hasMutatingReplanStep(steps)) {
788
+ session._runApprovalRequired = true;
789
+ session._runApprovalResolved = false;
790
+ }
726
791
  return { ok: true, steps };
727
792
  } catch (err) {
728
793
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
@@ -811,6 +876,10 @@ function normalizeReplan(steps) {
811
876
  .slice(0, 12);
812
877
  }
813
878
 
879
+ function hasMutatingReplanStep(steps) {
880
+ return steps.some((step) => /\b(build|copy|ingest|import|export|polish|pipeline|write|create|delete|update|send|deploy|publish|generate|construire|copier|importer|exporter|publier|envoyer|supprimer|modifier|creer|générer|generer)\b/i.test(step));
881
+ }
882
+
814
883
  function mergeReplanWithCompleted(plan, steps) {
815
884
  const completed = (plan ?? [])
816
885
  .filter((step) => step.status === 'done')
@@ -3,6 +3,41 @@ import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
4
  import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
5
5
 
6
+ test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
7
+ const events = [];
8
+ const session = {
9
+ activities: {},
10
+ headlessPlan: null,
11
+ llm: {
12
+ async completeWithTools() {
13
+ assert.fail('conversation-only turn must not call evaluator or replanner');
14
+ },
15
+ },
16
+ _onAgentEvent: (event) => events.push(event),
17
+ };
18
+ const agent = {
19
+ async invoke() {
20
+ return { response: 'Salut.' };
21
+ },
22
+ };
23
+
24
+ const started = Date.now();
25
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'salut', {
26
+ runId: 'run-chat',
27
+ timeoutMs: 1000,
28
+ maxTurns: 1,
29
+ maxReplans: 1,
30
+ });
31
+
32
+ assert.equal(result.ok, true);
33
+ assert.ok(Date.now() - started < 5000);
34
+ assert.equal(session.headlessPlan, null);
35
+ assert.equal(Object.keys(session.activities).length, 0);
36
+ assert.ok(events.some((event) => event.type === 'run_done'));
37
+ assert.equal(events.some((event) => event.type === 'run_evaluated'), false);
38
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
39
+ });
40
+
6
41
  test('finishRuntimeRun emits evaluation before run_done', async () => {
7
42
  const events = [];
8
43
  const session = {
@@ -33,6 +68,47 @@ test('finishRuntimeRun emits evaluation before run_done', async () => {
33
68
  assert.equal(session.agentProjection.status, 'done');
34
69
  });
35
70
 
71
+ test('runRuntimeAgenticWorkflow clarifies vague evaluations without replan', async () => {
72
+ const events = [];
73
+ const session = {
74
+ activities: {},
75
+ headlessPlan: [{ step: 1, id: 'task', description: 'Task', status: 'done' }],
76
+ llm: {
77
+ async completeWithTools({ system }) {
78
+ assert.match(system, /strict evaluator/);
79
+ return { content: '{"ok":false,"reason":"demande vague / objectif indefini","suggestedAction":"clarifier l objectif"}' };
80
+ },
81
+ },
82
+ _onAgentEvent: (event) => events.push(event),
83
+ };
84
+ const agent = {
85
+ async invoke({ session: turnSession }) {
86
+ if (turnSession.headlessPlan === null) {
87
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
88
+ origin: 'tool',
89
+ payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
90
+ }));
91
+ }
92
+ return { response: 'Terminé.' };
93
+ },
94
+ };
95
+
96
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'fais le truc', {
97
+ runId: 'run-vague',
98
+ timeoutMs: 1000,
99
+ maxTurns: 1,
100
+ maxReplans: 1,
101
+ });
102
+
103
+ assert.equal(result.ok, true);
104
+ assert.equal(result.clarified, true);
105
+ assert.ok(session.agentProjection.conversation.at(-1).content.includes('clarifier'));
106
+ assert.ok(events.some((event) => event.type === 'run_evaluated'));
107
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
108
+ assert.equal(events.some((event) => event.type === 'tool_call_started'), false);
109
+ assert.equal(session.agentProjection.status, 'done');
110
+ });
111
+
36
112
  test('finishRuntimeRun turns negative evaluation into run_error', async () => {
37
113
  const events = [];
38
114
  const session = {
@@ -62,7 +138,7 @@ test('finishRuntimeRun turns negative evaluation into run_error', async () => {
62
138
  test('finishRuntimeRun falls back open when evaluator response is invalid', async () => {
63
139
  const session = {
64
140
  activities: {},
65
- headlessPlan: null,
141
+ headlessPlan: [{ step: 1, description: 'Do work', status: 'done' }],
66
142
  agentProjection: { conversation: [] },
67
143
  llm: {
68
144
  async completeWithTools() {
@@ -126,13 +202,20 @@ test('runRuntimeAgenticWorkflow replans after negative evaluation', async () =>
126
202
  const agent = {
127
203
  async invoke({ session: turnSession }) {
128
204
  turns += 1;
205
+ if (turnSession.headlessPlan === null) {
206
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
207
+ origin: 'tool',
208
+ payload: { steps: [{ step: 1, id: 'initial', description: 'Initial work', status: 'done' }] },
209
+ }));
210
+ return { response: 'Initial done.' };
211
+ }
129
212
  if (turnSession.headlessPlan?.[0]?.status === 'pending') {
130
213
  dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
131
214
  origin: 'tool',
132
215
  payload: { step: 1, status: 'done' },
133
216
  }));
134
217
  }
135
- return { response: turns === 1 ? 'Initial done.' : 'Export done.' };
218
+ return { response: 'Export done.' };
136
219
  },
137
220
  };
138
221
 
@@ -175,6 +258,27 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
175
258
  assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
176
259
  });
177
260
 
261
+ test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
262
+ const session = {
263
+ activities: {},
264
+ headlessPlan: [{ step: 1, id: 'build', description: 'Build', status: 'failed' }],
265
+ llm: {
266
+ async completeWithTools() {
267
+ return { content: '{"steps":["Run production build"]}' };
268
+ },
269
+ },
270
+ };
271
+
272
+ const result = await replanRuntimeRun(session, 'Build deliverable', {
273
+ kind: 'evaluation',
274
+ reason: 'Build missing.',
275
+ }, { runId: 'run-replan-approval', replansLeft: 1 });
276
+
277
+ assert.equal(result.ok, true);
278
+ assert.equal(session._runApprovalRequired, true);
279
+ assert.equal(session._runApprovalResolved, false);
280
+ });
281
+
178
282
  test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
179
283
  const originalFetch = globalThis.fetch;
180
284
  let pollAttempts = 0;
@@ -276,7 +380,13 @@ test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async (
276
380
  },
277
381
  };
278
382
  const agent = {
279
- async invoke() {
383
+ async invoke({ session: turnSession }) {
384
+ if (turnSession.headlessPlan === null) {
385
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
386
+ origin: 'tool',
387
+ payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
388
+ }));
389
+ }
280
390
  return { response: 'Done.' };
281
391
  },
282
392
  };
@@ -18,6 +18,7 @@ export function startRuntimeServer({
18
18
  approve,
19
19
  configProfiles,
20
20
  useConfigProfile,
21
+ exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
21
22
  } = {}) {
22
23
  const clients = new Set();
23
24
  const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
@@ -31,6 +32,13 @@ export function startRuntimeServer({
31
32
  }
32
33
  }
33
34
 
35
+ function publishState(workspace = null, context = null) {
36
+ for (const client of clients) {
37
+ if (client.workspace && client.workspace !== workspace) continue;
38
+ client.response.write(`event: state\ndata: ${JSON.stringify(runtimeState(context, store, { workspace, session }))}\n\n`);
39
+ }
40
+ }
41
+
34
42
  const server = createServer(async (request, response) => {
35
43
  try {
36
44
  if (!isAuthorized(request, token)) {
@@ -223,6 +231,23 @@ export function startRuntimeServer({
223
231
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
224
232
  return;
225
233
  }
234
+ if (request.method === 'POST' && url.pathname === '/shutdown') {
235
+ const workspace = workspaceFromUrl(url);
236
+ const context = await resolveContext({ workspace });
237
+ if (context?.running && context.currentAbortController) {
238
+ context.currentAbortController.abort();
239
+ await cancel?.(context);
240
+ }
241
+ sendJson(response, 202, { shutdown: true });
242
+ setImmediate(() => {
243
+ for (const client of clients) client.response.end();
244
+ clients.clear();
245
+ server.close(() => {
246
+ if (exitOnShutdown) process.exit(0);
247
+ });
248
+ });
249
+ return;
250
+ }
226
251
  if (request.method === 'POST' && url.pathname === '/resume') {
227
252
  const workspace = workspaceFromUrl(url);
228
253
  const result = await resume?.({ workspace });
@@ -307,6 +332,7 @@ export function startRuntimeServer({
307
332
  context.currentAbortController = null;
308
333
  context.currentRunId = null;
309
334
  context.currentRunWorkspace = null;
335
+ publishState(runWorkspace, context);
310
336
  void startNextControlRequest(context);
311
337
  });
312
338
  return { accepted: true, runId, workspace: runWorkspace };
@@ -610,7 +636,7 @@ function classifyControlMessage(input, status, forcedIntent = null) {
610
636
  if (['observe', 'converse', 'mutate', 'enqueue'].includes(intent)) {
611
637
  return { kind: intent, confidence: 1, reason: 'explicit_intent' };
612
638
  }
613
- if (/\b(o[uù] en est|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
639
+ if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
614
640
  return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
615
641
  }
616
642
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
@@ -46,6 +46,44 @@ test('runtime server checks bearer and x-runtime-token credentials', async (t) =
46
46
  }
47
47
  });
48
48
 
49
+ test('runtime server shutdown endpoint closes the listener', async (t) => {
50
+ let handle;
51
+ try {
52
+ handle = await startRuntimeServer({
53
+ host: '127.0.0.1',
54
+ port: 0,
55
+ token: 'runtime-secret',
56
+ exitOnShutdown: false,
57
+ store: {
58
+ dbPath: ':memory:',
59
+ getState: () => ({ status: 'idle' }),
60
+ listEvents: () => [],
61
+ },
62
+ session: {},
63
+ run: async () => {},
64
+ });
65
+ } catch (err) {
66
+ if (err?.code === 'EPERM') {
67
+ t.skip('network listen is not permitted in this sandbox');
68
+ return;
69
+ }
70
+ throw err;
71
+ }
72
+
73
+ const url = `http://127.0.0.1:${handle.port}`;
74
+ const response = await fetch(`${url}/shutdown`, {
75
+ method: 'POST',
76
+ headers: { authorization: 'Bearer runtime-secret' },
77
+ });
78
+ assert.equal(response.status, 202);
79
+ assert.equal((await response.json()).shutdown, true);
80
+
81
+ await new Promise((resolve) => setTimeout(resolve, 20));
82
+ await assert.rejects(fetch(`${url}/health`, {
83
+ headers: { authorization: 'Bearer runtime-secret' },
84
+ }));
85
+ });
86
+
49
87
  test('runtime server accepts only one active run', async (t) => {
50
88
  let releaseRun;
51
89
  let runCount = 0;
@@ -662,6 +662,7 @@ export function LeftPane(props: {
662
662
  }) {
663
663
  const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
664
664
  const modeLabel = () => props.chatMode ? 'CHAT MODE direct LLM, no tools' : 'AGENTIC MODE LangGraph + MCP tools';
665
+ const showWelcome = () => props.showWelcome && props.messages.length === 0;
665
666
  return (
666
667
  <box width={props.width} height="100%" flexDirection="column" padding={1} overflow="hidden">
667
668
  <box height={3} flexDirection="column">
@@ -676,7 +677,7 @@ export function LeftPane(props: {
676
677
  {props.hintLine ? <text fg="#FBBF24">[ {props.hintLine} ]</text> : null}
677
678
  </box>
678
679
  </box>
679
- {props.showWelcome ? (
680
+ {showWelcome() ? (
680
681
  <WelcomeHelpPanels width={props.conversationColumns} />
681
682
  ) : (
682
683
  <ConversationView
@@ -1,6 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { useKeyboard } from '@opentui/solid';
3
- import { createMemo, createSignal, For } from 'solid-js';
2
+ import { createEffect, createMemo, createSignal, For, untrack } from 'solid-js';
4
3
  import { fit } from './textFit';
5
4
 
6
5
  export type StartupAction =
@@ -15,6 +14,15 @@ type StartupItem = {
15
14
  detail: string;
16
15
  };
17
16
 
17
+ const DONNA_LOGO = `
18
+ ██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗
19
+ ██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗
20
+ ██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║
21
+ ██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║
22
+ ██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║
23
+ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝
24
+ `.trim();
25
+
18
26
  function statusDot(ready: boolean) {
19
27
  return ready ? '●' : '○';
20
28
  }
@@ -23,6 +31,58 @@ function initWorkspaceItem(detail: string): StartupItem {
23
31
  return { action: 'init-workspace', label: 'Initialize workspace', detail };
24
32
  }
25
33
 
34
+ function keyNameOf(key: any) {
35
+ return String(key?.name ?? key?.key ?? '').toLowerCase();
36
+ }
37
+
38
+ function keySequenceOf(key: any) {
39
+ return String(key?.sequence ?? key?.raw ?? key?.seq ?? key?.input ?? '');
40
+ }
41
+
42
+ function isEnterKey(key: any) {
43
+ const keyName = keyNameOf(key);
44
+ const sequence = keySequenceOf(key);
45
+ return Boolean(
46
+ key?.return ||
47
+ key?.enter ||
48
+ key?.linefeed ||
49
+ keyName === 'return' ||
50
+ keyName === 'enter' ||
51
+ keyName === 'linefeed' ||
52
+ keyName === 'kpenter' ||
53
+ keyName === 'keypadenter' ||
54
+ keyName === 'numenter' ||
55
+ (key?.ctrl && keyName === 'm') ||
56
+ sequence === '\r' ||
57
+ sequence === '\n' ||
58
+ sequence === '\r\n' ||
59
+ sequence === '\x1bOM' ||
60
+ /^\x1b\[13(?:;\d+)?u$/.test(sequence),
61
+ );
62
+ }
63
+
64
+ function isEscapeKey(key: any) {
65
+ const keyName = keyNameOf(key);
66
+ return keyName === 'escape' || keySequenceOf(key) === '\x1b';
67
+ }
68
+
69
+ function isUpKey(key: any) {
70
+ const keyName = keyNameOf(key);
71
+ const sequence = keySequenceOf(key);
72
+ return keyName === 'up' || keyName === 'arrow_up' || sequence === '\x1b[A' || /^\x1b\[57352(?:;\d+)?u$/.test(sequence);
73
+ }
74
+
75
+ function isDownKey(key: any) {
76
+ const keyName = keyNameOf(key);
77
+ const sequence = keySequenceOf(key);
78
+ return keyName === 'down' || keyName === 'arrow_down' || sequence === '\x1b[B' || /^\x1b\[57353(?:;\d+)?u$/.test(sequence);
79
+ }
80
+
81
+ function consumeKey(key: any) {
82
+ key?.preventDefault?.();
83
+ key?.stopPropagation?.();
84
+ }
85
+
26
86
  export function StartupScreen(props: {
27
87
  version: string;
28
88
  model: string;
@@ -34,13 +94,15 @@ export function StartupScreen(props: {
34
94
  hasWorkspace: boolean;
35
95
  width: number;
36
96
  height: number;
97
+ keyboardEvent?: { id: number; key: any } | null;
37
98
  onSelect: (action: StartupAction, workspaceName?: string) => void;
38
99
  onQuit: () => void;
39
100
  }) {
40
101
  const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
41
102
  const [selected, setSelected] = createSignal(0);
42
- const panelWidth = createMemo(() => Math.max(54, Math.min(86, props.width - 4)));
43
- const panelHeight = createMemo(() => Math.max(24, Math.min(32, props.height - 2)));
103
+ let lastKeyboardEventId = 0;
104
+ const panelWidth = createMemo(() => Math.max(70, Math.min(96, props.width - 4)));
105
+ const panelHeight = createMemo(() => Math.max(27, Math.min(35, props.height - 2)));
44
106
  const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
45
107
  const top = createMemo(() => Math.max(1, Math.floor((props.height - panelHeight()) / 2)));
46
108
 
@@ -74,9 +136,23 @@ export function StartupScreen(props: {
74
136
  const currentLength = createMemo(() => mode() === 'workspace-select' ? workspaceItems().length : items().length);
75
137
 
76
138
  const move = (delta: number) => {
77
- setSelected((value) => (value + delta + currentLength()) % currentLength());
139
+ const length = currentLength();
140
+ if (length <= 0) {
141
+ setSelected(0);
142
+ return;
143
+ }
144
+ setSelected((value) => (value + delta + length) % length);
78
145
  };
79
146
 
147
+ createEffect(() => {
148
+ const length = currentLength();
149
+ if (length <= 0) {
150
+ setSelected(0);
151
+ return;
152
+ }
153
+ if (selected() >= length) setSelected(length - 1);
154
+ });
155
+
80
156
  const choose = (index = selected()) => {
81
157
  if (mode() === 'workspace-select') {
82
158
  const item = workspaceItems()[index];
@@ -93,38 +169,52 @@ export function StartupScreen(props: {
93
169
  props.onSelect(item.action);
94
170
  };
95
171
 
96
- useKeyboard((key: any) => {
97
- const keyName = String(key.name ?? '').toLowerCase();
98
- const sequence = String(key.sequence ?? '');
172
+ const handleKey = (key: any) => {
173
+ const keyName = keyNameOf(key);
174
+ const sequence = keySequenceOf(key);
99
175
  if ((key.ctrl || key.meta) && keyName === 'c') {
176
+ consumeKey(key);
100
177
  props.onQuit();
101
178
  return;
102
179
  }
103
- if (keyName === 'escape' && mode() === 'workspace-select') {
180
+ if (isEscapeKey(key) && mode() === 'workspace-select') {
181
+ consumeKey(key);
104
182
  setMode('home');
105
183
  setSelected(0);
106
184
  return;
107
185
  }
108
- if (keyName === 'escape') {
186
+ if (isEscapeKey(key)) {
187
+ consumeKey(key);
109
188
  props.onQuit();
110
189
  return;
111
190
  }
112
- if (keyName === 'up') {
113
- move(-1);
191
+ if (isEnterKey(key)) {
192
+ consumeKey(key);
193
+ choose();
114
194
  return;
115
195
  }
116
- if (keyName === 'down') {
117
- move(1);
196
+ if (isUpKey(key)) {
197
+ consumeKey(key);
198
+ move(-1);
118
199
  return;
119
200
  }
120
- if (keyName === 'return' || keyName === 'enter') {
121
- choose();
201
+ if (isDownKey(key)) {
202
+ consumeKey(key);
203
+ move(1);
122
204
  return;
123
205
  }
124
206
  const numeric = Number.parseInt(sequence, 10);
125
207
  if (Number.isInteger(numeric) && numeric >= 1 && numeric <= currentLength()) {
208
+ consumeKey(key);
126
209
  choose(numeric - 1);
127
210
  }
211
+ };
212
+
213
+ createEffect(() => {
214
+ const event = props.keyboardEvent;
215
+ if (!event || event.id === lastKeyboardEventId) return;
216
+ lastKeyboardEventId = event.id;
217
+ untrack(() => handleKey(event.key));
128
218
  });
129
219
 
130
220
  const status = createMemo(() => {
@@ -144,7 +234,7 @@ export function StartupScreen(props: {
144
234
  const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
145
235
  return mode() === 'workspace-select'
146
236
  ? `↑/↓ select Enter open ${quick} Esc back`
147
- : `↑/↓ select Enter open ${quick} Esc quit`;
237
+ : `↑/↓ select Enter choose ${quick} Esc quit`;
148
238
  });
149
239
  const menuTitle = createMemo(() => mode() === 'workspace-select' ? 'Select workspace' : '');
150
240
 
@@ -170,12 +260,17 @@ export function StartupScreen(props: {
170
260
  </box>
171
261
  <text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
172
262
  <text height={1}>{''}</text>
173
- <text height={1} fg="#D6DEE8">██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗</text>
174
- <text height={1} fg="#D6DEE8">██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗</text>
175
- <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║</text>
176
- <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║</text>
177
- <text height={1} fg="#D6DEE8">██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║</text>
178
- <text height={1} fg="#D6DEE8">╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝</text>
263
+ <box
264
+ height={8}
265
+ flexDirection="column"
266
+ justifyContent="center"
267
+ alignItems="center"
268
+ gap={1}
269
+ overflow="hidden"
270
+ >
271
+ <text fg="#d6a85f">{DONNA_LOGO}</text>
272
+ <text fg="#888888">Intelligent workspace</text>
273
+ </box>
179
274
  <text height={1}>{''}</text>
180
275
  <text height={1} fg="#7F8C8D" content={menuTitle()} />
181
276
  <box height={Math.max(1, currentLength())} flexDirection="column">