@arbidocs/tui 0.1.2 → 0.3.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/dist/index.cjs CHANGED
@@ -6,8 +6,8 @@ var core = require('@arbidocs/core');
6
6
  var piTui = require('@mariozechner/pi-tui');
7
7
  var chalk = require('chalk');
8
8
  require('fake-indexeddb/auto');
9
- var prompts = require('@inquirer/prompts');
10
9
  var sdk = require('@arbidocs/sdk');
10
+ var prompts = require('@inquirer/prompts');
11
11
 
12
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
@@ -148,6 +148,13 @@ var ChatLog = class extends piTui.Container {
148
148
  this.addChild(new UserMessage(text));
149
149
  this.addChild(new piTui.Spacer(1));
150
150
  }
151
+ /** Add a complete assistant message (non-streaming, e.g. for history restoration). */
152
+ addAssistant(text) {
153
+ const msg = new AssistantMessage();
154
+ msg.setText(text);
155
+ this.addChild(msg);
156
+ this.addChild(new piTui.Spacer(1));
157
+ }
151
158
  /** Add a system/info/error message. */
152
159
  addSystem(message, level = "info") {
153
160
  this.addChild(new SystemMessage(message, level));
@@ -308,10 +315,16 @@ var commands = [
308
315
  { name: "workspace", description: "Switch workspace: /workspace <id>" },
309
316
  { name: "workspaces", description: "List all workspaces" },
310
317
  { name: "contacts", description: "List contacts (type @name to DM)" },
318
+ { name: "invite", description: "Invite a contact: /invite user@example.com" },
311
319
  { name: "docs", description: "List documents in current workspace" },
320
+ { name: "upload", description: "Upload a file: /upload <path>" },
321
+ { name: "delete", description: "Delete a document: /delete <doc-id>" },
312
322
  { name: "conversations", description: "List conversations" },
313
323
  { name: "new", description: "Start fresh conversation (clear threading)" },
324
+ { name: "models", description: "List available AI models" },
325
+ { name: "health", description: "Show system health status" },
314
326
  { name: "status", description: "Show auth/workspace/connection status" },
327
+ { name: "logout", description: "Log out and clear credentials" },
315
328
  { name: "exit", description: "Exit TUI" },
316
329
  { name: "quit", description: "Exit TUI" }
317
330
  ];
@@ -333,78 +346,52 @@ function formatHelpText() {
333
346
  " Escape \u2014 Abort streaming"
334
347
  ].join("\n");
335
348
  }
