@redplanethq/corebrain 2.8.30 → 2.8.32

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 (108) hide show
  1. package/dist/assets/assets/AppIcon.icns +0 -0
  2. package/dist/commands/gateway/register.js +1 -1
  3. package/dist/commands/gateway/register.js.map +1 -1
  4. package/dist/server/api/server.d.ts.map +1 -1
  5. package/dist/server/api/server.js +5 -0
  6. package/dist/server/api/server.js.map +1 -1
  7. package/dist/server/api/skills/index.d.ts +9 -0
  8. package/dist/server/api/skills/index.d.ts.map +1 -0
  9. package/dist/server/api/skills/index.js +12 -0
  10. package/dist/server/api/skills/index.js.map +1 -0
  11. package/dist/server/skills/install.d.ts +35 -0
  12. package/dist/server/skills/install.d.ts.map +1 -0
  13. package/dist/server/skills/install.js +182 -0
  14. package/dist/server/skills/install.js.map +1 -0
  15. package/dist/server/skills/skill-store.d.ts +12 -0
  16. package/dist/server/skills/skill-store.d.ts.map +1 -0
  17. package/dist/server/skills/skill-store.js +76 -0
  18. package/dist/server/skills/skill-store.js.map +1 -0
  19. package/dist/server/tools/skills-tools.d.ts +14 -0
  20. package/dist/server/tools/skills-tools.d.ts.map +1 -0
  21. package/dist/server/tools/skills-tools.js +100 -0
  22. package/dist/server/tools/skills-tools.js.map +1 -0
  23. package/dist/tui/chat.d.ts.map +1 -1
  24. package/dist/tui/chat.js +464 -73
  25. package/dist/tui/chat.js.map +1 -1
  26. package/dist/tui/components/approval-bar.d.ts +1 -1
  27. package/dist/tui/components/approval-bar.d.ts.map +1 -1
  28. package/dist/tui/components/approval-bar.js +1 -1
  29. package/dist/tui/components/approval-bar.js.map +1 -1
  30. package/dist/tui/components/approval-panel.d.ts +1 -1
  31. package/dist/tui/components/approval-panel.d.ts.map +1 -1
  32. package/dist/tui/components/approval-panel.js +1 -1
  33. package/dist/tui/components/approval-panel.js.map +1 -1
  34. package/dist/tui/components/context-bar.d.ts +26 -0
  35. package/dist/tui/components/context-bar.d.ts.map +1 -0
  36. package/dist/tui/components/context-bar.js +45 -0
  37. package/dist/tui/components/context-bar.js.map +1 -0
  38. package/dist/tui/components/conversation-selector.d.ts +24 -6
  39. package/dist/tui/components/conversation-selector.d.ts.map +1 -1
  40. package/dist/tui/components/conversation-selector.js +187 -117
  41. package/dist/tui/components/conversation-selector.js.map +1 -1
  42. package/dist/tui/components/integrations-view.d.ts +1 -1
  43. package/dist/tui/components/integrations-view.d.ts.map +1 -1
  44. package/dist/tui/components/integrations-view.js +1 -1
  45. package/dist/tui/components/integrations-view.js.map +1 -1
  46. package/dist/tui/components/scratchpad-panel.d.ts +28 -0
  47. package/dist/tui/components/scratchpad-panel.d.ts.map +1 -0
  48. package/dist/tui/components/scratchpad-panel.js +117 -0
  49. package/dist/tui/components/scratchpad-panel.js.map +1 -0
  50. package/dist/tui/components/split-pane.d.ts +23 -0
  51. package/dist/tui/components/split-pane.d.ts.map +1 -0
  52. package/dist/tui/components/split-pane.js +59 -0
  53. package/dist/tui/components/split-pane.js.map +1 -0
  54. package/dist/tui/components/status-line.d.ts +35 -6
  55. package/dist/tui/components/status-line.d.ts.map +1 -1
  56. package/dist/tui/components/status-line.js +149 -19
  57. package/dist/tui/components/status-line.js.map +1 -1
  58. package/dist/tui/components/task-detail.d.ts +1 -1
  59. package/dist/tui/components/task-detail.d.ts.map +1 -1
  60. package/dist/tui/components/task-detail.js +1 -1
  61. package/dist/tui/components/task-detail.js.map +1 -1
  62. package/dist/tui/components/task-list.d.ts +1 -1
  63. package/dist/tui/components/task-list.d.ts.map +1 -1
  64. package/dist/tui/components/task-list.js +5 -6
  65. package/dist/tui/components/task-list.js.map +1 -1
  66. package/dist/tui/components/task-runs.d.ts +32 -0
  67. package/dist/tui/components/task-runs.d.ts.map +1 -0
  68. package/dist/tui/components/task-runs.js +146 -0
  69. package/dist/tui/components/task-runs.js.map +1 -0
  70. package/dist/tui/components/tool-call-item.d.ts +1 -1
  71. package/dist/tui/components/tool-call-item.d.ts.map +1 -1
  72. package/dist/tui/components/tool-call-item.js +44 -13
  73. package/dist/tui/components/tool-call-item.js.map +1 -1
  74. package/dist/tui/components/widgets-view.d.ts +1 -1
  75. package/dist/tui/components/widgets-view.d.ts.map +1 -1
  76. package/dist/tui/components/widgets-view.js +1 -1
  77. package/dist/tui/components/widgets-view.js.map +1 -1
  78. package/dist/tui/hooks/use-conversation.d.ts.map +1 -1
  79. package/dist/tui/hooks/use-conversation.js +44 -8
  80. package/dist/tui/hooks/use-conversation.js.map +1 -1
  81. package/dist/tui/utils/avatar.d.ts +1 -1
  82. package/dist/tui/utils/avatar.d.ts.map +1 -1
  83. package/dist/tui/utils/avatar.js +1 -1
  84. package/dist/tui/utils/avatar.js.map +1 -1
  85. package/dist/tui/utils/bundle-loader.d.ts.map +1 -1
  86. package/dist/tui/utils/bundle-loader.js +8 -1
  87. package/dist/tui/utils/bundle-loader.js.map +1 -1
  88. package/dist/tui/utils/integration-loader.d.ts +1 -1
  89. package/dist/tui/utils/integration-loader.d.ts.map +1 -1
  90. package/dist/tui/utils/stream.d.ts +75 -13
  91. package/dist/tui/utils/stream.d.ts.map +1 -1
  92. package/dist/tui/utils/stream.js +148 -13
  93. package/dist/tui/utils/stream.js.map +1 -1
  94. package/dist/utils/env-bootstrap.js +1 -1
  95. package/dist/utils/env-bootstrap.js.map +1 -1
  96. package/dist/utils/setup/docker.js +1 -1
  97. package/dist/utils/setup/docker.js.map +1 -1
  98. package/dist/utils/setup/railway.js +1 -1
  99. package/dist/utils/setup/railway.js.map +1 -1
  100. package/package.json +5 -4
  101. package/dist/tui/components/dashboard-view.d.ts +0 -20
  102. package/dist/tui/components/dashboard-view.d.ts.map +0 -1
  103. package/dist/tui/components/dashboard-view.js +0 -147
  104. package/dist/tui/components/dashboard-view.js.map +0 -1
  105. package/dist/tui/components/reminder-list.d.ts +0 -28
  106. package/dist/tui/components/reminder-list.d.ts.map +0 -1
  107. package/dist/tui/components/reminder-list.js +0 -261
  108. package/dist/tui/components/reminder-list.js.map +0 -1
