@redplanethq/corebrain 2.8.29 → 2.8.31

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/commands/gateway/register.js +1 -1
  2. package/dist/commands/gateway/register.js.map +1 -1
  3. package/dist/server/api/server.d.ts.map +1 -1
  4. package/dist/server/api/server.js +5 -0
  5. package/dist/server/api/server.js.map +1 -1
  6. package/dist/server/api/skills/index.d.ts +9 -0
  7. package/dist/server/api/skills/index.d.ts.map +1 -0
  8. package/dist/server/api/skills/index.js +12 -0
  9. package/dist/server/api/skills/index.js.map +1 -0
  10. package/dist/server/coding-session-watcher.d.ts +4 -3
  11. package/dist/server/coding-session-watcher.d.ts.map +1 -1
  12. package/dist/server/coding-session-watcher.js +5 -13
  13. package/dist/server/coding-session-watcher.js.map +1 -1
  14. package/dist/server/skills/install.d.ts +35 -0
  15. package/dist/server/skills/install.d.ts.map +1 -0
  16. package/dist/server/skills/install.js +182 -0
  17. package/dist/server/skills/install.js.map +1 -0
  18. package/dist/server/skills/skill-store.d.ts +12 -0
  19. package/dist/server/skills/skill-store.d.ts.map +1 -0
  20. package/dist/server/skills/skill-store.js +76 -0
  21. package/dist/server/skills/skill-store.js.map +1 -0
  22. package/dist/server/tools/skills-tools.d.ts +14 -0
  23. package/dist/server/tools/skills-tools.d.ts.map +1 -0
  24. package/dist/server/tools/skills-tools.js +100 -0
  25. package/dist/server/tools/skills-tools.js.map +1 -0
  26. package/dist/tui/chat.d.ts.map +1 -1
  27. package/dist/tui/chat.js +390 -57
  28. package/dist/tui/chat.js.map +1 -1
  29. package/dist/tui/components/approval-bar.d.ts +1 -1
  30. package/dist/tui/components/approval-bar.d.ts.map +1 -1
  31. package/dist/tui/components/approval-bar.js +1 -1
  32. package/dist/tui/components/approval-bar.js.map +1 -1
  33. package/dist/tui/components/approval-panel.d.ts +1 -1
  34. package/dist/tui/components/approval-panel.d.ts.map +1 -1
  35. package/dist/tui/components/approval-panel.js +1 -1
  36. package/dist/tui/components/approval-panel.js.map +1 -1
  37. package/dist/tui/components/context-bar.d.ts +26 -0
  38. package/dist/tui/components/context-bar.d.ts.map +1 -0
  39. package/dist/tui/components/context-bar.js +45 -0
  40. package/dist/tui/components/context-bar.js.map +1 -0
  41. package/dist/tui/components/conversation-selector.d.ts +24 -6
  42. package/dist/tui/components/conversation-selector.d.ts.map +1 -1
  43. package/dist/tui/components/conversation-selector.js +187 -117
  44. package/dist/tui/components/conversation-selector.js.map +1 -1
  45. package/dist/tui/components/integrations-view.d.ts +1 -1
  46. package/dist/tui/components/integrations-view.d.ts.map +1 -1
  47. package/dist/tui/components/integrations-view.js +1 -1
  48. package/dist/tui/components/integrations-view.js.map +1 -1
  49. package/dist/tui/components/scratchpad-panel.d.ts +28 -0
  50. package/dist/tui/components/scratchpad-panel.d.ts.map +1 -0
  51. package/dist/tui/components/scratchpad-panel.js +117 -0
  52. package/dist/tui/components/scratchpad-panel.js.map +1 -0
  53. package/dist/tui/components/split-pane.d.ts +23 -0
  54. package/dist/tui/components/split-pane.d.ts.map +1 -0
  55. package/dist/tui/components/split-pane.js +59 -0
  56. package/dist/tui/components/split-pane.js.map +1 -0
  57. package/dist/tui/components/status-line.d.ts +35 -6
  58. package/dist/tui/components/status-line.d.ts.map +1 -1
  59. package/dist/tui/components/status-line.js +149 -19
  60. package/dist/tui/components/status-line.js.map +1 -1
  61. package/dist/tui/components/task-detail.d.ts +1 -1
  62. package/dist/tui/components/task-detail.d.ts.map +1 -1
  63. package/dist/tui/components/task-detail.js +1 -1
  64. package/dist/tui/components/task-detail.js.map +1 -1
  65. package/dist/tui/components/task-list.d.ts +1 -1
  66. package/dist/tui/components/task-list.d.ts.map +1 -1
  67. package/dist/tui/components/task-list.js +5 -6
  68. package/dist/tui/components/task-list.js.map +1 -1
  69. package/dist/tui/components/task-runs.d.ts +32 -0
  70. package/dist/tui/components/task-runs.d.ts.map +1 -0
  71. package/dist/tui/components/task-runs.js +146 -0
  72. package/dist/tui/components/task-runs.js.map +1 -0
  73. package/dist/tui/components/tool-call-item.d.ts +1 -1
  74. package/dist/tui/components/tool-call-item.d.ts.map +1 -1
  75. package/dist/tui/components/tool-call-item.js +44 -13
  76. package/dist/tui/components/tool-call-item.js.map +1 -1
  77. package/dist/tui/components/widgets-view.d.ts +1 -1
  78. package/dist/tui/components/widgets-view.d.ts.map +1 -1
  79. package/dist/tui/components/widgets-view.js +1 -1
  80. package/dist/tui/components/widgets-view.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 +3 -2
  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) {
@@ -160,17 +231,27 @@ export function startTuiApp(baseUrl, apiKey, version) {
160
231
  statusLine.setIncognito(conversation.incognito);
161
232
  tui.requestRender();
162
233
  }
234
+ function applyContext(info) {
235
+ contextBar.setContext(info);
236
+ activeBorderKind = info?.kind ?? null;
237
+ refreshBorder();
238
+ }
163
239
  function setTaskContext(task) {
164
240
  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));