336
- async function interactiveLogin(store) {
337
- const config = store.requireConfig();
338
- const email = await prompts.input({ message: "Email", validate: (v) => v.trim() ? true : "Required" });
339
- const pw = await prompts.password({
340
- message: "Password",
349
+ async function promptSelect(message, choices) {
350
+ return prompts.select({ message, choices });
351
+ }
352
+ async function promptInput(message, required = true) {
353
+ return prompts.input({
354
+ message,
355
+ validate: required ? (v) => v.trim() ? true : "Required" : void 0
356
+ });
357
+ }
358
+ async function promptPassword(message) {
359
+ return prompts.password({
360
+ message,
341
361
  mask: "*",
342
362
  validate: (v) => v ? true : "Required"
343
363
  });
344
- const arbi = sdk.createArbiClient({
345
- baseUrl: config.baseUrl,
346
- deploymentDomain: config.deploymentDomain,
347
- credentials: "omit"
348
- });
349
- await arbi.crypto.initSodium();
350
- const loginResult = await arbi.auth.login({ email, password: pw });
351
- store.saveCredentials({
352
- email,
353
- signingPrivateKeyBase64: arbi.crypto.bytesToBase64(loginResult.signingPrivateKey),
354
- serverSessionKeyBase64: arbi.crypto.bytesToBase64(loginResult.serverSessionKey)
355
- });
364
+ }
365
+ async function promptConfirm(message, defaultValue = true) {
366
+ return prompts.confirm({ message, default: defaultValue });
367
+ }
368
+
369
+ // src/auth.ts
370
+ async function interactiveLogin(store) {
371
+ const config = store.requireConfig();
372
+ const email = await promptInput("Email");
373
+ const pw = await promptPassword("Password");
374
+ const authContext = await core.performPasswordLogin(config, email, pw, store);
356
375
  console.info(`Logged in as ${email}`);
357
- const { data: workspaces3 } = await arbi.fetch.GET("/api/user/workspaces");
358
- const wsList = workspaces3 || [];
359
- let selectedWorkspaceId;
360
- let selectedWorkspaceName;
361
- if (wsList.length === 1) {
362
- selectedWorkspaceId = wsList[0].external_id;
363
- selectedWorkspaceName = wsList[0].name;
364
- store.updateConfig({ selectedWorkspaceId });
365
- console.info(`Workspace: ${wsList[0].name}`);
366
- } else if (wsList.length > 1) {
367
- const choices = wsList.map((ws2) => {
368
- const totalDocs = ws2.shared_document_count + ws2.private_document_count;
369
- return {
370
- name: `${ws2.name} (${totalDocs} docs)`,
371
- value: ws2.external_id,
372
- description: ws2.external_id
373
- };
374
- });
375
- selectedWorkspaceId = await prompts.select({ message: "Select workspace", choices });
376
- const ws = wsList.find((w) => w.external_id === selectedWorkspaceId);
377
- selectedWorkspaceName = ws?.name;
378
- store.updateConfig({ selectedWorkspaceId });
379
- console.info(`Workspace: ${selectedWorkspaceName}`);
380
- } else {
381
- console.info("No workspaces found.");
382
- }
383
- const authContext = await core.resolveAuth(store);
384
- return { authContext, selectedWorkspaceId, selectedWorkspaceName };
376
+ const result = await selectOrCreateWorkspace(authContext, store);
377
+ return { authContext, ...result };
385
378
  }
386
379
  async function interactiveRegister(store) {
387
380
  const config = store.requireConfig();
388
- const email = await prompts.input({ message: "Email", validate: (v) => v.trim() ? true : "Required" });
381
+ const email = await promptInput("Email");
389
382
  const arbi = sdk.createArbiClient({
390
383
  baseUrl: config.baseUrl,
391
384
  deploymentDomain: config.deploymentDomain,
392
385
  credentials: "omit"
393
386
  });
394
387
  await arbi.crypto.initSodium();
395
- const codeMethod = await prompts.select({
396
- message: "Verification method",
397
- choices: [
398
- { name: "I have an invitation code", value: "code" },
399
- { name: "Send me a verification email", value: "email" }
400
- ]
401
- });
388
+ const codeMethod = await promptSelect("Verification method", [
389
+ { name: "I have an invitation code", value: "code" },
390
+ { name: "Send me a verification email", value: "email" }
391
+ ]);
402
392
  let verificationCode;
403
393
  if (codeMethod === "code") {
404
- verificationCode = await prompts.input({
405
- message: "Invitation code",
406
- validate: (v) => v.trim() ? true : "Required"
407
- });
394
+ verificationCode = await promptInput("Invitation code");
408
395
  } else {
409
396
  console.info("Sending verification email...");
410
397
  const verifyResponse = await arbi.fetch.POST("/api/user/verify-email", {
@@ -414,26 +401,15 @@ async function interactiveRegister(store) {
414
401
  throw new Error(`Failed to send verification email: ${JSON.stringify(verifyResponse.error)}`);
415
402
  }
416
403
  console.info("Verification email sent. Check your inbox.");
417
- verificationCode = await prompts.input({
418
- message: "Verification code",
419
- validate: (v) => v.trim() ? true : "Required"
420
- });
404
+ verificationCode = await promptInput("Verification code");
421
405
  }
422
- const pw = await prompts.password({
423
- message: "Password",
424
- mask: "*",
425
- validate: (v) => v ? true : "Required"
426
- });
427
- const confirmPw = await prompts.password({
428
- message: "Confirm password",
429
- mask: "*",
430
- validate: (v) => v ? true : "Required"
431
- });
406
+ const pw = await promptPassword("Password");
407
+ const confirmPw = await promptPassword("Confirm password");
432
408
  if (pw !== confirmPw) {
433
409
  throw new Error("Passwords do not match.");
434
410
  }
435
- const firstName = await prompts.input({ message: "First name (optional)" }) || "User";
436
- const lastName = await prompts.input({ message: "Last name (optional)" }) || "";
411
+ const firstName = await promptInput("First name (optional)", false) || "User";
412
+ const lastName = await promptInput("Last name (optional)", false) || "";
437
413
  await arbi.auth.register({
438
414
  email,
439
415
  password: pw,
@@ -443,11 +419,37 @@ async function interactiveRegister(store) {
443
419
  });
444
420
  console.info(`
445
421
  Registered successfully as ${email}`);
446
- const doLogin = await prompts.confirm({ message: "Log in now?", default: true });
447
- if (doLogin) {
448
- return interactiveLogin(store);
422
+ console.info("Logging in...");
423
+ const authContext = await core.performPasswordLogin(config, email, pw, store);
424
+ console.info(`Logged in as ${email}`);
425
+ const result = await selectOrCreateWorkspace(authContext, store);
426
+ return { authContext, ...result };
427
+ }
428
+ async function selectOrCreateWorkspace(authContext, store) {
429
+ const wsList = await core.workspaces.listWorkspaces(authContext.arbi);
430
+ if (wsList.length === 1) {
431
+ store.updateConfig({ selectedWorkspaceId: wsList[0].external_id });
432
+ console.info(`Workspace: ${wsList[0].name}`);
433
+ return { selectedWorkspaceId: wsList[0].external_id, selectedWorkspaceName: wsList[0].name };
449
434
  }
450
- return null;
435
+ if (wsList.length > 1) {
436
+ const choices = core.formatWorkspaceChoices(wsList);
437
+ const selectedWorkspaceId = await promptSelect("Select workspace", choices);
438
+ const ws = wsList.find((w) => w.external_id === selectedWorkspaceId);
439
+ store.updateConfig({ selectedWorkspaceId });
440
+ console.info(`Workspace: ${ws?.name}`);
441
+ return { selectedWorkspaceId, selectedWorkspaceName: ws?.name };
442
+ }
443
+ console.info("No workspaces found.");
444
+ const shouldCreate = await promptConfirm("Create a new workspace?");
445
+ if (shouldCreate) {
446
+ const name = await promptInput("Workspace name");
447
+ const ws = await core.workspaces.createWorkspace(authContext.arbi, name.trim());
448
+ store.updateConfig({ selectedWorkspaceId: ws.external_id });
449
+ console.info(`Created workspace: ${ws.name}`);
450
+ return { selectedWorkspaceId: ws.external_id, selectedWorkspaceName: ws.name };
451
+ }
452
+ return {};
451
453
  }
452
454
  async function ensureAuthenticated(store) {
453
455
  try {
@@ -459,26 +461,75 @@ async function ensureAuthenticated(store) {
459
461
  };
460
462
  } catch {
461
463
  console.info("Not authenticated.\n");
462
- const action = await prompts.select({
463
- message: "What would you like to do?",
464
- choices: [
465
- { name: "Log in", value: "login" },
466
- { name: "Register a new account", value: "register" },
467
- { name: "Exit", value: "exit" }
468
- ]
469
- });
464
+ }
465
+ while (true) {
466
+ const action = await promptSelect("What would you like to do?", [
467
+ { name: "Log in", value: "login" },
468
+ { name: "Register a new account", value: "register" },
469
+ { name: "Exit", value: "exit" }
470
+ ]);
470
471
  if (action === "exit") {
471
472
  process.exit(0);
472
473
  }
473
- if (action === "register") {
474
- const result = await interactiveRegister(store);
475
- if (!result) {
476
- console.info("Registration complete. Please restart to log in.");
477
- process.exit(0);
474
+ try {
475
+ if (action === "register") {
476
+ return await interactiveRegister(store);
478
477
  }
479
- return result;
478
+ return await interactiveLogin(store);
479
+ } catch (err) {
480
+ console.error(`
481
+ ${core.getErrorMessage(err)}
482
+ `);
480
483
  }
481
- return interactiveLogin(store);
484
+ }
485
+ }
486
+ function requireAuth(tui, message = "Not authenticated. Use /login first.") {
487
+ if (tui.authContext) return true;
488
+ showMessage(tui, message, "error");
489
+ return false;
490
+ }
491
+ function showMessage(tui, message, level) {
492
+ tui.chatLog.addSystem(message, level);
493
+ tui.requestRender();
494
+ }
495
+ async function switchWorkspace(tui, workspaceId) {
496
+ if (!tui.authContext) return null;
497
+ try {
498
+ const ws = await core.selectWorkspaceById(
499
+ tui.authContext.arbi,
500
+ workspaceId,
501
+ tui.authContext.loginResult.serverSessionKey,
502
+ tui.store.requireCredentials().signingPrivateKeyBase64
503
+ );
504
+ tui.state.workspaceId = ws.external_id;
505
+ tui.state.workspaceName = ws.name;
506
+ tui.state.conversationMessageId = null;
507
+ tui.store.clearChatSession();
508
+ tui.store.updateConfig({ selectedWorkspaceId: ws.external_id });
509
+ await tui.refreshWorkspaceContext();
510
+ return ws;
511
+ } catch (err) {
512
+ showMessage(tui, `Failed to switch workspace: ${core.getErrorMessage(err)}`, "error");
513
+ return null;
514
+ }
515
+ }
516
+ async function applyWorkspaceSelection(tui, workspaceId, workspaceName) {
517
+ if (!workspaceId) return;
518
+ const wsCtx = await core.resolveWorkspace(tui.store, workspaceId);
519
+ tui.setWorkspaceContext(wsCtx);
520
+ tui.state.workspaceName = workspaceName ?? null;
521
+ }
522
+ async function runInteractiveFlow(tui, pauseMessage, action, errorPrefix) {
523
+ showMessage(tui, pauseMessage);
524
+ tui.stopTui();
525
+ try {
526
+ const result = await action();
527
+ tui.restartTui();
528
+ return result;
529
+ } catch (err) {
530
+ tui.restartTui();
531
+ showMessage(tui, `${errorPrefix}: ${core.getErrorMessage(err)}`, "error");
532
+ return null;
482
533
  }
483
534
  }
484
535
 
@@ -491,8 +542,7 @@ async function handleCommand(tui, input2) {
491
542
  const args = parts.slice(1);
492
543
  switch (cmd) {
493
544
  case "help":
494
- tui.chatLog.addSystem(formatHelpText());
495
- tui.requestRender();
545
+ showMessage(tui, formatHelpText());
496
546
  return true;
497
547
  case "login":
498
548
  await handleLogin(tui);
@@ -512,255 +562,208 @@ async function handleCommand(tui, input2) {
512
562
  case "contacts":
513
563
  await handleListContacts(tui);
514
564
  return true;
565
+ case "invite":
566
+ await handleInviteContact(tui, args[0]);
567
+ return true;
515
568
  case "docs":
516
569
  await handleListDocs(tui);
517
570
  return true;
571
+ case "upload":
572
+ await handleUpload(tui, args.join(" "));
573
+ return true;
574
+ case "delete":
575
+ await handleDelete(tui, args[0]);
576
+ return true;
518
577
  case "conversations":
519
578
  await handleListConversations(tui);
520
579
  return true;
521
580
  case "new":
522
581
  handleNewConversation(tui);
523
582
  return true;
583
+ case "models":
584
+ await handleModels(tui);
585
+ return true;
586
+ case "health":
587
+ await handleHealth(tui);
588
+ return true;
524
589
  case "status":
525
590
  handleStatus(tui);
526
591
  return true;
592
+ case "logout":
593
+ handleLogout(tui);
594
+ return true;
527
595
  case "exit":
528
596
  case "quit":
529
597
  tui.shutdown();
530
598
  return true;
531
599
  default:
532
- tui.chatLog.addSystem(
533
- `Unknown command: /${cmd}. Type /help for available commands.`,
534
- "warning"
535
- );
536
- tui.requestRender();
600
+ showMessage(tui, `Unknown command: /${cmd}. Type /help for available commands.`, "warning");
537
601
  return true;
538
602
  }
539
603
  }
540
604
  async function handleLogin(tui) {
541
- tui.chatLog.addSystem("Pausing TUI for login...");
542
- tui.requestRender();
543
- tui.stopTui();
544
- try {
545
- const result = await interactiveLogin(tui.store);
605
+ const result = await runInteractiveFlow(
606
+ tui,
607
+ "Pausing TUI for login...",
608
+ () => interactiveLogin(tui.store),
609
+ "Login failed"
610
+ );
611
+ if (result) {
546
612
  tui.setAuthContext(result.authContext);
547
- if (result.selectedWorkspaceId) {
548
- const wsCtx = await core.resolveWorkspace(tui.store, result.selectedWorkspaceId);
549
- tui.setWorkspaceContext(wsCtx);
550
- tui.state.workspaceName = result.selectedWorkspaceName ?? null;
551
- }
552
- tui.restartTui();
553
- tui.chatLog.addSystem("Logged in successfully.");
554
- tui.requestRender();
555
- } catch (err) {
556
- tui.restartTui();
557
- const msg = err instanceof Error ? err.message : String(err);
558
- tui.chatLog.addSystem(`Login failed: ${msg}`, "error");
559
- tui.requestRender();
613
+ await applyWorkspaceSelection(tui, result.selectedWorkspaceId, result.selectedWorkspaceName);
614
+ showMessage(tui, "Logged in successfully.");
560
615
  }
561
616
  }
562
617
  async function handleRegister(tui) {
563
- tui.chatLog.addSystem("Pausing TUI for registration...");
564
- tui.requestRender();
565
- tui.stopTui();
566
- try {
567
- const result = await interactiveRegister(tui.store);
568
- if (result) {
569
- tui.setAuthContext(result.authContext);
570
- if (result.selectedWorkspaceId) {
571
- const wsCtx = await core.resolveWorkspace(tui.store, result.selectedWorkspaceId);
572
- tui.setWorkspaceContext(wsCtx);
573
- tui.state.workspaceName = result.selectedWorkspaceName ?? null;
574
- }
575
- }
576
- tui.restartTui();
577
- tui.chatLog.addSystem(
578
- result ? "Registered and logged in." : "Registered. Use /login to log in."
579
- );
580
- tui.requestRender();
581
- } catch (err) {
582
- tui.restartTui();
583
- const msg = err instanceof Error ? err.message : String(err);
584
- tui.chatLog.addSystem(`Registration failed: ${msg}`, "error");
585
- tui.requestRender();
618
+ const result = await runInteractiveFlow(
619
+ tui,
620
+ "Pausing TUI for registration...",
621
+ () => interactiveRegister(tui.store),
622
+ "Registration failed"
623
+ );
624
+ if (result) {
625
+ tui.setAuthContext(result.authContext);
626
+ await applyWorkspaceSelection(tui, result.selectedWorkspaceId, result.selectedWorkspaceName);
627
+ showMessage(tui, "Registered and logged in.");
586
628
  }
587
629
  }
588
630
  async function handleCreateWorkspace(tui, name) {
589
- if (!tui.authContext) {
590
- tui.chatLog.addSystem("Not authenticated. Use /login first.", "error");
591
- tui.requestRender();
592
- return;
593
- }
631
+ if (!requireAuth(tui)) return;
594
632
  if (!name.trim()) {
595
- tui.chatLog.addSystem("Usage: /create <workspace name>", "warning");
596
- tui.requestRender();
633
+ showMessage(tui, "Usage: /create <workspace name>", "warning");
597
634
  return;
598
635
  }
599
636
  try {
600
- tui.chatLog.addSystem(`Creating workspace "${name}"...`);
601
- tui.requestRender();
637
+ showMessage(tui, `Creating workspace "${name}"...`);
602
638
  const ws = await core.workspaces.createWorkspace(tui.authContext.arbi, name.trim());
603
- const { selectWorkspaceById } = await import('@arbidocs/core');
604
- const selected = await selectWorkspaceById(
605
- tui.authContext.arbi,
606
- ws.external_id,
607
- tui.authContext.loginResult.serverSessionKey,
608
- tui.store.requireCredentials().signingPrivateKeyBase64
609
- );
610
- tui.state.workspaceId = selected.external_id;
611
- tui.state.workspaceName = selected.name;
612
- tui.state.conversationMessageId = null;
613
- tui.store.updateConfig({ selectedWorkspaceId: selected.external_id });
614
- await tui.refreshWorkspaceContext();
615
- tui.chatLog.addSystem(`Created and switched to workspace: ${colors.accentBold(selected.name)}`);
616
- tui.requestRender();
639
+ const selected = await switchWorkspace(tui, ws.external_id);
640
+ if (selected) {
641
+ showMessage(tui, `Created and switched to workspace: ${colors.accentBold(selected.name)}`);
642
+ }
617
643
  } catch (err) {
618
- const msg = err instanceof Error ? err.message : String(err);
619
- tui.chatLog.addSystem(`Failed to create workspace: ${msg}`, "error");
620
- tui.requestRender();
644
+ showMessage(tui, `Failed to create workspace: ${core.getErrorMessage(err)}`, "error");
621
645
  }
622
646
  }
623
647
  async function handleWorkspaceSwitch(tui, workspaceId) {
624
- if (!tui.authContext) {
625
- tui.chatLog.addSystem("Not authenticated. Please restart and log in.", "error");
626
- tui.requestRender();
627
- return;
628
- }
648
+ if (!requireAuth(tui, "Not authenticated. Please restart and log in.")) return;
629
649
  if (!workspaceId) {
630
650
  await handleListWorkspaces(tui);
631
- tui.chatLog.addSystem("Use /workspace <id> to switch.");
632
- tui.requestRender();
651
+ showMessage(tui, "Use /workspace <id> to switch.");
633
652
  return;
634
653
  }
635
- try {
636
- tui.chatLog.addSystem(`Switching to workspace ${workspaceId}...`);
637
- tui.requestRender();
638
- const { selectWorkspaceById } = await import('@arbidocs/core');
639
- const ws = await selectWorkspaceById(
640
- tui.authContext.arbi,
641
- workspaceId,
642
- tui.authContext.loginResult.serverSessionKey,
643
- tui.store.requireCredentials().signingPrivateKeyBase64
644
- );
645
- tui.state.workspaceId = ws.external_id;
646
- tui.state.workspaceName = ws.name;
647
- tui.state.conversationMessageId = null;
648
- tui.store.updateConfig({ selectedWorkspaceId: ws.external_id });
649
- await tui.refreshWorkspaceContext();
650
- tui.chatLog.addSystem(`Switched to workspace: ${colors.accentBold(ws.name)}`);
651
- tui.requestRender();
652
- } catch (err) {
653
- const msg = err instanceof Error ? err.message : String(err);
654
- tui.chatLog.addSystem(`Failed to switch workspace: ${msg}`, "error");
655
- tui.requestRender();
654
+ showMessage(tui, `Switching to workspace ${workspaceId}...`);
655
+ const ws = await switchWorkspace(tui, workspaceId);
656
+ if (ws) {
657
+ showMessage(tui, `Switched to workspace: ${colors.accentBold(ws.name)}`);
656
658
  }
657
659
  }
658
660
  async function handleListWorkspaces(tui) {
659
- if (!tui.authContext) {
660
- tui.chatLog.addSystem("Not authenticated.", "error");
661
- tui.requestRender();
662
- return;
663
- }
661
+ if (!requireAuth(tui, "Not authenticated.")) return;
664
662
  try {
665
663
  const wsList = await core.workspaces.listWorkspaces(tui.authContext.arbi);
666
664
  const lines = wsList.map((ws) => {
667
665
  const current = ws.external_id === tui.state.workspaceId ? colors.accent(" (current)") : "";
668
666
  return ` ${ws.external_id} ${ws.name}${current}`;
669
667
  });
670
- tui.chatLog.addSystem(["Workspaces:", "", ...lines].join("\n"));
671
- tui.requestRender();
668
+ showMessage(tui, ["Workspaces:", "", ...lines].join("\n"));
672
669
  } catch (err) {
673
- const msg = err instanceof Error ? err.message : String(err);
674
- tui.chatLog.addSystem(`Failed to list workspaces: ${msg}`, "error");
675
- tui.requestRender();
670
+ showMessage(tui, `Failed to list workspaces: ${core.getErrorMessage(err)}`, "error");
676
671
  }
677
672
  }
678
673
  async function handleListDocs(tui) {
679
674
  if (!tui.authContext || !tui.state.workspaceId) {
680
- tui.chatLog.addSystem("No workspace selected. Use /workspace <id> first.", "warning");
681
- tui.requestRender();
675
+ showMessage(tui, "No workspace selected. Use /workspace <id> first.", "warning");
682
676
  return;
683
677
  }
684
678
  try {
685
679
  const docs = await core.documents.listDocuments(tui.authContext.arbi, tui.state.workspaceId);
686
680
  if (docs.length === 0) {
687
- tui.chatLog.addSystem("No documents in this workspace.");
681
+ showMessage(tui, "No documents in this workspace.");
688
682
  } else {
689
683
  const lines = docs.map((d) => ` ${d.external_id} ${d.file_name ?? "(unnamed)"}`);
690
- tui.chatLog.addSystem([`Documents (${docs.length}):`, "", ...lines].join("\n"));
684
+ showMessage(tui, [`Documents (${docs.length}):`, "", ...lines].join("\n"));
691
685
  }
692
- tui.requestRender();
693
686
  } catch (err) {
694
- const msg = err instanceof Error ? err.message : String(err);
695
- tui.chatLog.addSystem(`Failed to list documents: ${msg}`, "error");
696
- tui.requestRender();
687
+ showMessage(tui, `Failed to list documents: ${core.getErrorMessage(err)}`, "error");
697
688
  }
698
689
  }
699
690
  async function handleListContacts(tui) {
700
- if (!tui.authContext) {
701
- tui.chatLog.addSystem("Not authenticated. Use /login first.", "error");
702
- tui.requestRender();
703
- return;
704
- }
691
+ if (!requireAuth(tui)) return;
705
692
  try {
706
693
  const contactList = await core.contacts.listContacts(tui.authContext.arbi);
707
- const registered = contactList.filter((c) => c.status === "registered");
708
- const invited = contactList.filter((c) => c.status !== "registered");
709
- if (registered.length === 0 && invited.length === 0) {
710
- tui.chatLog.addSystem("No contacts. Use the web app to add contacts.");
694
+ const { registered, pending } = core.contacts.groupContactsByStatus(contactList);
695
+ if (registered.length === 0 && pending.length === 0) {
696
+ showMessage(tui, "No contacts. Use the web app to add contacts.");
711
697
  } else {
712
698
  const lines = [];
713
699
  if (registered.length > 0) {
714
700
  lines.push(`Contacts (${registered.length}):`, "");
715
701
  for (const c of registered) {
716
- const name = c.user ? `${c.user.given_name} ${c.user.family_name}`.trim() : "";
702
+ const name = core.formatUserName(c.user);
717
703
  const nameStr = name ? colors.muted(` (${name})`) : "";
718
704
  lines.push(` ${c.email}${nameStr}`);
719
705
  }
720
706
  }
721
- if (invited.length > 0) {
707
+ if (pending.length > 0) {
722
708
  if (lines.length > 0) lines.push("");
723
- lines.push(`Pending invitations (${invited.length}):`);
724
- for (const c of invited) {
709
+ lines.push(`Pending invitations (${pending.length}):`);
710
+ for (const c of pending) {
725
711
  lines.push(` ${c.email} \u2014 ${colors.muted(c.status)}`);
726
712
  }
727
713
  }
728
714
  lines.push("", colors.muted("Type @name to start a DM with a registered contact."));
729
- tui.chatLog.addSystem(lines.join("\n"));
715
+ showMessage(tui, lines.join("\n"));
730
716
  }
731
- tui.requestRender();
732
717
  } catch (err) {
733
- const msg = err instanceof Error ? err.message : String(err);
734
- tui.chatLog.addSystem(`Failed to list contacts: ${msg}`, "error");
735
- tui.requestRender();
718
+ showMessage(tui, `Failed to list contacts: ${core.getErrorMessage(err)}`, "error");
719
+ }
720
+ }
721
+ async function handleInviteContact(tui, email) {
722
+ if (!requireAuth(tui)) return;
723
+ if (!email?.trim()) {
724
+ showMessage(tui, "Usage: /invite user@example.com", "warning");
725
+ return;
726
+ }
727
+ try {
728
+ showMessage(tui, `Inviting ${email}...`);
729
+ const result = await core.contacts.addContacts(tui.authContext.arbi, [email.trim()]);
730
+ const contact = result[0];
731
+ if (contact?.status === "registered") {
732
+ showMessage(
733
+ tui,
734
+ `${colors.success("Added")} ${email} \u2014 already registered. You can DM them with @${email.split("@")[0]}`
735
+ );
736
+ } else {
737
+ showMessage(
738
+ tui,
739
+ `${colors.success("Invited")} ${email} \u2014 invitation sent. They'll appear in /contacts once they register.`
740
+ );
741
+ }
742
+ } catch (err) {
743
+ showMessage(tui, `Failed to invite contact: ${core.getErrorMessage(err)}`, "error");
736
744
  }
737
745
  }
738
746
  async function handleListConversations(tui) {
739
747
  if (!tui.authContext || !tui.state.workspaceId) {
740
- tui.chatLog.addSystem("No workspace selected. Use /workspace <id> first.", "warning");
741
- tui.requestRender();
748
+ showMessage(tui, "No workspace selected. Use /workspace <id> first.", "warning");
742
749
  return;
743
750
  }
744
751
  try {
745
752
  const convs = await core.conversations.listConversations(tui.authContext.arbi, tui.state.workspaceId);
746
753
  if (convs.length === 0) {
747
- tui.chatLog.addSystem("No conversations in this workspace.");
754
+ showMessage(tui, "No conversations in this workspace.");
748
755
  } else {
749
756
  const lines = convs.map((c) => ` ${c.external_id} ${c.title ?? "(untitled)"}`);
750
- tui.chatLog.addSystem([`Conversations (${convs.length}):`, "", ...lines].join("\n"));
757
+ showMessage(tui, [`Conversations (${convs.length}):`, "", ...lines].join("\n"));
751
758
  }
752
- tui.requestRender();
753
759
  } catch (err) {
754
- const msg = err instanceof Error ? err.message : String(err);
755
- tui.chatLog.addSystem(`Failed to list conversations: ${msg}`, "error");
756
- tui.requestRender();
760
+ showMessage(tui, `Failed to list conversations: ${core.getErrorMessage(err)}`, "error");
757
761
  }
758
762
  }
759
763
  function handleNewConversation(tui) {
760
764
  tui.state.conversationMessageId = null;
761
765
  tui.store.clearChatSession();
762
- tui.chatLog.addSystem("Started new conversation.");
763
- tui.requestRender();
766
+ showMessage(tui, "Started new conversation.");
764
767
  }
765
768
  function handleStatus(tui) {
766
769
  const { state } = tui;
@@ -772,8 +775,112 @@ function handleStatus(tui) {
772
775
  `Status: ${state.activityStatus}`,
773
776
  `WebSocket: ${tui.wsConnected ? colors.success("connected") : colors.muted("disconnected")}`
774
777
  ];
775
- tui.chatLog.addSystem(lines.join("\n"));
776
- tui.requestRender();
778
+ showMessage(tui, lines.join("\n"));
779
+ }
780
+ async function handleUpload(tui, filePath) {
781
+ const wsCtx = tui.wsContext;
782
+ if (!wsCtx) {
783
+ showMessage(tui, "No workspace selected. Use /workspace <id> first.", "warning");
784
+ return;
785
+ }
786
+ if (!filePath.trim()) {
787
+ showMessage(tui, "Usage: /upload <file-path>", "warning");
788
+ return;
789
+ }
790
+ try {
791
+ showMessage(tui, `Uploading ${filePath.trim()}...`);
792
+ const result = await core.documents.uploadLocalFile(
793
+ {
794
+ baseUrl: wsCtx.config.baseUrl,
795
+ accessToken: wsCtx.accessToken,
796
+ workspaceKeyHeader: wsCtx.workspaceKeyHeader
797
+ },
798
+ wsCtx.workspaceId,
799
+ filePath.trim()
800
+ );
801
+ const ids = result.doc_ext_ids.join(", ");
802
+ showMessage(tui, `${colors.success("Uploaded")} ${result.fileName} \u2014 doc ID(s): ${ids}`);
803
+ if (result.duplicates && result.duplicates.length > 0) {
804
+ showMessage(tui, `Duplicates detected: ${result.duplicates.join(", ")}`, "warning");
805
+ }
806
+ } catch (err) {
807
+ showMessage(tui, `Failed to upload: ${core.getErrorMessage(err)}`, "error");
808
+ }
809
+ }
810
+ async function handleDelete(tui, docId) {
811
+ if (!requireAuth(tui)) return;
812
+ if (!docId?.trim()) {
813
+ showMessage(tui, "Usage: /delete <doc-id> (use /docs to list document IDs)", "warning");
814
+ return;
815
+ }
816
+ try {
817
+ showMessage(tui, `Deleting document ${docId}...`);
818
+ await core.documents.deleteDocuments(tui.authContext.arbi, [docId.trim()]);
819
+ showMessage(tui, `${colors.success("Deleted")} document ${docId}`);
820
+ } catch (err) {
821
+ showMessage(tui, `Failed to delete document: ${core.getErrorMessage(err)}`, "error");
822
+ }
823
+ }
824
+ function handleLogout(tui) {
825
+ tui.store.deleteCredentials();
826
+ tui.store.updateConfig({ selectedWorkspaceId: void 0 });
827
+ tui.store.clearChatSession();
828
+ tui.authContext = null;
829
+ tui.state.isAuthenticated = false;
830
+ tui.state.workspaceId = null;
831
+ tui.state.workspaceName = null;
832
+ tui.state.conversationMessageId = null;
833
+ showMessage(tui, "Logged out. Use /login to authenticate again.");
834
+ }
835
+ async function handleHealth(tui) {
836
+ if (!requireAuth(tui)) return;
837
+ try {
838
+ const data = await core.health.getHealth(tui.authContext.arbi);
839
+ const lines = ["System Health:", ""];
840
+ const status = data.status;
841
+ if (status) {
842
+ const statusColor = status === "healthy" ? colors.success(status) : colors.warning(status);
843
+ lines.push(` Status: ${statusColor}`);
844
+ }
845
+ const services = data.services;
846
+ if (services) {
847
+ lines.push("", " Services:");
848
+ for (const [name, info] of Object.entries(services)) {
849
+ const svc = info;
850
+ const svcStatus = svc.status;
851
+ const statusStr = svcStatus ? svcStatus === "healthy" ? colors.success(svcStatus) : colors.error(svcStatus) : colors.muted("unknown");
852
+ lines.push(` ${name}: ${statusStr}`);
853
+ }
854
+ }
855
+ showMessage(tui, lines.join("\n"));
856
+ } catch (err) {
857
+ showMessage(tui, `Failed to fetch health status: ${core.getErrorMessage(err)}`, "error");
858
+ }
859
+ }
860
+ async function handleModels(tui) {
861
+ if (!requireAuth(tui)) return;
862
+ try {
863
+ const data = await core.health.getHealthModels(tui.authContext.arbi);
864
+ const models = Array.isArray(data) ? data : data.data ?? [];
865
+ if (models.length === 0) {
866
+ showMessage(tui, "No models available.");
867
+ return;
868
+ }
869
+ const lines = [`Models (${models.length}):`, ""];
870
+ for (const m of models) {
871
+ const model = m;
872
+ const name = model.model_name ?? model.name ?? "unknown";
873
+ const provider = model.provider ?? "";
874
+ const apiType = model.api_type ?? "";
875
+ const parts = [colors.accent(name)];
876
+ if (provider) parts.push(`provider: ${provider}`);
877
+ if (apiType) parts.push(`api: ${apiType}`);
878
+ lines.push(` ${parts.join(" ")}`);
879
+ }
880
+ showMessage(tui, lines.join("\n"));
881
+ } catch (err) {
882
+ showMessage(tui, `Failed to fetch models: ${core.getErrorMessage(err)}`, "error");
883
+ }
777
884
  }
778
885
  async function streamResponse(tui, response) {
779
886
  tui.chatLog.startAssistant();
@@ -812,8 +919,7 @@ async function streamResponse(tui, response) {
812
919
  } catch (err) {
813
920
  tui.chatLog.finalizeAssistant();
814
921
  tui.state.activityStatus = "error";
815
- const msg = err instanceof Error ? err.message : String(err);
816
- tui.chatLog.addSystem(`Streaming failed: ${msg}`, "error");
922
+ tui.chatLog.addSystem(`Streaming failed: ${core.getErrorMessage(err)}`, "error");
817
923
  tui.requestRender();
818
924
  throw err;
819
925
  }
@@ -828,8 +934,7 @@ function deriveEncryptionKeypair(signingPrivateKeyBase64) {
828
934
  }
829
935
  async function resolveRecipient(tui, query) {
830
936
  if (!tui.authContext) {
831
- tui.chatLog.addSystem("Not authenticated. Use /login first.", "error");
832
- tui.requestRender();
937
+ showMessage(tui, "Not authenticated. Use /login first.", "error");
833
938
  return null;
834
939
  }
835
940
  const queryLower = query.toLowerCase();
@@ -844,21 +949,17 @@ async function resolveRecipient(tui, query) {
844
949
  return emailPrefix === queryLower || givenName === queryLower;
845
950
  });
846
951
  if (matches.length === 0) {
847
- tui.chatLog.addSystem(
952
+ showMessage(
953
+ tui,
848
954
  `No registered contact matching "${query}". Use /contacts to see available contacts.`,
849
955
  "warning"
850
956
  );
851
- tui.requestRender();
852
957
  return null;
853
958
  }
854
959
  if (matches.length > 1) {
855
960
  const names = matches.map((c) => ` ${c.email} (${c.user?.given_name ?? ""} ${c.user?.family_name ?? ""})`).join("\n");
856
- tui.chatLog.addSystem(
857
- `Ambiguous match for "${query}". Be more specific:
858
- ${names}`,
859
- "warning"
860
- );
861
- tui.requestRender();
961
+ showMessage(tui, `Ambiguous match for "${query}". Be more specific:
962
+ ${names}`, "warning");
862
963
  return null;
863
964
  }
864
965
  const contact = matches[0];
@@ -871,17 +972,14 @@ ${names}`,
871
972
  family_name: user.family_name
872
973
  };
873
974
  } catch (err) {
874
- const msg = err instanceof Error ? err.message : String(err);
875
- tui.chatLog.addSystem(`Failed to resolve contact: ${msg}`, "error");
876
- tui.requestRender();
975
+ showMessage(tui, `Failed to resolve contact: ${core.getErrorMessage(err)}`, "error");
877
976
  return null;
878
977
  }
879
978
  }
880
979
  async function handleChannelSwitch(tui, input2) {
881
980
  const query = input2.slice(1).trim();
882
981
  if (!query) {
883
- tui.chatLog.addSystem("Usage: @username to switch to DM, @arbi to switch back.", "warning");
884
- tui.requestRender();
982
+ showMessage(tui, "Usage: @username to switch to DM, @arbi to switch back.", "warning");
885
983
  return;
886
984
  }
887
985
  if (query.toLowerCase() === "arbi") {
@@ -894,30 +992,24 @@ async function handleChannelSwitch(tui, input2) {
894
992
  }
895
993
  async function switchToDmChannel(tui, user) {
896
994
  if (!tui.authContext || !tui.encryptionKeys) {
897
- tui.chatLog.addSystem("Not authenticated or encryption not initialized.", "error");
898
- tui.requestRender();
995
+ showMessage(tui, "Not authenticated or encryption not initialized.", "error");
899
996
  return;
900
997
  }
901
998
  tui.currentDmChannel = { user };
902
999
  tui.updateHeader();
903
1000
  tui.chatLog.clearMessages();
904
- tui.chatLog.addSystem(
905
- `Switched to DM with ${colors.accent(user.email)}. Type @arbi to switch back.`
906
- );
907
- tui.requestRender();
1001
+ showMessage(tui, `Switched to DM with ${colors.accent(user.email)}. Type @arbi to switch back.`);
908
1002
  await loadDmHistory(tui, user);
909
1003
  }
910
1004
  function switchToArbi(tui) {
911
1005
  if (!tui.currentDmChannel) {
912
- tui.chatLog.addSystem("Already in AI chat mode.", "info");
913
- tui.requestRender();
1006
+ showMessage(tui, "Already in AI chat mode.", "info");
914
1007
  return;
915
1008
  }
916
1009
  tui.currentDmChannel = null;
917
1010
  tui.updateHeader();
918
1011
  tui.chatLog.clearMessages();
919
- tui.chatLog.addSystem("Switched back to AI chat.");
920
- tui.requestRender();
1012
+ showMessage(tui, "Switched back to AI chat.");
921
1013
  }
922
1014
  async function loadDmHistory(tui, user) {
923
1015
  if (!tui.authContext || !tui.encryptionKeys) return;
@@ -927,9 +1019,7 @@ async function loadDmHistory(tui, user) {
927
1019
  const conversation = allDms.filter(
928
1020
  (msg) => msg.type === "user_message" && (msg.sender.external_id === user.external_id && msg.recipient.external_id === myExtId || msg.sender.external_id === myExtId && msg.recipient.external_id === user.external_id)
929
1021
  );
930
- conversation.sort(
931
- (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
932
- );
1022
+ conversation.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
933
1023
  const unreadIds = conversation.filter((msg) => !msg.read && msg.sender.external_id === user.external_id).map((msg) => msg.external_id);
934
1024
  if (unreadIds.length > 0) {
935
1025
  core.dm.markRead(tui.authContext.arbi, unreadIds).catch(() => {
@@ -944,15 +1034,12 @@ async function loadDmHistory(tui, user) {
944
1034
  }
945
1035
  tui.requestRender();
946
1036
  } catch (err) {
947
- const errMsg = err instanceof Error ? err.message : String(err);
948
- tui.chatLog.addSystem(`Failed to load DM history: ${errMsg}`, "error");
949
- tui.requestRender();
1037
+ showMessage(tui, `Failed to load DM history: ${core.getErrorMessage(err)}`, "error");
950
1038
  }
951
1039
  }
952
1040
  async function sendEncryptedDm(tui, plaintext) {
953
1041
  if (!tui.authContext || !tui.encryptionKeys || !tui.currentDmChannel) {
954
- tui.chatLog.addSystem("Cannot send DM \u2014 not in a DM channel.", "error");
955
- tui.requestRender();
1042
+ showMessage(tui, "Cannot send DM \u2014 not in a DM channel.", "error");
956
1043
  return;
957
1044
  }
958
1045
  const recipient = tui.currentDmChannel.user;
@@ -968,9 +1055,7 @@ async function sendEncryptedDm(tui, plaintext) {
968
1055
  displayDmMessage(tui, plaintext, true, recipient.email);
969
1056
  tui.requestRender();
970
1057
  } catch (err) {
971
- const msg = err instanceof Error ? err.message : String(err);
972
- tui.chatLog.addSystem(`Failed to send DM: ${msg}`, "error");
973
- tui.requestRender();
1058
+ showMessage(tui, `Failed to send DM: ${core.getErrorMessage(err)}`, "error");
974
1059
  }
975
1060
  }
976
1061
  async function handleIncomingDm(tui, msg) {
@@ -1061,12 +1146,21 @@ function handleBatchComplete(msg, toasts) {
1061
1146
  async function connectTuiWebSocket(options) {
1062
1147
  const { baseUrl, accessToken, toasts, tui } = options;
1063
1148
  try {
1064
- const connection = await core.connectWebSocket({
1149
+ const connection = await core.connectWithReconnect({
1065
1150
  baseUrl,
1066
1151
  accessToken,
1067
1152
  onMessage: (msg) => handleMessage(msg, toasts, tui ?? null),
1068
1153
  onClose: (_code, _reason) => {
1069
1154
  toasts.show("WebSocket disconnected", "warning", DURATION_DEFAULT);
1155
+ },
1156
+ onReconnecting: (attempt, maxRetries) => {
1157
+ toasts.show(`Reconnecting... (${attempt}/${maxRetries})`, "warning", DURATION_DEFAULT);
1158
+ },
1159
+ onReconnected: () => {
1160
+ toasts.show("WebSocket reconnected", "success", DURATION_DEFAULT);
1161
+ },
1162
+ onReconnectFailed: () => {
1163
+ toasts.show("WebSocket reconnection failed", "error", DURATION_LONG);
1070
1164
  }
1071
1165
  });
1072
1166
  return connection;
@@ -1114,14 +1208,7 @@ var ArbiTui = class {
1114
1208
  this.chatLog = new ChatLog();
1115
1209
  this.toastContainer = new ToastContainer();
1116
1210
  this.toastContainer.setRenderCallback(() => this.tui.requestRender());
1117
- this.editor = new ArbiEditor(this.tui);
1118
- this.editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(commands));
1119
- this.editor.onSubmit = (text) => this.handleSubmit(text);
1120
- this.editor.onEscape = () => this.handleAbort();
1121
- this.editor.onCtrlC = () => this.shutdown();
1122
- this.editor.onCtrlD = () => this.shutdown();
1123
- this.editor.onCtrlW = () => this.handleSubmit("/workspaces");
1124
- this.editor.onCtrlN = () => this.handleSubmit("/new");
1211
+ this.editor = this.createEditor();
1125
1212
  this.tui.addChild(this.header);
1126
1213
  this.tui.addChild(new piTui.Spacer(1));
1127
1214
  this.tui.addChild(this.toastContainer);
@@ -1162,20 +1249,25 @@ var ArbiTui = class {
1162
1249
  this.tui.addChild(new piTui.Spacer(1));
1163
1250
  this.tui.addChild(this.toastContainer);
1164
1251
  this.tui.addChild(this.chatLog);
1165
- this.editor = new ArbiEditor(this.tui);
1166
- this.editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(commands));
1167
- this.editor.onSubmit = (text) => this.handleSubmit(text);
1168
- this.editor.onEscape = () => this.handleAbort();
1169
- this.editor.onCtrlC = () => this.shutdown();
1170
- this.editor.onCtrlD = () => this.shutdown();
1171
- this.editor.onCtrlW = () => this.handleSubmit("/workspaces");
1172
- this.editor.onCtrlN = () => this.handleSubmit("/new");
1252
+ this.editor = this.createEditor();
1173
1253
  this.tui.addChild(this.editor);
1174
1254
  this.tui.setFocus(this.editor);
1175
1255
  this.tui.start();
1176
1256
  this.updateHeader();
1177
1257
  this.tui.requestRender();
1178
1258
  }
1259
+ /** Create and wire a new editor instance. */
1260
+ createEditor() {
1261
+ const editor = new ArbiEditor(this.tui);
1262
+ editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(commands));
1263
+ editor.onSubmit = (text) => this.handleSubmit(text);
1264
+ editor.onEscape = () => this.handleAbort();
1265
+ editor.onCtrlC = () => this.shutdown();
1266
+ editor.onCtrlD = () => this.shutdown();
1267
+ editor.onCtrlW = () => this.handleSubmit("/workspaces");
1268
+ editor.onCtrlN = () => this.handleSubmit("/new");
1269
+ return editor;
1270
+ }
1179
1271
  /** Request a render update. */
1180
1272
  requestRender() {
1181
1273
  this.tui.requestRender();
@@ -1205,6 +1297,10 @@ var ArbiTui = class {
1205
1297
  this.workspaceContext = ctx;
1206
1298
  this.updateHeader();
1207
1299
  }
1300
+ /** Public accessor for workspace context (used by command handlers). */
1301
+ get wsContext() {
1302
+ return this.workspaceContext;
1303
+ }
1208
1304
  /** Whether the WebSocket is currently connected. */
1209
1305
  get wsConnected() {
1210
1306
  return this.wsConnection !== null;
@@ -1282,12 +1378,18 @@ var ArbiTui = class {
1282
1378
  const result = await streamResponse(this, response);
1283
1379
  if (result.assistantMessageExtId) {
1284
1380
  this.state.conversationMessageId = result.assistantMessageExtId;
1285
- this.store.updateChatSession({ lastMessageExtId: result.assistantMessageExtId });
1381
+ const sessionUpdate = {
1382
+ lastMessageExtId: result.assistantMessageExtId
1383
+ };
1384
+ const conversationExtId = result.userMessage?.conversation_ext_id ?? result.metadata?.conversation_ext_id;
1385
+ if (conversationExtId) {
1386
+ sessionUpdate.conversationExtId = conversationExtId;
1387
+ }
1388
+ this.store.updateChatSession(sessionUpdate);
1286
1389
  }
1287
1390
  } catch (err) {
1288
1391
  this.state.activityStatus = "error";
1289
- const msg = err instanceof Error ? err.message : String(err);
1290
- this.chatLog.addSystem(`Error: ${msg}`, "error");
1392
+ this.chatLog.addSystem(`Error: ${core.getErrorMessage(err)}`, "error");
1291
1393
  }
1292
1394
  this.state.activityStatus = "idle";
1293
1395
  this.updateHeader();
@@ -1352,8 +1454,7 @@ program.name("arbi-tui").description("Interactive terminal UI for ARBI \u2014 ch
1352
1454
  }
1353
1455
  }
1354
1456
  } catch (err) {
1355
- const msg = err instanceof Error ? err.message : String(err);
1356
- tui.chatLog.addSystem(`Failed to load workspace: ${msg}`, "warning");
1457
+ tui.chatLog.addSystem(`Failed to load workspace: ${core.getErrorMessage(err)}`, "warning");
1357
1458
  tui.chatLog.addSystem("Use /workspaces to list and /workspace <id> to select.");
1358
1459
  }
1359
1460
  } else {
@@ -1365,6 +1466,29 @@ program.name("arbi-tui").description("Interactive terminal UI for ARBI \u2014 ch
1365
1466
  if (session.lastMessageExtId) {
1366
1467
  tui.state.conversationMessageId = session.lastMessageExtId;
1367
1468
  }
1469
+ if (session.conversationExtId && session.lastMessageExtId && tui.authContext) {
1470
+ try {
1471
+ const threads = await core.conversations.getConversationThreads(
1472
+ tui.authContext.arbi,
1473
+ session.conversationExtId
1474
+ );
1475
+ const threadList = threads.threads ?? [];
1476
+ const thread = threadList.find(
1477
+ (t) => t.leaf_message_ext_id === session.lastMessageExtId
1478
+ );
1479
+ if (thread?.history && thread.history.length > 0) {
1480
+ for (const msg of thread.history) {
1481
+ if (msg.role === "user") {
1482
+ tui.chatLog.addUser(msg.content);
1483
+ } else if (msg.role === "assistant") {
1484
+ tui.chatLog.addAssistant(msg.content);
1485
+ }
1486
+ }
1487
+ tui.chatLog.addSystem("--- restored from previous session ---");
1488
+ }
1489
+ } catch {
1490
+ }
1491
+ }
1368
1492
  tui.chatLog.addSystem(
1369
1493
  "Type a question to chat with the ARBI assistant. Use /help for commands."
1370
1494
  );