package/dist/tui/chat.js CHANGED
@@ -1,21 +1,23 @@
1
- import { TUI, Text, Editor, ProcessTerminal, Markdown, Loader, Spacer, Container, CombinedAutocompleteProvider, matchesKey, Key, } from '@mariozechner/pi-tui';
1
+ import { TUI, Text, Editor, ProcessTerminal, Markdown, Loader, Spacer, Container, CombinedAutocompleteProvider, matchesKey, Key, } from '@earendil-works/pi-tui';
2
2
  import { buildAvatar } from './utils/avatar.js';
3
3
  import chalk from 'chalk';
4
4
  import { StatusLine } from './components/status-line.js';
5
5
  import { editorTheme, markdownTheme } from './themes.js';
6
6
  import { createConversation } from './hooks/use-conversation.js';
7
7
  import { ConversationSelector } from './components/conversation-selector.js';
8
- import { ReminderList } from './components/reminder-list.js';
9
8
  import { TaskList } from './components/task-list.js';
10
9
  import { TaskDetail } from './components/task-detail.js';
11
10
  import { IntegrationsView } from './components/integrations-view.js';
12
11
  import { WidgetsView } from './components/widgets-view.js';
13
- import { DashboardView } from './components/dashboard-view.js';
14
12
  import { loadWidgetBundle } from './utils/widget-loader.js';
15
13
  import { getPreferences } from '../config/preferences.js';
16
- import { fetchConversationHistory, fetchWorkspace, fetchIntegrationAccounts, createTaskApi } from './utils/stream.js';
14
+ import { fetchConversationHistory, fetchWorkspace, fetchIntegrationAccounts, createTaskApi, isRecurringTask, summariseCatchup, fetchCredits, NoCreditsError } from './utils/stream.js';
17
15
  import { getToolDisplayName } from './utils/tool-names.js';
18
16
  import { ApprovalPanel } from './components/approval-panel.js';