241
+ if (!task) {
242
+ applyContext(null);
243
+ return;
172
244
  }
173
- tui.requestRender();
245
+ const pill = task.displayId ?? 'TASK';
246
+ const hint = isRecurringTask(task)
247
+ ? 'ctrl+o info · ctrl+r runs'
248
+ : 'ctrl+o info';
249
+ applyContext({
250
+ kind: 'task',
251
+ pill,
252
+ subtitle: `${task.title || 'Untitled'} (${task.status})`,
253
+ hint,
254
+ });
174
255
  }
175
256
  function clearConversation() {
176
257
  requestId++; // invalidate any in-flight callbacks
@@ -234,10 +315,6 @@ export function startTuiApp(baseUrl, apiKey, version) {
234
315
  showResumeSelector();
235
316
  return;
236
317
  }
237
- if (trimmed === '/reminders') {
238
- showReminderList();
239
- return;
240
- }
241
318
  if (trimmed === '/tasks') {
242
319
  showTaskList();
243
320
  return;
@@ -250,8 +327,16 @@ export function startTuiApp(baseUrl, apiKey, version) {
250
327
  showWidgetsView();
251
328
  return;
252
329
  }
253
- if (trimmed === '/dashboard') {
254
- showDashboardView();
330
+ if (trimmed === '/new-task') {
331
+ setNewTaskMode(true);
332
+ return;
333
+ }
334
+ if (trimmed === '/catchup') {
335
+ runCatchup();
336
+ return;
337
+ }
338
+ if (trimmed === '/today') {
339
+ openScratchpad();
255
340
  return;
256
341
  }
257
342
  if (trimmed === '/incognito') {
@@ -259,6 +344,7 @@ export function startTuiApp(baseUrl, apiKey, version) {
259
344
  return;
260
345
  }
261
346
  if (trimmed === '/exit') {
347
+ statusLine.dispose();
262
348
  tui.stop();
263
349
  process.stdout.write('\n');
264
350
  process.exit(0);
@@ -268,13 +354,15 @@ export function startTuiApp(baseUrl, apiKey, version) {
268
354
  runMessage(trimmed);
269
355
  };
270
356
  function hideMainUI() {
357
+ if (statusLine.focused)
358
+ tui.setFocus(editor);
271
359
  try {
272
360
  tui.removeChild(statusLine);
273
361
  }
274
362
  catch { /* ignore */ }
275
- tui.removeChild(messagesContainer);
363
+ tui.removeChild(messagesSplit);
276
364
  try {
277
- tui.removeChild(taskTitleContainer);
365
+ tui.removeChild(contextBar);
278
366
  }
279
367
  catch { /* ignore */ }
280
368
  tui.removeChild(editor);
@@ -282,8 +370,8 @@ export function startTuiApp(baseUrl, apiKey, version) {
282
370
  }
283
371
  function restoreMainUI() {
284
372
  overlayActive = false;
285
- tui.addChild(messagesContainer);
286
- tui.addChild(taskTitleContainer);
373
+ tui.addChild(messagesSplit);
374
+ tui.addChild(contextBar);
287
375
  tui.addChild(editor);
288
376
  tui.addChild(statusLine);
289
377
  tui.setFocus(editor);
@@ -338,17 +426,27 @@ export function startTuiApp(baseUrl, apiKey, version) {
338
426
  : chalk.green('●');
339
427
  const header = `${dot} ${chalk.bold(displayName)}${argSummary ? chalk.dim(' (' + argSummary + ')') : ''}`;
340
428
  addToMessages(new Text(header, 1, 0));
341
- // Result preview (first 3 non-empty lines)
429
+ // Result preview up to 2 non-empty lines, HTML stripped, prefixed
430
+ // with `│` / `└─` to match the streaming ToolCallItem style.
342
431
  const resultStr = stringifyToolOutput(part.output);
343
432
  if (resultStr) {
344
- const previewLines = resultStr
433
+ const allLines = resultStr
345
434
  .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));
435
+ .map(cleanPreviewLine)
436
+ .filter(l => l.length > 0);
437
+ const previewLines = allLines.slice(0, 2);
438
+ const extra = allLines.length - previewLines.length;
439
+ previewLines.forEach((line, idx) => {
440
+ const isLast = idx === previewLines.length - 1 && extra === 0;
441
+ const prefix = isLast ? ' └ ' : ' │ ';
442
+ addToMessages(new Text(chalk.dim(prefix + line), 1, 0));
443
+ });
444
+ if (extra > 0) {
445
+ addToMessages(new Text(chalk.dim(` └ +${extra} more lines`), 1, 0));
350
446
  }
351
447
  }
448
+ // Breathing room so consecutive tool calls don't run together.
449
+ addToMessages(new Spacer(1));
352
450
  any = true;
353
451
  continue;
354
452
  }
@@ -357,6 +455,26 @@ export function startTuiApp(baseUrl, apiKey, version) {
357
455
  }
358
456
  return any;
359
457
  }
458
+ /**
459
+ * Turn one line of a tool's raw output into something that scans in the
460
+ * preview strip: HTML tags stripped, whitespace collapsed, capped to a
461
+ * readable width. Preserves tag *content* so `<p>hello</p>` becomes `hello`
462
+ * (not empty), which is important for HTML-heavy tools like get_scratchpad.
463
+ */
464
+ function cleanPreviewLine(raw) {
465
+ const stripped = raw
466
+ .replace(/<[^>]+>/g, ' ')
467
+ .replace(/&nbsp;/gi, ' ')
468
+ .replace(/&amp;/gi, '&')
469
+ .replace(/&lt;/gi, '<')
470
+ .replace(/&gt;/gi, '>')
471
+ .replace(/&quot;/gi, '"')
472
+ .replace(/&#39;/gi, "'");
473
+ const collapsed = stripped.replace(/\s+/g, ' ').trim();
474
+ if (collapsed.length <= 120)
475
+ return collapsed;
476
+ return collapsed.slice(0, 120) + '…';
477
+ }
360
478
  function summarizeArgs(input) {
361
479
  if (!input || typeof input !== 'object')
362
480
  return '';
@@ -429,16 +547,6 @@ export function startTuiApp(baseUrl, apiKey, version) {
429
547
  loadConversationHistoryIntoMessages(conv.id);
430
548
  };
431
549
  }
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
550
  function showTaskList() {
443
551
  hideMainUI();
444
552
  const list = new TaskList(baseUrl, apiKey, tui, () => tui.requestRender());
@@ -507,6 +615,30 @@ export function startTuiApp(baseUrl, apiKey, version) {
507
615
  restoreMainUI();
508
616
  };
509
617
  }
618
+ function showTaskRunsOverlay(task) {
619
+ hideMainUI();
620
+ const runsView = new TaskRuns(baseUrl, apiKey, task, tui, () => tui.requestRender());
621
+ tui.addChild(runsView);
622
+ tui.setFocus(runsView);
623
+ runsView.onCancel = () => {
624
+ try {
625
+ tui.removeChild(runsView);
626
+ }
627
+ catch { /* ignore */ }
628
+ restoreMainUI();
629
+ };
630
+ runsView.onOpenRun = run => {
631
+ try {
632
+ tui.removeChild(runsView);
633
+ }
634
+ catch { /* ignore */ }
635
+ // Swap the active conversation to the picked run. `openTaskConversation`
636
+ // already handles clearing state, setting the task context, and
637
+ // loading history — reuse it so the flow matches picking a run from
638
+ // `/tasks`.
639
+ openTaskConversation(task, run.id);
640
+ };
641
+ }
510
642
  function showIntegrationsView() {
511
643
  hideMainUI();
512
644
  const view = new IntegrationsView(baseUrl, apiKey, tui, () => tui.requestRender());
@@ -528,15 +660,130 @@ export function startTuiApp(baseUrl, apiKey, version) {
528
660
  loadBelowInputWidget().then(() => restoreMainUI()).catch(() => restoreMainUI());
529
661
  };
530
662
  }
531
- function showDashboardView() {
663
+ // ── Scratchpad (/today) ───────────────────────────────────────────────────
664
+ function updateScratchpadContextBar() {
665
+ if (!scratchpadPanel) {
666
+ // If a task was active, keep its context; otherwise clear.
667
+ if (currentTask)
668
+ setTaskContext(currentTask);
669
+ else
670
+ applyContext(null);
671
+ return;
672
+ }
673
+ // The ScratchpadPanel already shows the date + nav hints, so an extra
674
+ // context-bar row is just repetition. Keep the visual bar empty but
675
+ // drive the border colour off `activeBorderKind = 'scratchpad'`.
676
+ contextBar.setContext(null);
677
+ activeBorderKind = 'scratchpad';
678
+ refreshBorder();
679
+ }
680
+ function openScratchpad() {
681
+ if (scratchpadPanel) {
682
+ const currentDate = scratchpadPanel.getDate();
683
+ const today = new Date();
684
+ const onToday = currentDate.getUTCFullYear() === today.getUTCFullYear() &&
685
+ currentDate.getUTCMonth() === today.getUTCMonth() &&
686
+ currentDate.getUTCDate() === today.getUTCDate();
687
+ if (onToday) {
688
+ // Toggle off — /today acts as a "close scratchpad" too.
689
+ closeScratchpad();
690
+ return;
691
+ }
692
+ scratchpadPanel.setDate(today);
693
+ updateScratchpadContextBar();
694
+ tui.requestRender();
695
+ return;
696
+ }
697
+ scratchpadPanel = new ScratchpadPanel(baseUrl, apiKey, () => tui.requestRender());
698
+ scratchpadPanel.onDateChange = () => updateScratchpadContextBar();
699
+ messagesSplit.setLeft(scratchpadPanel);
700
+ updateScratchpadContextBar();
701
+ tui.requestRender();
702
+ }
703
+ function closeScratchpad() {
704
+ if (!scratchpadPanel)
705
+ return;
706
+ scratchpadPanel = null;
707
+ messagesSplit.setLeft(null);
708
+ updateScratchpadContextBar();
709
+ tui.requestRender();
710
+ }
711
+ // ── Catchup ───────────────────────────────────────────────────────────────
712
+ function showCatchupView(summary, count) {
532
713
  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);
714
+ pendingCatchupSummary = summary;
715
+ const container = new Container();
716
+ catchupViewContainer = container;
717
+ container.addChild(new Spacer(1));
718
+ container.addChild(new Text(chalk.bold.cyan('Catchup') +
719
+ chalk.dim(` · ${count} item${count === 1 ? '' : 's'}`), 1, 0));
720
+ container.addChild(new Spacer(1));
721
+ container.addChild(new Markdown(summary, 1, 0, markdownTheme));
722
+ container.addChild(new Spacer(2));
723
+ container.addChild(new Text(chalk.bold.white(' c') +
724
+ chalk.dim(' continue in a new chat') +
725
+ chalk.dim(' ') +
726
+ chalk.bold.white('esc') +
727
+ chalk.dim(' close'), 1, 0));
728
+ container.addChild(new Spacer(1));
729
+ tui.addChild(container);
730
+ tui.setFocus(container);
731
+ tui.requestRender();
732
+ }
733
+ function closeCatchupView() {
734
+ if (!catchupViewContainer)
735
+ return;
736
+ try {
737
+ tui.removeChild(catchupViewContainer);
738
+ }
739
+ catch { /* ignore */ }
740
+ catchupViewContainer = null;
741
+ pendingCatchupSummary = null;
742
+ restoreMainUI();
743
+ }
744
+ function showCatchupEmptyToast() {
745
+ addToMessages(new Text(chalk.dim('Nothing to catch up on.'), 1, 0));
746
+ addToMessages(new Spacer(1));
747
+ tui.requestRender();
748
+ }
749
+ function runCatchup() {
750
+ if (isProcessing || catchupViewContainer)
751
+ return;
752
+ pendingCatchupSummary = null;
753
+ showLoader();
754
+ summariseCatchup(baseUrl, apiKey)
755
+ .then(({ summary, count }) => {
756
+ removeLoader();
757
+ if (count === 0 || !summary) {
758
+ showCatchupEmptyToast();
759
+ return;
760
+ }
761
+ showCatchupView(summary, count);
762
+ })
763
+ .catch((err) => {
764
+ removeLoader();
765
+ addToMessages(new Text(chalk.red('Catchup failed: ') + chalk.dim(err.message), 1, 0));
766
+ addToMessages(new Spacer(1));
767
+ tui.requestRender();
768
+ });
769
+ }
770
+ function continueCatchupInNewChat() {
771
+ if (!pendingCatchupSummary)
772
+ return;
773
+ const seed = pendingCatchupSummary;
774
+ if (catchupViewContainer) {
775
+ try {
776
+ tui.removeChild(catchupViewContainer);
777
+ }
778
+ catch { /* ignore */ }
779
+ catchupViewContainer = null;
538
780
  restoreMainUI();
539
- };
781
+ }
782
+ pendingCatchupSummary = null;
783
+ clearConversation();
784
+ editor.setText(seed + '\n\n');
785
+ tui.setFocus(editor);
786
+ tui.requestRender();
540
787
  }
541
788
  function removeLoader() {
542
789
  const idx = conversationComponents.lastIndexOf(loader);
@@ -556,11 +803,24 @@ export function startTuiApp(baseUrl, apiKey, version) {
556
803
  loader.stop(); // clear any existing interval before restarting
557
804
  loader.start();
558
805
  }
806
+ function showOutOfCreditsToast() {
807
+ addToMessages(new Text(chalk.red('You’re out of credits. ') +
808
+ chalk.dim('Top up at ') +
809
+ chalk.white(baseUrl + '/settings/billing'), 1, 0));
810
+ addToMessages(new Spacer(1));
811
+ }
559
812
  function runMessage(message) {
813
+ if (outOfCredits()) {
814
+ showOutOfCreditsToast();
815
+ tui.requestRender();
816
+ return;
817
+ }
560
818
  isProcessing = true;
561
819
  editor.disableSubmit = true;
562
820
  const myRequestId = ++requestId;
563
- // User bubble
821
+ // User bubble — leading blank line so consecutive turns don't run
822
+ // into the previous assistant reply.
823
+ addToMessages(new Spacer(1));
564
824
  addToMessages(new Text(chalk.bgHex('#3a3a3a').white(' \u276f ' + message + ' '), 0, 0));
565
825
  addToMessages(new Spacer(1));
566
826
  showLoader();
@@ -568,6 +828,7 @@ export function startTuiApp(baseUrl, apiKey, version) {
568
828
  let accumulated = '';
569
829
  let markdownInserted = false;
570
830
  let hadOutput = false;
831
+ let hadErrorMessage = false;
571
832
  const callbacks = {
572
833
  onTextDelta(delta) {
573
834
  if (requestId !== myRequestId)
@@ -603,12 +864,15 @@ export function startTuiApp(baseUrl, apiKey, version) {
603
864
  if (requestId !== myRequestId)
604
865
  return;
605
866
  removeLoader();
606
- if (!hadOutput) {
867
+ if (!hadOutput && !hadErrorMessage) {
607
868
  addToMessages(new Text(chalk.gray('(no response)'), 1, 0));
869
+ addToMessages(new Spacer(1));
608
870
  }
609
- addToMessages(new Spacer(1));
610
871
  isProcessing = false;
611
872
  editor.disableSubmit = false;
873
+ // If the scratchpad panel is open the agent may have called
874
+ // `update_scratchpad` — refresh so the panel reflects new content.
875
+ scratchpadPanel?.refresh().catch(() => { });
612
876
  tui.requestRender();
613
877
  },
614
878
  onAbort() {
@@ -638,7 +902,29 @@ export function startTuiApp(baseUrl, apiKey, version) {
638
902
  if (requestId !== myRequestId)
639
903
  return;
640
904
  removeLoader();
641
- addToMessages(new Text(chalk.red('Error: ') + chalk.gray(err.message), 1, 0));
905
+ hadErrorMessage = true;
906
+ const code = err.code;
907
+ const isNoCredits = err instanceof NoCreditsError || code === 'no_credits';
908
+ if (isNoCredits) {
909
+ // Server told us we're out — flip local state so the border
910
+ // turns red immediately and future submits short-circuit.
911
+ if (creditsInfo)
912
+ creditsInfo.available = 0;
913
+ refreshBorder();
914
+ addToMessages(new Text(chalk.red('You’re out of credits. ') +
915
+ chalk.dim('Top up at ') +
916
+ chalk.white(baseUrl + '/settings/billing'), 1, 0));
917
+ }
918
+ else {
919
+ // Empty message often means the server emitted a bare SSE
920
+ // `error` event with no payload — surface something
921
+ // actionable instead of a lone "Error:" line.
922
+ const message = err.message?.trim() || 'Stream ended unexpectedly (no error message from server).';
923
+ addToMessages(new Text(chalk.red('Error: ') + chalk.gray(message), 1, 0));
924
+ // Any stream failure could be a credit gate that raced past
925
+ // our poll — refresh so the border reflects reality.
926
+ refreshCredits().catch(() => { });
927
+ }
642
928
  addToMessages(new Spacer(1));
643
929
  isProcessing = false;
644
930
  editor.disableSubmit = false;
@@ -703,6 +989,7 @@ export function startTuiApp(baseUrl, apiKey, version) {
703
989
  // ── Global input ──────────────────────────────────────────────────────────
704
990
  tui.addInputListener(data => {
705
991
  if (data === '\x03') {
992
+ statusLine.dispose();
706
993
  tui.stop();
707
994
  process.stdout.write('\n');
708
995
  process.exit(0);
@@ -777,6 +1064,43 @@ export function startTuiApp(baseUrl, apiKey, version) {
777
1064
  setNewTaskMode(false);
778
1065
  return { consume: true };
779
1066
  }
1067
+ // ── Down arrow on empty editor → focus status line zone selector ───
1068
+ if (!overlayActive &&
1069
+ !isProcessing &&
1070
+ !newTaskMode &&
1071
+ !statusLine.focused &&
1072
+ matchesKey(data, Key.down) &&
1073
+ editor.getText().length === 0) {
1074
+ statusLine.setSelectedZone('tasks');
1075
+ tui.setFocus(statusLine);
1076
+ tui.requestRender();
1077
+ return { consume: true };
1078
+ }
1079
+ // ── Catchup view input (only when the modal view is up) ────────────
1080
+ if (catchupViewContainer) {
1081
+ if (data === 'c' || data === 'C') {
1082
+ continueCatchupInNewChat();
1083
+ return { consume: true };
1084
+ }
1085
+ if (matchesKey(data, Key.escape)) {
1086
+ closeCatchupView();
1087
+ return { consume: true };
1088
+ }
1089
+ // Block other keys while the catchup modal is up — no editor,
1090
+ // no accidental slash-commands.
1091
+ return { consume: true };
1092
+ }
1093
+ // ── Scratchpad day nav (Ctrl+P prev · Ctrl+N next) ─────────────────
1094
+ if (scratchpadPanel && !overlayActive && !isProcessing && !statusLine.focused) {
1095
+ if (matchesKey(data, Key.ctrl('p'))) {
1096
+ scratchpadPanel.shiftDays(-1);
1097
+ return { consume: true };
1098
+ }
1099
+ if (matchesKey(data, Key.ctrl('n'))) {
1100
+ scratchpadPanel.shiftDays(1);
1101
+ return { consume: true };
1102
+ }
1103
+ }
780
1104
  if (matchesKey(data, Key.ctrl('o'))) {
781
1105
  if (taskInfoOverlay) {
782
1106
  taskInfoOverlay.onCancel?.();
@@ -795,6 +1119,15 @@ export function startTuiApp(baseUrl, apiKey, version) {
795
1119
  }
796
1120
  return;
797
1121
  }
1122
+ // ── Ctrl+R → show runs of the current recurring task ──────────────
1123
+ if (!overlayActive &&
1124
+ !isProcessing &&
1125
+ currentTask &&
1126
+ isRecurringTask(currentTask) &&
1127
+ matchesKey(data, Key.ctrl('r'))) {
1128
+ showTaskRunsOverlay(currentTask);
1129
+ return { consume: true };
1130
+ }
798
1131
  if (!overlayActive && matchesKey(data, Key.ctrl('i'))) {
799
1132
  toggleIncognito();
800
1133
  return;