@promptbook/cli 0.112.0 → 0.113.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +45 -29
  2. package/agents/default/developer.book +2 -1
  3. package/apps/agents-server/next.config.ts +21 -0
  4. package/apps/agents-server/src/app/admin/limits/LimitsClient.tsx +1 -1
  5. package/apps/agents-server/src/app/admin/task-manager/TaskManagerTaskRow.tsx +7 -2
  6. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +52 -33
  7. package/apps/agents-server/src/app/api/agent-folders/[folderId]/visibility/route.ts +19 -7
  8. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +8 -18
  9. package/apps/agents-server/src/app/api/internal/agent-runner-limits/route.ts +1 -1
  10. package/apps/agents-server/src/app/api/page-preview/check/route.ts +5 -20
  11. package/apps/agents-server/src/app/api/page-preview/interact/route.ts +179 -0
  12. package/apps/agents-server/src/app/api/page-preview/live/route.ts +67 -0
  13. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +13 -21
  14. package/apps/agents-server/src/app/api/v1/agents/[agentId]/route.ts +13 -8
  15. package/apps/agents-server/src/components/AgentProfile/AgentCapabilityChips.tsx +39 -5
  16. package/apps/agents-server/src/components/Homepage/AgentCard.tsx +7 -1
  17. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +3 -0
  18. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +41 -0
  19. package/apps/agents-server/src/components/Homepage/hiddenFolders.ts +104 -0
  20. package/apps/agents-server/src/components/Homepage/useAgentsListQueryState.ts +35 -0
  21. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +24 -4
  22. package/apps/agents-server/src/constants/chatVisualMode.ts +1 -1
  23. package/apps/agents-server/src/constants/serverLimits.ts +19 -0
  24. package/apps/agents-server/src/constants/themeMode.ts +1 -1
  25. package/apps/agents-server/src/database/migrations/2026-05-2600-default-theme.sql +2 -2
  26. package/apps/agents-server/src/database/seedCoreAgents.ts +0 -2
  27. package/apps/agents-server/src/database/seedDefaultAgents.ts +0 -2
  28. package/apps/agents-server/src/tools/agent_progress.ts +4 -10
  29. package/apps/agents-server/src/utils/agentVisibility.ts +25 -62
  30. package/apps/agents-server/src/utils/chatTasksAdmin.ts +3 -1
  31. package/apps/agents-server/src/utils/createAgentWithDefaultVisibility.ts +3 -1
  32. package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +9 -17
  33. package/apps/agents-server/src/utils/getAdminChatTasksResponse/mapVpsSelfUpdateJobToAdminChatTask.ts +84 -0
  34. package/apps/agents-server/src/utils/getAdminChatTasksResponse.ts +195 -5
  35. package/apps/agents-server/src/utils/localChatRunner/LocalUserChatJobMetadata.ts +15 -5
  36. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +17 -26
  37. package/apps/agents-server/src/utils/pagePreview/livePagePreviewSessions.ts +365 -0
  38. package/apps/agents-server/src/utils/pagePreview/resolvePagePreviewRequestUrl.ts +74 -0
  39. package/apps/agents-server/src/utils/serverLimits.ts +2 -0
  40. package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerCoreAgents.ts +2 -2
  41. package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerDefaultAgents.ts +1 -1
  42. package/apps/agents-server/src/utils/userChat/createUserChatHarnessProgressCard.ts +0 -6
  43. package/apps/agents-server/src/utils/userChat/createUserChatRunnerProgressCard.ts +0 -6
  44. package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +69 -0
  45. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +0 -6
  46. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +150 -24
  47. package/esm/apps/agents-server/src/constants/serverLimits.d.ts +17 -0
  48. package/esm/index.es.js +581 -93
  49. package/esm/index.es.js.map +1 -1
  50. package/esm/scripts/run-agent-messages/main/runMultipleAgentMessages.d.ts +1 -0
  51. package/esm/scripts/run-agent-messages/main/tickAgentMessages.d.ts +1 -0
  52. package/esm/src/_packages/core.index.d.ts +22 -0
  53. package/esm/src/_packages/types.index.d.ts +2 -0
  54. package/esm/src/book-2.0/agent-source/AgentBasicInformation.d.ts +2 -0
  55. package/esm/src/book-2.0/agent-source/agentSourceVisibility.d.ts +97 -0
  56. package/esm/src/book-2.0/agent-source/agentSourceVisibility.test.d.ts +1 -0
  57. package/esm/src/book-components/BookEditor/BookEditorMonacoTokenization.d.ts +1 -0
  58. package/esm/src/book-components/Chat/Chat/CitationIframePreview.d.ts +2 -2
  59. package/esm/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +12 -0
  60. package/esm/src/cli/cli-commands/agents-server/buildAgentsServer.d.ts +6 -0
  61. package/esm/src/collection/agent-collection/CreateAgentInput.d.ts +2 -1
  62. package/esm/src/collection/agent-collection/constructors/agent-collection-in-supabase/prepareAgentSourceForPersistence.d.ts +21 -1
  63. package/esm/src/commitments/META_VISIBILITY/META_VISIBILITY.d.ts +27 -0
  64. package/esm/src/commitments/index.d.ts +2 -1
  65. package/esm/src/version.d.ts +1 -1
  66. package/package.json +1 -1
  67. package/src/_packages/core.index.ts +22 -0
  68. package/src/_packages/types.index.ts +2 -0
  69. package/src/book-2.0/agent-source/AgentBasicInformation.ts +2 -0
  70. package/src/book-2.0/agent-source/agentSourceVisibility.ts +214 -0
  71. package/src/book-2.0/agent-source/parseAgentSource/applyMetaCommitment.ts +17 -0
  72. package/src/book-2.0/book-language-documentation/createStandaloneBookLanguageMarkdown.ts +1 -0
  73. package/src/book-components/BookEditor/BookEditorMonacoTokenization.ts +11 -0
  74. package/src/book-components/BookEditor/useBookEditorMonacoLanguage.ts +7 -3
  75. package/src/book-components/Chat/Chat/Chat.module.css +14 -5
  76. package/src/book-components/Chat/Chat/CitationIframePreview.tsx +197 -22
  77. package/src/book-components/Chat/utils/livePagePreviewConstants.ts +17 -0
  78. package/src/cli/cli-commands/agents-server/buildAgentsServer.ts +144 -16
  79. package/src/cli/cli-commands/agents-server/startAgentsServer.ts +22 -2
  80. package/src/cli/cli-commands/coder/getDefaultCoderPackageJsonScripts.ts +1 -1
  81. package/src/cli/cli-commands/coder/run.ts +30 -0
  82. package/src/collection/agent-collection/CreateAgentInput.ts +10 -5
  83. package/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase.ts +7 -3
  84. package/src/collection/agent-collection/constructors/agent-collection-in-supabase/createAgentPersistenceRecords.ts +7 -4
  85. package/src/collection/agent-collection/constructors/agent-collection-in-supabase/prepareAgentSourceForPersistence.ts +44 -8
  86. package/src/commitments/META_VISIBILITY/META_VISIBILITY.ts +78 -0
  87. package/src/commitments/index.ts +2 -0
  88. package/src/other/templates/getTemplatesPipelineCollection.ts +826 -650
  89. package/src/version.ts +2 -2
  90. package/src/versions.txt +2 -0
  91. package/umd/apps/agents-server/src/constants/serverLimits.d.ts +17 -0
  92. package/umd/index.umd.js +581 -93
  93. package/umd/index.umd.js.map +1 -1
  94. package/umd/scripts/run-agent-messages/main/runMultipleAgentMessages.d.ts +1 -0
  95. package/umd/scripts/run-agent-messages/main/tickAgentMessages.d.ts +1 -0
  96. package/umd/src/_packages/core.index.d.ts +22 -0
  97. package/umd/src/_packages/types.index.d.ts +2 -0
  98. package/umd/src/book-2.0/agent-source/AgentBasicInformation.d.ts +2 -0
  99. package/umd/src/book-2.0/agent-source/agentSourceVisibility.d.ts +97 -0
  100. package/umd/src/book-2.0/agent-source/agentSourceVisibility.test.d.ts +1 -0
  101. package/umd/src/book-components/BookEditor/BookEditorMonacoTokenization.d.ts +1 -0
  102. package/umd/src/book-components/Chat/Chat/CitationIframePreview.d.ts +2 -2
  103. package/umd/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +12 -0
  104. package/umd/src/cli/cli-commands/agents-server/buildAgentsServer.d.ts +6 -0
  105. package/umd/src/collection/agent-collection/CreateAgentInput.d.ts +2 -1
  106. package/umd/src/collection/agent-collection/constructors/agent-collection-in-supabase/prepareAgentSourceForPersistence.d.ts +21 -1
  107. package/umd/src/commitments/META_VISIBILITY/META_VISIBILITY.d.ts +27 -0
  108. package/umd/src/commitments/index.d.ts +2 -1
  109. package/umd/src/version.d.ts +1 -1