17
+ import { ContextBar } from './components/context-bar.js';
18
+ import { SplitPane } from './components/split-pane.js';
19
+ import { ScratchpadPanel } from './components/scratchpad-panel.js';
20
+ import { TaskRuns } from './components/task-runs.js';
19
21
  export function startTuiApp(baseUrl, apiKey, version) {
20
22
  const terminal = new ProcessTerminal();
21
23
  const tui = new TUI(terminal);
@@ -28,20 +30,25 @@ export function startTuiApp(baseUrl, apiKey, version) {
28
30
  tui.addChild(new Spacer(1));
29
31
  // ── Messages area ─────────────────────────────────────────────────────────
30
32
  const messagesContainer = new Container();
31
- tui.addChild(messagesContainer);
32
- // ── Task title bar (shown above editor when in a task conversation) ─────
33
- const taskTitleContainer = new Container();
34
- tui.addChild(taskTitleContainer);
33
+ // SplitPane wraps messages so we can slot a scratchpad panel on the left
34
+ // half when `/today` is active. Without a left child it renders the right
35
+ // child at full width, i.e. behaves exactly like the plain container.
36
+ const messagesSplit = new SplitPane(messagesContainer);
37
+ tui.addChild(messagesSplit);
38
+ // ── Context bar (shown above editor when a task/scratchpad is active) ────
39
+ const contextBar = new ContextBar();
40
+ tui.addChild(contextBar);
35
41
  // ── Editor ────────────────────────────────────────────────────────────────
36
42
  const editor = new Editor(tui, editorTheme);
37
43
  editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
44
+ { name: 'new-task', description: 'Create a new task from the next message' },
45
+ { name: 'today', description: 'Open today’s scratchpad' },
46
+ { name: 'catchup', description: 'Summarise unread inbox items' },
38
47
  { name: 'clear', description: 'Clear conversation and start fresh' },
39
48
  { name: 'resume', description: 'Resume a previous conversation' },
40
- { name: 'reminders', description: 'View your reminders' },
41
49
  { name: 'tasks', description: 'View and manage your tasks' },
42
50
  { name: 'integrations', description: 'View and connect integrations' },
43
51
  { name: 'widgets', description: 'Configure widgets (below-input & overview)' },
44
- { name: 'dashboard', description: 'Show overview widgets in TUI' },
45
52
  {
46
53
  name: 'incognito',
47
54
  description: 'Toggle incognito mode (new conversations only)',
@@ -49,8 +56,28 @@ export function startTuiApp(baseUrl, apiKey, version) {
49
56
  { name: 'exit', description: 'Exit CORE' },
50
57
  ], process.cwd()));
51
58
  tui.addChild(editor);
52
- // ── Status line (incognito + below-input widget on one row) ──────────────
53
- const statusLine = new StatusLine();
59
+ // ── Status line badges + tasks + widget + G + C (focusable, 2 rows)
60
+ const statusLine = new StatusLine(baseUrl, apiKey, () => tui.requestRender());
61
+ statusLine.onBlur = () => {
62
+ tui.setFocus(editor);
63
+ tui.requestRender();
64
+ };
65
+ statusLine.onActivate = (zone) => {
66
+ tui.setFocus(editor);
67
+ if (zone === 'tasks') {
68
+ showTaskList();
69
+ }
70
+ else if (zone === 'catchup') {
71
+ runCatchup();
72
+ }
73
+ else if (zone === 'gateways') {
74
+ addToMessages(new Text(chalk.dim('Manage gateways: run ') +
75
+ chalk.white('corebrain gateway list') +
76
+ chalk.dim(' outside the chat.'), 1, 0));
77
+ addToMessages(new Spacer(1));
78
+ tui.requestRender();
79
+ }
80
+ };
54
81
  tui.addChild(statusLine);
55
82
  async function loadBelowInputWidget() {
56
83
  statusLine.setWidget(null);
@@ -108,6 +135,44 @@ export function startTuiApp(baseUrl, apiKey, version) {
108
135
  let autoApproveAll = false;
109
136
  let currentTask = null;
110
137
  let taskInfoOverlay = null;
138
+ // Pending catchup summary that will seed a new chat when the user presses `c`.
139
+ let pendingCatchupSummary = null;
140
+ // Container holding the catchup summary + hints while catchup view is active.
141
+ let catchupViewContainer = null;
142
+ // Active scratchpad side-panel (set while `/today` is in effect).
143
+ let scratchpadPanel = null;
144
+ // Cached credit balance for the workspace. `null` = not fetched yet.
145
+ // The webapp's `/api/v1/conversation` and `/api/v1/conversation/create`
146
+ // endpoints both server-gate the same check; we mirror it client-side so
147
+ // the input border can turn red immediately instead of waiting for the
148
+ // user to type + submit and see a 402.
149
+ let creditsInfo = null;
150
+ // Which context is driving the "normal" border colour right now. Kept
151
+ // separate from `contextBar.getContext()` because scratchpad mode hides
152
+ // the bar visually but still wants a yellow border.
153
+ let activeBorderKind = null;
154
+ function outOfCredits() {
155
+ if (!creditsInfo)
156
+ return false;
157
+ if (!creditsInfo.billingEnabled)
158
+ return false;
159
+ if (creditsInfo.byok)
160
+ return false;
161
+ return creditsInfo.available <= 0;
162
+ }
163
+ function refreshBorder() {
164
+ editor.borderColor = outOfCredits()
165
+ ? chalk.red
166
+ : ContextBar.borderColor(activeBorderKind);
167
+ tui.requestRender();
168
+ }
169
+ async function refreshCredits() {
170
+ const info = await fetchCredits(baseUrl, apiKey);
171
+ if (info) {
172
+ creditsInfo = info;
173
+ refreshBorder();
174
+ }
175
+ }
111
176
  const conversation = createConversation(baseUrl, apiKey);
112
177
  let avatarComponent = buildAvatar(butlerName, workspaceAccent);
113
178
  avatarContainer.addChild(avatarComponent);
@@ -132,6 +197,12 @@ export function startTuiApp(baseUrl, apiKey, version) {
132
197
  }
133
198
  })
134
199
  .catch(() => { });
200
+ // Initial credit probe + 60s poll so a top-up done in the webapp reflects
201
+ // back in the CLI within a minute (mirrors the webapp's polling cadence).
202
+ refreshCredits().catch(() => { });
203
+ setInterval(() => {
204
+ refreshCredits().catch(() => { });
205
+ }, 60_000);
135
206
  const loader = new Loader(tui, s => chalk.cyan(s), s => chalk.gray(s), 'Thinking...');
136
207
  // ── Helpers ───────────────────────────────────────────────────────────────
137
208
  function addToMessages(component) {
@@ -140,15 +211,23 @@ export function startTuiApp(baseUrl, apiKey, version) {
140
211
  }
141
212
  function insertBeforeLoader(component) {
142
213
  conversationComponents.push(component);
143
- // Remove loader, add component, re-add loader
144
- try {
145
- messagesContainer.removeChild(loader);
146
- }
147
- catch {
148
- // loader not in tree yet
214
+ // Insert new content ABOVE the loader when it's visible, otherwise
215
+ // just append. Do NOT re-add the loader here — that used to bypass
216
+ // the loaderVisible flag and stranded the loader in the tree after
217
+ // removeLoader() had already run, which the caller couldn't clear.
218
+ const loaderInTree = loaderVisible;
219
+ if (loaderInTree) {
220
+ try {
221
+ messagesContainer.removeChild(loader);
222
+ }
223
+ catch {
224
+ // ignore
225
+ }
149
226
  }
150
227
  messagesContainer.addChild(component);
151
- messagesContainer.addChild(loader);
228
+ if (loaderInTree) {
229
+ messagesContainer.addChild(loader);
230
+ }
152
231
  }
153
232
  function toggleIncognito() {
154
233
  if (conversation.conversationId !== null) {
@@ -160,17 +239,27 @@ export function startTuiApp(baseUrl, apiKey, version) {
160
239
  statusLine.setIncognito(conversation.incognito);
161
240
  tui.requestRender();
162
241
  }
242
+ function applyContext(info) {
243
+ contextBar.setContext(info);
244
+ activeBorderKind = info?.kind ?? null;
245
+ refreshBorder();
246
+ }
163
247
  function setTaskContext(task) {
164
248
  currentTask = task;
165
- taskTitleContainer.clear();
166
- if (task) {
167
- const id = task.displayId ? chalk.dim(`[${task.displayId}] `) : '';
168
- const title = chalk.bold.white(task.title || 'Untitled');
169
- const status = chalk.cyan(`(${task.status})`);
170
- const hint = chalk.dim(' ctrl+o info');
171
- taskTitleContainer.addChild(new Text(`${id}${title} ${status}${hint}`, 1, 0));
249
+ if (!task) {
250
+ applyContext(null);
251
+ return;
172
252
  }
173
- tui.requestRender();
253
+ const pill = task.displayId ?? 'TASK';
254
+ const hint = isRecurringTask(task)
255
+ ? 'ctrl+o info · ctrl+r runs'
256
+ : 'ctrl+o info';
257
+ applyContext({
258
+ kind: 'task',
259
+ pill,
260
+ subtitle: `${task.title || 'Untitled'} (${task.status})`,
261
+ hint,
262
+ });
174
263
  }
175
264
  function clearConversation() {
176
265
  requestId++; // invalidate any in-flight callbacks
@@ -234,10 +323,6 @@ export function startTuiApp(baseUrl, apiKey, version) {
234
323
  showResumeSelector();
235
324
  return;
236
325
  }
237
- if (trimmed === '/reminders') {
238
- showReminderList();
239
- return;
240
- }
241
326
  if (trimmed === '/tasks') {
242
327
  showTaskList();
243
328
  return;
@@ -250,8 +335,16 @@ export function startTuiApp(baseUrl, apiKey, version) {
250
335
  showWidgetsView();
251
336
  return;
252
337
  }
253
- if (trimmed === '/dashboard') {
254
- showDashboardView();
338
+ if (trimmed === '/new-task') {
339
+ setNewTaskMode(true);
340
+ return;
341
+ }
342
+ if (trimmed === '/catchup') {
343
+ runCatchup();
344
+ return;
345
+ }
346
+ if (trimmed === '/today') {
347
+ openScratchpad();
255
348
  return;
256
349
  }
257
350
  if (trimmed === '/incognito') {
@@ -259,6 +352,7 @@ export function startTuiApp(baseUrl, apiKey, version) {
259
352
  return;
260
353
  }
261
354
  if (trimmed === '/exit') {
355
+ statusLine.dispose();
262
356
  tui.stop();
263
357
  process.stdout.write('\n');
264
358
  process.exit(0);
@@ -268,13 +362,15 @@ export function startTuiApp(baseUrl, apiKey, version) {
268
362
  runMessage(trimmed);
269
363
  };
270
364
  function hideMainUI() {
365
+ if (statusLine.focused)
366
+ tui.setFocus(editor);
271
367
  try {
272
368
  tui.removeChild(statusLine);
273
369
  }
274
370
  catch { /* ignore */ }
275
- tui.removeChild(messagesContainer);
371
+ tui.removeChild(messagesSplit);
276
372
  try {
277
- tui.removeChild(taskTitleContainer);
373
+ tui.removeChild(contextBar);
278
374
  }
279
375
  catch { /* ignore */ }
280
376
  tui.removeChild(editor);
@@ -282,8 +378,8 @@ export function startTuiApp(baseUrl, apiKey, version) {
282
378
  }
283
379
  function restoreMainUI() {
284
380
  overlayActive = false;
285
- tui.addChild(messagesContainer);
286
- tui.addChild(taskTitleContainer);
381
+ tui.addChild(messagesSplit);
382
+ tui.addChild(contextBar);
287
383
  tui.addChild(editor);
288
384
  tui.addChild(statusLine);
289
385
  tui.setFocus(editor);
@@ -338,17 +434,27 @@ export function startTuiApp(baseUrl, apiKey, version) {
338
434
  : chalk.green('●');
339
435
  const header = `${dot} ${chalk.bold(displayName)}${argSummary ? chalk.dim(' (' + argSummary + ')') : ''}`;
340
436
  addToMessages(new Text(header, 1, 0));
341
- // Result preview (first 3 non-empty lines)
437
+ // Result preview up to 2 non-empty lines, HTML stripped, prefixed
438
+ // with `│` / `└─` to match the streaming ToolCallItem style.
342
439
  const resultStr = stringifyToolOutput(part.output);
343
440
  if (resultStr) {
344
- const previewLines = resultStr
441
+ const allLines = resultStr
345
442
  .split('\n')
346
- .filter(l => l.trim().length > 0)
347
- .slice(0, 3);
348
- for (const line of previewLines) {
349
- addToMessages(new Text(chalk.dim(' ' + line), 1, 0));
443
+ .map(cleanPreviewLine)
444
+ .filter(l => l.length > 0);
445
+ const previewLines = allLines.slice(0, 2);
446
+ const extra = allLines.length - previewLines.length;
447
+ previewLines.forEach((line, idx) => {
448
+ const isLast = idx === previewLines.length - 1 && extra === 0;
449
+ const prefix = isLast ? ' └ ' : ' │ ';
450
+ addToMessages(new Text(chalk.dim(prefix + line), 1, 0));
451
+ });
452
+ if (extra > 0) {
453
+ addToMessages(new Text(chalk.dim(` └ +${extra} more lines`), 1, 0));
350
454
  }
351
455
  }
456
+ // Breathing room so consecutive tool calls don't run together.
457
+ addToMessages(new Spacer(1));
352
458
  any = true;
353
459
  continue;
354
460
  }
@@ -357,6 +463,26 @@ export function startTuiApp(baseUrl, apiKey, version) {
357
463
  }
358
464
  return any;
359
465
  }
466
+ /**
467
+ * Turn one line of a tool's raw output into something that scans in the
468
+ * preview strip: HTML tags stripped, whitespace collapsed, capped to a
469
+ * readable width. Preserves tag *content* so `<p>hello</p>` becomes `hello`
470
+ * (not empty), which is important for HTML-heavy tools like get_scratchpad.
471
+ */
472
+ function cleanPreviewLine(raw) {
473
+ const stripped = raw
474
+ .replace(/<[^>]+>/g, ' ')
475
+ .replace(/&nbsp;/gi, ' ')
476
+ .replace(/&amp;/gi, '&')
477
+ .replace(/&lt;/gi, '<')
478
+ .replace(/&gt;/gi, '>')
479
+ .replace(/&quot;/gi, '"')
480
+ .replace(/&#39;/gi, "'");
481
+ const collapsed = stripped.replace(/\s+/g, ' ').trim();
482
+ if (collapsed.length <= 120)
483
+ return collapsed;
484
+ return collapsed.slice(0, 120) + '…';
485
+ }
360
486
  function summarizeArgs(input) {
361
487
  if (!input || typeof input !== 'object')
362
488
  return '';
@@ -429,16 +555,6 @@ export function startTuiApp(baseUrl, apiKey, version) {
429
555
  loadConversationHistoryIntoMessages(conv.id);
430
556
  };
431
557
  }
432
- function showReminderList() {
433
- hideMainUI();
434
- const list = new ReminderList(baseUrl, apiKey, tui, () => tui.requestRender());
435
- tui.addChild(list);
436
- tui.setFocus(list);
437
- list.onCancel = () => {
438
- tui.removeChild(list);
439
- restoreMainUI();
440
- };
441
- }
442
558
  function showTaskList() {
443
559
  hideMainUI();
444
560
  const list = new TaskList(baseUrl, apiKey, tui, () => tui.requestRender());
@@ -507,6 +623,30 @@ export function startTuiApp(baseUrl, apiKey, version) {
507
623
  restoreMainUI();
508
624
  };
509
625
  }
626
+ function showTaskRunsOverlay(task) {
627
+ hideMainUI();
628
+ const runsView = new TaskRuns(baseUrl, apiKey, task, tui, () => tui.requestRender());
629
+ tui.addChild(runsView);
630
+ tui.setFocus(runsView);
631
+ runsView.onCancel = () => {
632
+ try {
633
+ tui.removeChild(runsView);
634
+ }
635
+ catch { /* ignore */ }
636
+ restoreMainUI();
637
+ };
638
+ runsView.onOpenRun = run => {
639
+ try {
640
+ tui.removeChild(runsView);
641
+ }
642
+ catch { /* ignore */ }
643
+ // Swap the active conversation to the picked run. `openTaskConversation`
644
+ // already handles clearing state, setting the task context, and
645
+ // loading history — reuse it so the flow matches picking a run from
646
+ // `/tasks`.
647
+ openTaskConversation(task, run.id);
648
+ };
649
+ }
510
650
  function showIntegrationsView() {
511
651
  hideMainUI();
512
652
  const view = new IntegrationsView(baseUrl, apiKey, tui, () => tui.requestRender());
@@ -528,17 +668,135 @@ export function startTuiApp(baseUrl, apiKey, version) {
528
668
  loadBelowInputWidget().then(() => restoreMainUI()).catch(() => restoreMainUI());
529
669
  };
530
670
  }
531
- function showDashboardView() {
671
+ // ── Scratchpad (/today) ───────────────────────────────────────────────────
672
+ function updateScratchpadContextBar() {
673
+ if (!scratchpadPanel) {
674
+ // If a task was active, keep its context; otherwise clear.
675
+ if (currentTask)
676
+ setTaskContext(currentTask);
677
+ else
678
+ applyContext(null);
679
+ return;
680
+ }
681
+ // The ScratchpadPanel already shows the date + nav hints, so an extra
682
+ // context-bar row is just repetition. Keep the visual bar empty but
683
+ // drive the border colour off `activeBorderKind = 'scratchpad'`.
684
+ contextBar.setContext(null);
685
+ activeBorderKind = 'scratchpad';
686
+ refreshBorder();
687
+ }
688
+ function openScratchpad() {
689
+ if (scratchpadPanel) {
690
+ const currentDate = scratchpadPanel.getDate();
691
+ const today = new Date();
692
+ const onToday = currentDate.getUTCFullYear() === today.getUTCFullYear() &&
693
+ currentDate.getUTCMonth() === today.getUTCMonth() &&
694
+ currentDate.getUTCDate() === today.getUTCDate();
695
+ if (onToday) {
696
+ // Toggle off — /today acts as a "close scratchpad" too.
697
+ closeScratchpad();
698
+ return;
699
+ }
700
+ scratchpadPanel.setDate(today);
701
+ updateScratchpadContextBar();
702
+ tui.requestRender();
703
+ return;
704
+ }
705
+ scratchpadPanel = new ScratchpadPanel(baseUrl, apiKey, () => tui.requestRender());
706
+ scratchpadPanel.onDateChange = () => updateScratchpadContextBar();
707
+ messagesSplit.setLeft(scratchpadPanel);
708
+ updateScratchpadContextBar();
709
+ tui.requestRender();
710
+ }
711
+ function closeScratchpad() {
712
+ if (!scratchpadPanel)
713
+ return;
714
+ scratchpadPanel = null;
715
+ messagesSplit.setLeft(null);
716
+ updateScratchpadContextBar();
717
+ tui.requestRender();
718
+ }
719
+ // ── Catchup ───────────────────────────────────────────────────────────────
720
+ function showCatchupView(summary, count) {
532
721
  hideMainUI();
533
- const view = new DashboardView(baseUrl, apiKey, tui, () => tui.requestRender());
534
- tui.addChild(view);
535
- tui.setFocus(view);
536
- view.onCancel = () => {
537
- tui.removeChild(view);
722
+ pendingCatchupSummary = summary;
723
+ const container = new Container();
724
+ catchupViewContainer = container;
725
+ container.addChild(new Spacer(1));
726
+ container.addChild(new Text(chalk.bold.cyan('Catchup') +
727
+ chalk.dim(` · ${count} item${count === 1 ? '' : 's'}`), 1, 0));
728
+ container.addChild(new Spacer(1));
729
+ container.addChild(new Markdown(summary, 1, 0, markdownTheme));
730
+ container.addChild(new Spacer(2));
731
+ container.addChild(new Text(chalk.bold.white(' c') +
732
+ chalk.dim(' continue in a new chat') +
733
+ chalk.dim(' ') +
734
+ chalk.bold.white('esc') +
735
+ chalk.dim(' close'), 1, 0));
736
+ container.addChild(new Spacer(1));
737
+ tui.addChild(container);
738
+ tui.setFocus(container);
739
+ tui.requestRender();
740
+ }
741
+ function closeCatchupView() {
742
+ if (!catchupViewContainer)
743
+ return;
744
+ try {
745
+ tui.removeChild(catchupViewContainer);
746
+ }
747
+ catch { /* ignore */ }
748
+ catchupViewContainer = null;
749
+ pendingCatchupSummary = null;
750
+ restoreMainUI();
751
+ }
752
+ function showCatchupEmptyToast() {
753
+ addToMessages(new Text(chalk.dim('Nothing to catch up on.'), 1, 0));
754
+ addToMessages(new Spacer(1));
755
+ tui.requestRender();
756
+ }
757
+ function runCatchup() {
758
+ if (isProcessing || catchupViewContainer)
759
+ return;
760
+ pendingCatchupSummary = null;
761
+ showLoader();
762
+ summariseCatchup(baseUrl, apiKey)
763
+ .then(({ summary, count }) => {
764
+ removeLoader();
765
+ if (count === 0 || !summary) {
766
+ showCatchupEmptyToast();
767
+ return;
768
+ }
769
+ showCatchupView(summary, count);
770
+ })
771
+ .catch((err) => {
772
+ removeLoader();
773
+ addToMessages(new Text(chalk.red('Catchup failed: ') + chalk.dim(err.message), 1, 0));
774
+ addToMessages(new Spacer(1));
775
+ tui.requestRender();
776
+ });
777
+ }
778
+ function continueCatchupInNewChat() {
779
+ if (!pendingCatchupSummary)
780
+ return;
781
+ const seed = pendingCatchupSummary;
782
+ if (catchupViewContainer) {
783
+ try {
784
+ tui.removeChild(catchupViewContainer);
785
+ }
786
+ catch { /* ignore */ }
787
+ catchupViewContainer = null;
538
788
  restoreMainUI();
539
- };
789
+ }
790
+ pendingCatchupSummary = null;
791
+ clearConversation();
792
+ editor.setText(seed + '\n\n');
793
+ tui.setFocus(editor);
794
+ tui.requestRender();
540
795
  }
796
+ let loaderVisible = false;
541
797
  function removeLoader() {
798
+ if (!loaderVisible)
799
+ return;
542
800
  const idx = conversationComponents.lastIndexOf(loader);
543
801
  if (idx !== -1)
544
802
  conversationComponents.splice(idx, 1);
@@ -549,18 +807,40 @@ export function startTuiApp(baseUrl, apiKey, version) {
549
807
  // ignore
550
808
  }
551
809
  loader.stop();
810
+ loaderVisible = false;
552
811
  }
553
812
  function showLoader() {
813
+ if (loaderVisible) {
814
+ // Already in the tree — just restart the animation in case a
815
+ // previous stop() cleared the interval.
816
+ loader.stop();
817
+ loader.start();
818
+ return;
819
+ }
554
820
  conversationComponents.push(loader);
555
821
  messagesContainer.addChild(loader);
556
- loader.stop(); // clear any existing interval before restarting
822
+ loader.stop();
557
823
  loader.start();
824
+ loaderVisible = true;
825
+ }
826
+ function showOutOfCreditsToast() {
827
+ addToMessages(new Text(chalk.red('You’re out of credits. ') +
828
+ chalk.dim('Top up at ') +
829
+ chalk.white(baseUrl + '/settings/billing'), 1, 0));
830
+ addToMessages(new Spacer(1));
558
831
  }
559
832
  function runMessage(message) {
833
+ if (outOfCredits()) {
834
+ showOutOfCreditsToast();
835
+ tui.requestRender();
836
+ return;
837
+ }
560
838
  isProcessing = true;
561
839
  editor.disableSubmit = true;
562
840
  const myRequestId = ++requestId;
563
- // User bubble
841
+ // User bubble — leading blank line so consecutive turns don't run
842
+ // into the previous assistant reply.
843
+ addToMessages(new Spacer(1));
564
844
  addToMessages(new Text(chalk.bgHex('#3a3a3a').white(' \u276f ' + message + ' '), 0, 0));
565
845
  addToMessages(new Spacer(1));
566
846
  showLoader();
@@ -568,6 +848,42 @@ export function startTuiApp(baseUrl, apiKey, version) {
568
848
  let accumulated = '';
569
849
  let markdownInserted = false;
570
850
  let hadOutput = false;
851
+ let hadErrorMessage = false;
852
+ let finishFired = false;
853
+ // Safety net: if the server never sends `finish` and stops emitting
854
+ // events for this many ms after any output, treat the turn as done.
855
+ // Otherwise the editor stays disabled forever and the user is stuck.
856
+ const IDLE_TIMEOUT_MS = 5_000;
857
+ let idleTimer = null;
858
+ function clearIdleTimer() {
859
+ if (idleTimer) {
860
+ clearTimeout(idleTimer);
861
+ idleTimer = null;
862
+ }
863
+ }
864
+ function fireFinish() {
865
+ if (requestId !== myRequestId)
866
+ return;
867
+ clearIdleTimer();
868
+ removeLoader();
869
+ if (!finishFired) {
870
+ finishFired = true;
871
+ if (!hadOutput && !hadErrorMessage) {
872
+ addToMessages(new Text(chalk.gray('(no response)'), 1, 0));
873
+ addToMessages(new Spacer(1));
874
+ }
875
+ scratchpadPanel?.refresh().catch(() => { });
876
+ }
877
+ isProcessing = false;
878
+ editor.disableSubmit = false;
879
+ tui.requestRender();
880
+ }
881
+ function armIdleTimer() {
882
+ clearIdleTimer();
883
+ if (!hadOutput)
884
+ return; // wait until at least one event before arming
885
+ idleTimer = setTimeout(fireFinish, IDLE_TIMEOUT_MS);
886
+ }
571
887
  const callbacks = {
572
888
  onTextDelta(delta) {
573
889
  if (requestId !== myRequestId)
@@ -578,8 +894,11 @@ export function startTuiApp(baseUrl, apiKey, version) {
578
894
  insertBeforeLoader(responseMd);
579
895
  markdownInserted = true;
580
896
  }
897
+ // Re-show the loader if it was hidden after a previous step.
898
+ showLoader();
581
899
  responseMd.setText(accumulated);
582
900
  tui.requestRender();
901
+ armIdleTimer();
583
902
  },
584
903
  onToolStart(_id, _name, item) {
585
904
  if (requestId !== myRequestId)
@@ -587,33 +906,35 @@ export function startTuiApp(baseUrl, apiKey, version) {
587
906
  hadOutput = true;
588
907
  allToolItems.push(item);
589
908
  insertBeforeLoader(item);
909
+ showLoader();
590
910
  tui.requestRender();
911
+ armIdleTimer();
591
912
  },
592
913
  onRerender() {
593
914
  if (requestId !== myRequestId)
594
915
  return;
595
916
  tui.requestRender();
917
+ armIdleTimer();
596
918
  },
597
919
  onStepFinish() {
598
920
  if (requestId !== myRequestId)
599
921
  return;
922
+ // The visible turn is done — server-side bookkeeping
923
+ // (persistence, token accounting) can still delay `finish`
924
+ // by seconds. Drop the spinner so the UI reads as "reply
925
+ // delivered". It'll be re-shown by the next text/tool event
926
+ // if another step runs.
927
+ removeLoader();
600
928
  tui.requestRender();
929
+ armIdleTimer();
601
930
  },
602
931
  onFinish() {
603
- if (requestId !== myRequestId)
604
- return;
605
- removeLoader();
606
- if (!hadOutput) {
607
- addToMessages(new Text(chalk.gray('(no response)'), 1, 0));
608
- }
609
- addToMessages(new Spacer(1));
610
- isProcessing = false;
611
- editor.disableSubmit = false;
612
- tui.requestRender();
932
+ fireFinish();
613
933
  },
614
934
  onAbort() {
615
935
  if (requestId !== myRequestId)
616
936
  return;
937
+ clearIdleTimer();
617
938
  removeLoader();
618
939
  // Remove pending approval panel if present
619
940
  if (pendingApprovalPanel) {
@@ -637,8 +958,31 @@ export function startTuiApp(baseUrl, apiKey, version) {
637
958
  onError(err) {
638
959
  if (requestId !== myRequestId)
639
960
  return;
961
+ clearIdleTimer();
640
962
  removeLoader();
641
- addToMessages(new Text(chalk.red('Error: ') + chalk.gray(err.message), 1, 0));
963
+ hadErrorMessage = true;
964
+ const code = err.code;
965
+ const isNoCredits = err instanceof NoCreditsError || code === 'no_credits';
966
+ if (isNoCredits) {
967
+ // Server told us we're out — flip local state so the border
968
+ // turns red immediately and future submits short-circuit.
969
+ if (creditsInfo)
970
+ creditsInfo.available = 0;
971
+ refreshBorder();
972
+ addToMessages(new Text(chalk.red('You’re out of credits. ') +
973
+ chalk.dim('Top up at ') +
974
+ chalk.white(baseUrl + '/settings/billing'), 1, 0));
975
+ }
976
+ else {
977
+ // Empty message often means the server emitted a bare SSE
978
+ // `error` event with no payload — surface something
979
+ // actionable instead of a lone "Error:" line.
980
+ const message = err.message?.trim() || 'Stream ended unexpectedly (no error message from server).';
981
+ addToMessages(new Text(chalk.red('Error: ') + chalk.gray(message), 1, 0));
982
+ // Any stream failure could be a credit gate that raced past
983
+ // our poll — refresh so the border reflects reality.
984
+ refreshCredits().catch(() => { });
985
+ }
642
986
  addToMessages(new Spacer(1));
643
987
  isProcessing = false;
644
988
  editor.disableSubmit = false;
@@ -703,6 +1047,7 @@ export function startTuiApp(baseUrl, apiKey, version) {
703
1047
  // ── Global input ──────────────────────────────────────────────────────────
704
1048
  tui.addInputListener(data => {
705
1049
  if (data === '\x03') {
1050
+ statusLine.dispose();
706
1051
  tui.stop();
707
1052
  process.stdout.write('\n');
708
1053
  process.exit(0);
@@ -777,6 +1122,43 @@ export function startTuiApp(baseUrl, apiKey, version) {
777
1122
  setNewTaskMode(false);
778
1123
  return { consume: true };
779
1124
  }
1125
+ // ── Down arrow on empty editor → focus status line zone selector ───
1126
+ if (!overlayActive &&
1127
+ !isProcessing &&
1128
+ !newTaskMode &&
1129
+ !statusLine.focused &&
1130
+ matchesKey(data, Key.down) &&
1131
+ editor.getText().length === 0) {
1132
+ statusLine.setSelectedZone('tasks');
1133
+ tui.setFocus(statusLine);
1134
+ tui.requestRender();
1135
+ return { consume: true };
1136
+ }
1137
+ // ── Catchup view input (only when the modal view is up) ────────────
1138
+ if (catchupViewContainer) {
1139
+ if (data === 'c' || data === 'C') {
1140
+ continueCatchupInNewChat();
1141
+ return { consume: true };
1142
+ }
1143
+ if (matchesKey(data, Key.escape)) {
1144
+ closeCatchupView();
1145
+ return { consume: true };
1146
+ }
1147
+ // Block other keys while the catchup modal is up — no editor,
1148
+ // no accidental slash-commands.
1149
+ return { consume: true };
1150
+ }
1151
+ // ── Scratchpad day nav (Ctrl+P prev · Ctrl+N next) ─────────────────
1152
+ if (scratchpadPanel && !overlayActive && !isProcessing && !statusLine.focused) {
1153
+ if (matchesKey(data, Key.ctrl('p'))) {
1154
+ scratchpadPanel.shiftDays(-1);
1155
+ return { consume: true };
1156
+ }
1157
+ if (matchesKey(data, Key.ctrl('n'))) {
1158
+ scratchpadPanel.shiftDays(1);
1159
+ return { consume: true };
1160
+ }
1161
+ }
780
1162
  if (matchesKey(data, Key.ctrl('o'))) {
781
1163
  if (taskInfoOverlay) {
782
1164
  taskInfoOverlay.onCancel?.();
@@ -795,6 +1177,15 @@ export function startTuiApp(baseUrl, apiKey, version) {
795
1177
  }
796
1178
  return;
797
1179
  }
1180
+ // ── Ctrl+R → show runs of the current recurring task ──────────────
1181
+ if (!overlayActive &&
1182
+ !isProcessing &&
1183
+ currentTask &&
1184
+ isRecurringTask(currentTask) &&
1185
+ matchesKey(data, Key.ctrl('r'))) {
1186
+ showTaskRunsOverlay(currentTask);
1187
+ return { consume: true };
1188
+ }
798
1189
  if (!overlayActive && matchesKey(data, Key.ctrl('i'))) {
799
1190
  toggleIncognito();
800
1191
  return;