@@ -203,6 +203,10 @@ export type VpsSelfUpdateOverview = {
203
203
  * Author timestamp of the latest remote commit in ISO format.
204
204
  */
205
205
  readonly latestRemoteCommitDate: string | null;
206
+ /**
207
+ * Latest remote commit subject.
208
+ */
209
+ readonly latestRemoteCommitMessage: string | null;
206
210
  /**
207
211
  * Number of commits the deployed checkout is behind the latest remote commit, or `null` when unknown.
208
212
  */
@@ -456,22 +460,24 @@ export async function readVpsSelfUpdateOverview(): Promise<VpsSelfUpdateOverview
456
460
  });
457
461
  }
458
462
 
459
- const [currentCommitSha, currentCommitMessage, currentCommitDate, latestRemoteCommitSha] = await Promise.all([
460
- runGitInRepository(repositoryDirectory, ['rev-parse', 'HEAD']),
461
- runGitInRepository(repositoryDirectory, ['log', '-1', '--format=%s']),
462
- runGitInRepository(repositoryDirectory, ['log', '-1', '--format=%aI']),
463
- readRemoteCommitSha(repositoryDirectory, currentEnvironment.branch, originRepositoryUrl),
463
+ await refreshVpsSelfUpdateRemoteBranch(repositoryDirectory, currentEnvironment.branch, originRepositoryUrl);
464
+
465
+ const [currentCommit, latestRemoteCommitSha] = await Promise.all([
466
+ readCommitMetadataFromRepository(repositoryDirectory, 'HEAD'),
467
+ readLatestRemoteBranchCommitSha(repositoryDirectory, currentEnvironment.branch, originRepositoryUrl),
464
468
  ]);
465
- const latestRemoteCommitDate = latestRemoteCommitSha
466
- ? await readCommitDateFromRepository(repositoryDirectory, latestRemoteCommitSha)
469
+ const latestRemoteCommit = latestRemoteCommitSha
470
+ ? await readCommitMetadataFromRepository(repositoryDirectory, latestRemoteCommitSha)
467
471
  : null;
472
+ const currentCommitSha = currentCommit?.commitSha ?? null;
473
+ const latestRemoteCommitResolvedSha = latestRemoteCommit?.commitSha ?? latestRemoteCommitSha;
468
474
  const commitsBehindCount =
469
- currentCommitSha && latestRemoteCommitSha
470
- ? await countCommitsBetween(repositoryDirectory, currentCommitSha, latestRemoteCommitSha)
475
+ currentCommitSha && latestRemoteCommitResolvedSha
476
+ ? await countCommitsBetween(repositoryDirectory, currentCommitSha, latestRemoteCommitResolvedSha)
471
477
  : null;
472
478
  const pendingCommits =
473
- currentCommitSha && latestRemoteCommitSha
474
- ? await listCommitsBetween(repositoryDirectory, currentCommitSha, latestRemoteCommitSha)
479
+ currentCommitSha && latestRemoteCommitResolvedSha
480
+ ? await listCommitsBetween(repositoryDirectory, currentCommitSha, latestRemoteCommitResolvedSha)
475
481
  : [];
476
482
  const resolvedJob = resolveVpsSelfUpdateJobForOverview(job, {
477
483
  currentEnvironment,
@@ -486,15 +492,16 @@ export async function readVpsSelfUpdateOverview(): Promise<VpsSelfUpdateOverview
486
492
  repositoryDirectory,
487
493
  currentCommitSha,
488
494
  currentCommitShortSha: abbreviateCommitSha(currentCommitSha),
489
- currentCommitMessage,
490
- currentCommitDate,
491
- latestRemoteCommitSha,
492
- latestRemoteCommitShortSha: abbreviateCommitSha(latestRemoteCommitSha),
493
- latestRemoteCommitDate,
495
+ currentCommitMessage: currentCommit?.subject ?? null,
496
+ currentCommitDate: currentCommit?.authoredAt ?? null,
497
+ latestRemoteCommitSha: latestRemoteCommitResolvedSha,
498
+ latestRemoteCommitShortSha: abbreviateCommitSha(latestRemoteCommitResolvedSha),
499
+ latestRemoteCommitDate: latestRemoteCommit?.authoredAt ?? null,
500
+ latestRemoteCommitMessage: latestRemoteCommit?.subject ?? null,
494
501
  commitsBehindCount,
495
502
  pendingCommits,
496
503
  isUpdateAvailable: Boolean(
497
- currentCommitSha && latestRemoteCommitSha && currentCommitSha !== latestRemoteCommitSha,
504
+ currentCommitSha && latestRemoteCommitResolvedSha && currentCommitSha !== latestRemoteCommitResolvedSha,
498
505
  ),
499
506
  originRepositoryUrl,
500
507
  isOriginRepositoryDefault: originRepositoryUrl === VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL,
@@ -525,11 +532,34 @@ export type VpsSelfUpdatePendingCommit = {
525
532
  readonly authoredAt: string | null;
526
533
  };
527
534
 
535
+ /**
536
+ * Browser-safe metadata read from one git commit object.
537
+ */
538
+ type VpsSelfUpdateCommitMetadata = {
539
+ /**
540
+ * Full commit hash.
541
+ */
542
+ readonly commitSha: string;
543
+ /**
544
+ * Single-line commit subject.
545
+ */
546
+ readonly subject: string;
547
+ /**
548
+ * Author timestamp in ISO format or `null` when unknown.
549
+ */
550
+ readonly authoredAt: string | null;
551
+ };
552
+
528
553
  /**
529
554
  * Hard ceiling for the pending-commits listing returned in the overview to avoid huge payloads on a long-stale server.
530
555
  */
531
556
  const VPS_SELF_UPDATE_MAX_PENDING_COMMITS = 100;
532
557
 
558
+ /**
559
+ * Number of latest branch commits fetched for the update overview.
560
+ */
561
+ const VPS_SELF_UPDATE_OVERVIEW_FETCH_DEPTH = VPS_SELF_UPDATE_MAX_PENDING_COMMITS + 1;
562
+
533
563
  /**
534
564
  * Browser-safe summary of one commit that the super admin can pick from the custom-target picker.
535
565
  */
@@ -825,6 +855,7 @@ function createUnavailableOverview(context: {
825
855
  latestRemoteCommitSha: null,
826
856
  latestRemoteCommitShortSha: null,
827
857
  latestRemoteCommitDate: null,
858
+ latestRemoteCommitMessage: null,
828
859
  commitsBehindCount: null,
829
860
  pendingCommits: [],
830
861
  isUpdateAvailable: false,
@@ -1011,6 +1042,18 @@ function resolveVpsSelfUpdateStateDirectory(): string {
1011
1042
  return resolve(dirname(resolveVpsEnvironmentFilePath()), '.promptbook', 'self-update');
1012
1043
  }
1013
1044
 
1045
+ /**
1046
+ * Reads a lightweight snapshot of the currently persisted standalone VPS self-update job.
1047
+ *
1048
+ * Reads only the persisted status file and its log tail — no git access, no remote fetching —
1049
+ * so it is safe to call from frequent polling loops such as the admin task manager.
1050
+ *
1051
+ * @returns Parsed job snapshot (status `idle` when no job has ever been persisted).
1052
+ */
1053
+ export async function readVpsSelfUpdateJobSnapshot(): Promise<VpsSelfUpdateJobSnapshot> {
1054
+ return readPersistedVpsSelfUpdateJob();
1055
+ }
1056
+
1014
1057
  /**
1015
1058
  * Reads one persisted update-job snapshot from disk.
1016
1059
  *
@@ -1171,6 +1214,59 @@ async function runGitInRepository(repositoryDirectory: string, args: ReadonlyArr
1171
1214
  }
1172
1215
  }
1173
1216
 
1217
+ /**
1218
+ * Fetches the tracked remote branch into the local object database before building the browser overview.
1219
+ *
1220
+ * @param repositoryDirectory - Repository checkout path.
1221
+ * @param branch - Target branch.
1222
+ * @param originRepositoryUrl - Configured upstream repository URL.
1223
+ */
1224
+ async function refreshVpsSelfUpdateRemoteBranch(
1225
+ repositoryDirectory: string,
1226
+ branch: string,
1227
+ originRepositoryUrl: string,
1228
+ ): Promise<void> {
1229
+ if (!branch) {
1230
+ return;
1231
+ }
1232
+
1233
+ await runGitInRepository(repositoryDirectory, [
1234
+ 'fetch',
1235
+ '--no-tags',
1236
+ '--prune',
1237
+ `--depth=${VPS_SELF_UPDATE_OVERVIEW_FETCH_DEPTH}`,
1238
+ originRepositoryUrl,
1239
+ `+refs/heads/${branch}:${createVpsSelfUpdateRemoteBranchReference(branch)}`,
1240
+ ]);
1241
+ }
1242
+
1243
+ /**
1244
+ * Reads the latest tracked-branch commit from the local remote-tracking ref, falling back to `ls-remote`.
1245
+ *
1246
+ * @param repositoryDirectory - Repository checkout path.
1247
+ * @param branch - Target branch.
1248
+ * @param originRepositoryUrl - Configured upstream repository URL.
1249
+ * @returns Remote branch commit sha or `null`.
1250
+ */
1251
+ async function readLatestRemoteBranchCommitSha(
1252
+ repositoryDirectory: string,
1253
+ branch: string,
1254
+ originRepositoryUrl: string,
1255
+ ): Promise<string | null> {
1256
+ if (!branch) {
1257
+ return null;
1258
+ }
1259
+
1260
+ const remoteBranchReference = createVpsSelfUpdateRemoteBranchReference(branch);
1261
+ const localCommitSha = await runGitInRepository(repositoryDirectory, [
1262
+ 'rev-parse',
1263
+ '--verify',
1264
+ `${remoteBranchReference}^{commit}`,
1265
+ ]);
1266
+
1267
+ return localCommitSha || readRemoteCommitSha(repositoryDirectory, branch, originRepositoryUrl);
1268
+ }
1269
+
1174
1270
  /**
1175
1271
  * Reads the latest remote branch commit without mutating the local checkout.
1176
1272
  *
@@ -1197,17 +1293,47 @@ async function readRemoteCommitSha(
1197
1293
  }
1198
1294
 
1199
1295
  /**
1200
- * Reads the author timestamp of a known commit from the local repository.
1296
+ * Creates the local remote-tracking reference used for the update overview fetch.
1297
+ *
1298
+ * @param branch - Target branch.
1299
+ * @returns Local remote-tracking reference.
1300
+ */
1301
+ function createVpsSelfUpdateRemoteBranchReference(branch: string): string {
1302
+ return `refs/remotes/origin/${branch}`;
1303
+ }
1304
+
1305
+ /**
1306
+ * Reads hash, subject and author timestamp for one known commit from the local repository.
1201
1307
  *
1202
1308
  * @param repositoryDirectory - Repository checkout path.
1203
- * @param commitSha - Commit hash to look up.
1204
- * @returns ISO timestamp or `null` when the commit is unknown locally.
1309
+ * @param commitReference - Commit hash or git revision reference to look up.
1310
+ * @returns Commit metadata or `null` when the commit cannot be resolved locally.
1205
1311
  */
1206
- async function readCommitDateFromRepository(
1312
+ async function readCommitMetadataFromRepository(
1207
1313
  repositoryDirectory: string,
1208
- commitSha: string,
1209
- ): Promise<string | null> {
1210
- return runGitInRepository(repositoryDirectory, ['log', '-1', '--format=%aI', commitSha]);
1314
+ commitReference: string,
1315
+ ): Promise<VpsSelfUpdateCommitMetadata | null> {
1316
+ const output = await runGitInRepository(repositoryDirectory, [
1317
+ 'log',
1318
+ '-1',
1319
+ `--format=%H${GIT_LOG_FIELD_SEPARATOR}%aI${GIT_LOG_FIELD_SEPARATOR}%s`,
1320
+ commitReference,
1321
+ ]);
1322
+ if (!output) {
1323
+ return null;
1324
+ }
1325
+
1326
+ const fields = output.split(GIT_LOG_FIELD_SEPARATOR);
1327
+ const commitSha = fields[0] ?? '';
1328
+ if (!commitSha) {
1329
+ return null;
1330
+ }
1331
+
1332
+ return {
1333
+ commitSha,
1334
+ authoredAt: fields[1] || null,
1335
+ subject: fields.slice(2).join(GIT_LOG_FIELD_SEPARATOR),
1336
+ };
1211
1337
  }
1212
1338
 
1213
1339
  /**
@@ -28,6 +28,12 @@ export declare const DEFAULT_SPAWN_AGENT_RATE_LIMIT_WINDOW_MS: number;
28
28
  * @private shared Agents Server constant
29
29
  */
30
30
  export declare const DEFAULT_LOCAL_AGENT_RUNNER_MAX_FAILED_ATTEMPTS = 3;
31
+ /**
32
+ * Default maximum number of local runner harness instances allowed to answer queued messages at once.
33
+ *
34
+ * @private shared Agents Server constant
35
+ */
36
+ export declare const DEFAULT_LOCAL_AGENT_RUNNER_MAX_PARALLEL_MESSAGES = 3;
31
37
  /**
32
38
  * Stable keys used by the dedicated server-limits table.
33
39
  *
@@ -42,6 +48,7 @@ export declare const SERVER_LIMIT_KEYS: {
42
48
  readonly SPAWN_AGENT_RATE_LIMIT_MAX: "SPAWN_AGENT_RATE_LIMIT_MAX";
43
49
  readonly SPAWN_AGENT_RATE_LIMIT_WINDOW_MS: "SPAWN_AGENT_RATE_LIMIT_WINDOW_MS";
44
50
  readonly LOCAL_AGENT_RUNNER_MAX_FAILED_ATTEMPTS: "LOCAL_AGENT_RUNNER_MAX_FAILED_ATTEMPTS";
51
+ readonly LOCAL_AGENT_RUNNER_MAX_PARALLEL_MESSAGES: "LOCAL_AGENT_RUNNER_MAX_PARALLEL_MESSAGES";
45
52
  };
46
53
  /**
47
54
  * One supported dedicated server-limit key.
@@ -156,6 +163,16 @@ export declare const SERVER_LIMIT_DEFINITIONS: ({
156
163
  minimumValue: number;
157
164
  step: number;
158
165
  legacyMetadataKeys: never[];
166
+ } | {
167
+ key: "LOCAL_AGENT_RUNNER_MAX_PARALLEL_MESSAGES";
168
+ category: "Local agent runner";
169
+ title: string;
170
+ description: string;
171
+ unit: "count";
172
+ defaultValue: number;
173
+ minimumValue: number;
174
+ step: number;
175
+ legacyMetadataKeys: never[];
159
176
  })[];
160
177
  /**
161
178
  * Fast lookup map used by server-limit validators and UI helpers.