@crouton-kit/humanloop 0.3.38 → 0.3.39

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 (43) hide show
  1. package/dist/browser/server.d.ts +15 -5
  2. package/dist/browser/server.js +110 -14
  3. package/dist/cli.js +0 -0
  4. package/dist/conversation/reader.d.ts +15 -1
  5. package/dist/conversation/reader.js +320 -30
  6. package/dist/editor/roundtrip.d.ts +5 -0
  7. package/dist/editor/roundtrip.js +36 -0
  8. package/dist/inbox/completion.js +1 -27
  9. package/dist/inbox/controller.d.ts +26 -2
  10. package/dist/inbox/controller.js +265 -34
  11. package/dist/inbox/convention.d.ts +7 -0
  12. package/dist/inbox/convention.js +32 -0
  13. package/dist/inbox/deck-adapter.d.ts +10 -2
  14. package/dist/inbox/deck-adapter.js +23 -12
  15. package/dist/inbox/deck-schema.d.ts +2 -0
  16. package/dist/inbox/deck-schema.js +4 -4
  17. package/dist/inbox/followup.d.ts +57 -0
  18. package/dist/inbox/followup.js +117 -0
  19. package/dist/inbox/registry.d.ts +2 -0
  20. package/dist/inbox/registry.js +6 -2
  21. package/dist/inbox/tickets.js +8 -2
  22. package/dist/inbox/tui.d.ts +1 -1
  23. package/dist/inbox/tui.js +58 -25
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +1 -0
  26. package/dist/tui/app.d.ts +1 -1
  27. package/dist/tui/app.js +80 -88
  28. package/dist/tui/input.d.ts +5 -1
  29. package/dist/tui/input.js +41 -15
  30. package/dist/tui/log.d.ts +2 -0
  31. package/dist/tui/log.js +53 -0
  32. package/dist/tui/render.js +40 -23
  33. package/dist/tui/terminal.d.ts +1 -0
  34. package/dist/tui/terminal.js +5 -0
  35. package/dist/tui/tmux.js +44 -22
  36. package/dist/types.d.ts +39 -2
  37. package/dist/visuals/conversation.d.ts +7 -0
  38. package/dist/visuals/conversation.js +16 -0
  39. package/dist/visuals/generate.d.ts +3 -1
  40. package/dist/visuals/generate.js +8 -6
  41. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  42. package/dist/web/index.html +1 -1
  43. package/package.json +7 -2
package/dist/tui/app.js CHANGED
@@ -1,13 +1,12 @@
1
1
  import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
2
- import { dirname, resolve as resolvePath, join } from 'node:path';
3
- import { spawnSync } from 'node:child_process';
4
- import { tmpdir } from 'node:os';
5
- import { randomUUID } from 'node:crypto';
2
+ import { dirname, resolve as resolvePath } from 'node:path';
6
3
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
7
4
  import { diffFrame, renderOverview, renderItemReview, renderFinal, renderHandoff, clampItemReviewScroll } from './render.js';
8
5
  import { handleKeypress, assignShortcuts } from './input.js';
9
- import { readConversation } from '../conversation/reader.js';
10
- import { defaultGenerateVisual } from '../visuals/generate.js';
6
+ import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
7
+ import { visualRenderWidth } from '../visuals/generate.js';
8
+ import { renderMarkdown } from '../render/termrender.js';
9
+ import { editBufferInEditor } from '../editor/roundtrip.js';
11
10
  import { validateDeck } from '../inbox/deck-schema.js';
12
11
  import { progressPath as progressPathFor, deckPath as deckPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
13
12
  import { startWebServer } from '../browser/server.js';
@@ -19,7 +18,7 @@ export function validateInput(parsed) {
19
18
  return validateDeck(parsed);
20
19
  }
21
20
  // ── Internal helpers ──────────────────────────────────────────────────────────
22
- function buildInitialState(deck) {
21
+ function buildInitialState(deck, editorAvailable = false, followUpAvailable = false) {
23
22
  // Single-question decks skip the overview list — there's nothing to overview,
24
23
  // and overview hides the option hotkeys so users press 'y' and nothing happens.
25
24
  const initialPhase = deck.interactions.length === 1 ? 'item-review' : 'overview';
@@ -55,8 +54,12 @@ function buildInitialState(deck) {
55
54
  preAnsweredIds,
56
55
  inputMode: null,
57
56
  selectedAction: 0,
58
- detailExpanded: false,
57
+ bodyMode: 'question',
59
58
  scrollOffset: 0,
59
+ bodyScrollOffsets: { question: 0, visual: 0 },
60
+ editorAvailable,
61
+ followUpAvailable,
62
+ followUp: undefined,
60
63
  };
61
64
  }
62
65
  function collectResponses(state) {
@@ -104,23 +107,36 @@ function rebindPersist(internals) {
104
107
  internals.callbacks.onProgress?.(responses);
105
108
  };
106
109
  }
110
+ function renderVisualMarkdown(markdown, cols) {
111
+ return renderMarkdown(markdown, visualRenderWidth(cols)).join('\n');
112
+ }
113
+ /** Reflow ready visual context locally; resizing must never invoke the model. */
114
+ function rerenderVisuals(internals) {
115
+ for (const [id, visual] of internals.state.visuals) {
116
+ if (visual.status !== 'ready' || visual.markdown === undefined)
117
+ continue;
118
+ internals.state.visuals.set(id, { ...visual, content: renderVisualMarkdown(visual.markdown, internals.cols) });
119
+ }
120
+ }
107
121
  function fireVisuals(internals, interactions) {
108
122
  if (internals.generateVisual === undefined)
109
123
  return;
110
124
  const gen = internals.generateVisual;
125
+ const generation = ++internals.visualGeneration;
111
126
  for (const interaction of interactions) {
127
+ const generationCols = internals.cols;
112
128
  internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'loading' });
113
- gen(interaction).then((r) => {
114
- if (!internals.mounted)
129
+ gen(interaction, generationCols).then((r) => {
130
+ if (!internals.mounted || generation !== internals.visualGeneration)
115
131
  return;
116
132
  if (!internals.state.interactions.some((x) => x.id === interaction.id))
117
133
  return;
118
134
  internals.state.visuals.set(interaction.id, r.ok
119
- ? { questionId: interaction.id, content: r.ansi, status: 'ready' }
135
+ ? { questionId: interaction.id, content: internals.cols === generationCols ? r.ansi : renderVisualMarkdown(r.markdown, internals.cols), markdown: r.markdown, status: 'ready' }
120
136
  : { questionId: interaction.id, content: '', status: 'error' });
121
137
  internals.callbacks.onDirty?.();
122
138
  }).catch(() => {
123
- if (!internals.mounted)
139
+ if (!internals.mounted || generation !== internals.visualGeneration)
124
140
  return;
125
141
  if (!internals.state.interactions.some((x) => x.id === interaction.id))
126
142
  return;
@@ -130,14 +146,23 @@ function fireVisuals(internals, interactions) {
130
146
  }
131
147
  }
132
148
  export function mountPanel(opts) {
149
+ const followUpAvailable = opts.followUpAvailable !== false
150
+ && opts.onFollowUpRequest !== undefined
151
+ && opts.onFollowUpCancel !== undefined;
133
152
  const internals = {
134
- state: buildInitialState(opts.deck),
153
+ state: buildInitialState(opts.deck, opts.onEditorRequest !== undefined, followUpAvailable),
135
154
  cols: opts.cols,
136
155
  rows: opts.rows,
137
156
  mounted: true,
138
157
  generateVisual: opts.generateVisual,
158
+ visualGeneration: 0,
139
159
  progressPath: opts.progressPath,
140
- callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit, onDirty: opts.onDirty },
160
+ followUpAvailable,
161
+ callbacks: {
162
+ onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit,
163
+ onDirty: opts.onDirty, onEditorRequest: opts.onEditorRequest,
164
+ onFollowUpRequest: opts.onFollowUpRequest, onFollowUpCancel: opts.onFollowUpCancel,
165
+ },
141
166
  };
142
167
  assignShortcuts(internals.state.interactions);
143
168
  rebindPersist(internals);
@@ -156,6 +181,10 @@ export function mountPanel(opts) {
156
181
  handleKey(input, key) {
157
182
  if (!internals.mounted)
158
183
  return;
184
+ if (key.ctrl && input === 'o' && internals.state.inputMode !== null && internals.callbacks.onEditorRequest !== undefined) {
185
+ internals.callbacks.onEditorRequest();
186
+ return;
187
+ }
159
188
  const onAutoComplete = () => {
160
189
  const responses = collectResponses(internals.state);
161
190
  if (internals.progressPath !== undefined) {
@@ -174,6 +203,9 @@ export function mountPanel(opts) {
174
203
  else {
175
204
  internals.callbacks.onExit?.();
176
205
  }
206
+ }, {
207
+ request: (question) => internals.callbacks.onFollowUpRequest?.(question),
208
+ cancel: () => internals.callbacks.onFollowUpCancel?.(),
177
209
  });
178
210
  // Pre-render clamp (input layer): keep scrollOffset within the current
179
211
  // body's bounds so u/d stay responsive. The renderer itself is pure.
@@ -189,7 +221,9 @@ export function mountPanel(opts) {
189
221
  handleResize(cols, rows) {
190
222
  internals.cols = cols;
191
223
  internals.rows = rows;
192
- // New dimensions change the scroll bounds re-clamp before laying out.
224
+ // Width changes reflow saved visual markdown, so clamp against the new
225
+ // content and geometry rather than the stale pre-resize wrapping.
226
+ rerenderVisuals(internals);
193
227
  if (internals.state.phase === 'item-review') {
194
228
  clampItemReviewScroll(internals.state, cols, rows);
195
229
  }
@@ -204,7 +238,7 @@ export function mountPanel(opts) {
204
238
  if (!internals.mounted)
205
239
  return;
206
240
  const prior = collectResponses(internals.state);
207
- internals.state = buildInitialState(deck);
241
+ internals.state = buildInitialState(deck, internals.callbacks.onEditorRequest !== undefined, internals.followUpAvailable);
208
242
  if (loadOpts !== undefined && loadOpts.progressPath !== undefined) {
209
243
  internals.progressPath = loadOpts.progressPath;
210
244
  }
@@ -227,6 +261,25 @@ export function mountPanel(opts) {
227
261
  }
228
262
  fireVisuals(internals, deck.interactions);
229
263
  },
264
+ setFollowUpHandlers(available, onRequest, onCancel) {
265
+ if (!internals.mounted)
266
+ return;
267
+ internals.callbacks.onFollowUpRequest = onRequest;
268
+ internals.callbacks.onFollowUpCancel = onCancel;
269
+ const enabled = available && onRequest !== undefined && onCancel !== undefined;
270
+ internals.followUpAvailable = enabled;
271
+ internals.state.followUpAvailable = enabled;
272
+ if (!enabled) {
273
+ internals.state.followUp = undefined;
274
+ if (internals.state.inputMode?.kind === 'follow-up')
275
+ internals.state.inputMode = null;
276
+ }
277
+ },
278
+ setFollowUpState(state) {
279
+ if (!internals.mounted || !internals.state.followUpAvailable)
280
+ return;
281
+ internals.state.followUp = state;
282
+ },
230
283
  canAcceptHostKeys() {
231
284
  if (!internals.mounted)
232
285
  return false;
@@ -264,24 +317,11 @@ export function mountPanel(opts) {
264
317
  * returned `deck` is the one actually answered (post-reload).
265
318
  */
266
319
  export async function resolveInteractionDir(dir, deck, opts = {}) {
267
- let conversationContext = '';
268
- if (opts.sessionId !== undefined) {
269
- try {
270
- const conv = readConversation(opts.sessionId);
271
- conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
272
- }
273
- catch {
274
- // empty context — proceed without visuals context
275
- }
276
- }
277
320
  setupTerminal();
278
321
  const term = getTerminalSize();
279
322
  const cols = opts.cols ?? term.cols;
280
323
  const rows = opts.rows ?? term.rows;
281
- const generateVisual = opts.generateVisual ??
282
- (opts.sessionId !== undefined
283
- ? (interaction) => defaultGenerateVisual(interaction, conversationContext)
284
- : undefined);
324
+ const generateVisual = opts.generateVisual ?? (opts.sessionId === undefined ? undefined : visualGeneratorForConversationSession(opts.sessionId));
285
325
  return new Promise((resolve) => {
286
326
  let panel = null;
287
327
  let prevFrameLocal = [];
@@ -369,6 +409,11 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
369
409
  cols,
370
410
  rows,
371
411
  generateVisual,
412
+ onEditorRequest: () => {
413
+ const buffer = panel?.getInputBuffer();
414
+ if (buffer !== undefined)
415
+ runEditorEscapeHatch(buffer);
416
+ },
372
417
  onProgress: (responses) => {
373
418
  lastResponses = responses;
374
419
  if (panel !== null)
@@ -444,68 +489,22 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
444
489
  return;
445
490
  process.stdin.removeListener('data', onData);
446
491
  process.stdout.removeListener('resize', onResize);
447
- const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
448
- const editor = process.env.EDITOR || 'vi';
449
- let next = buffer;
450
- let errorMessage = null;
451
- // Everything from suspending the TUI through the editor round-trip can
452
- // throw (tmpfile write, spawn, readback) — a tmpdir write failure alone
453
- // (e.g. /tmp full) used to escape with the listeners detached and the
454
- // real terminal never restored. The try/finally below guarantees the
455
- // finally branch — tmpfile cleanup, TUI restore, listener re-attach,
456
- // resize-aware redraw — runs on every path, success or failure.
492
+ let result = { text: buffer };
457
493
  try {
458
494
  restoreTerminal();
459
- writeFileSync(tmpFile, buffer);
460
- // $EDITOR is conventionally a shell fragment, not a bare binary —
461
- // "code --wait", "emacsclient -t" — so run it through the shell (as
462
- // git does) with the filename passed safely as a positional arg.
463
- const result = spawnSync('/bin/sh', ['-c', `${editor} "$1"`, 'sh', tmpFile], { stdio: 'inherit' });
464
- if (result.error) {
465
- errorMessage = `$EDITOR ("${editor}") failed to launch: ${result.error.message}`;
466
- }
467
- else if (result.signal !== null) {
468
- errorMessage = `$EDITOR ("${editor}") was killed by signal ${result.signal}`;
469
- }
470
- else if (result.status === 127 || result.status === 126) {
471
- // Shell couldn't exec the editor: 127 = not found, 126 = not executable.
472
- errorMessage = `$EDITOR ("${editor}") failed to launch (shell exit ${result.status})`;
473
- }
474
- else if (result.status !== 0) {
475
- errorMessage = `$EDITOR ("${editor}") exited with status ${result.status}`;
476
- }
477
- else {
478
- let read = readFileSync(tmpFile, 'utf8');
479
- // Editors conventionally append a trailing newline on save; strip
480
- // exactly one so a round-trip that added nothing doesn't grow the
481
- // buffer by a blank line.
482
- if (read.endsWith('\n') && !buffer.endsWith('\n'))
483
- read = read.slice(0, -1);
484
- next = read;
485
- }
486
- }
487
- catch (err) {
488
- errorMessage = `$EDITOR round-trip failed: ${err instanceof Error ? err.message : String(err)}`;
495
+ result = editBufferInEditor(buffer);
489
496
  }
490
497
  finally {
491
- try {
492
- unlinkSync(tmpFile);
493
- }
494
- catch { /* best-effort cleanup — may never have been created */ }
495
498
  setupTerminal();
496
499
  process.stdin.on('data', onData);
497
500
  process.stdout.on('resize', onResize);
498
- // On any failure the original buffer is kept untouched. Re-sample the
499
- // terminal size (it may have changed while the editor had it) so the
500
- // post-editor redraw lays out for the CURRENT dimensions rather than
501
- // the panel's stale cols/rows, same as the live resize listener does.
502
- panel.setInputBuffer(errorMessage === null ? next : buffer);
501
+ panel.setInputBuffer(result.text);
503
502
  const { cols: c, rows: r } = getTerminalSize();
504
503
  const lines = panel.handleResize(c, r);
505
- if (errorMessage !== null) {
504
+ if (result.error !== undefined) {
506
505
  while (lines.length < r)
507
506
  lines.push('');
508
- lines[r - 1] = ` ${errorMessage}`.slice(0, c);
507
+ lines[r - 1] = ` ${result.error}`.slice(0, c);
509
508
  }
510
509
  prevFrameLocal = [];
511
510
  process.stdout.write('\x1b[2J\x1b[H');
@@ -583,13 +582,6 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
583
582
  }
584
583
  return;
585
584
  }
586
- if (key.ctrl && inp === 'o') {
587
- const buf = panel.getInputBuffer();
588
- if (buf !== undefined) {
589
- runEditorEscapeHatch(buf);
590
- return;
591
- }
592
- }
593
585
  // 'w' hands the current interaction off to the browser. Gated on
594
586
  // canAcceptHostKeys() (not mid comment/freetext) so it never shadows a
595
587
  // literal 'w' typed into a buffer; 'w' is also reserved from option
@@ -2,5 +2,9 @@ import type { TuiState, Interaction } from '../types.js';
2
2
  import type { Key } from './terminal.js';
3
3
  export type RenderFn = () => void;
4
4
  export type ExitFn = () => void;
5
+ export interface FollowUpActions {
6
+ request(question: string): void;
7
+ cancel(): void;
8
+ }
5
9
  export declare function assignShortcuts(interactions: Interaction[]): void;
6
- export declare function handleKeypress(input: string, key: Key, state: TuiState, render: RenderFn, exit: ExitFn): void;
10
+ export declare function handleKeypress(input: string, key: Key, state: TuiState, render: RenderFn, exit: ExitFn, followUp?: FollowUpActions): void;
package/dist/tui/input.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // 'w' is reserved for the host-level "open in browser" handoff (see
2
2
  // tui/app.ts resolveInteractionDir) — never auto-assignable as an option
3
3
  // shortcut, or pressing it would race the handoff against picking that option.
4
- const RESERVED = new Set(['c', 'r', 'n', 'p', 'q', 'j', 'k', 'u', 'd', 'w', ' ']);
4
+ const RESERVED = new Set(['c', 'r', 'n', 'p', 'q', 'j', 'k', 'u', 'd', 'w', ' ', '?']);
5
5
  export function assignShortcuts(interactions) {
6
6
  for (const it of interactions) {
7
7
  const used = new Set(it.options.map((o) => o.shortcut).filter((s) => s !== undefined));
@@ -32,7 +32,14 @@ export function assignShortcuts(interactions) {
32
32
  }
33
33
  }
34
34
  }
35
- export function handleKeypress(input, key, state, render, exit) {
35
+ export function handleKeypress(input, key, state, render, exit, followUp) {
36
+ if (key.backTab) {
37
+ state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
38
+ state.bodyMode = state.bodyMode === 'question' ? 'visual' : 'question';
39
+ state.scrollOffset = state.bodyScrollOffsets[state.bodyMode];
40
+ render();
41
+ return;
42
+ }
36
43
  if (key.ctrl && input === 'c') {
37
44
  exit();
38
45
  return;
@@ -42,7 +49,7 @@ export function handleKeypress(input, key, state, render, exit) {
42
49
  // render cycle.
43
50
  state.hint = undefined;
44
51
  if (state.inputMode) {
45
- handleInputMode(input, key, state, render);
52
+ handleInputMode(input, key, state, render, followUp);
46
53
  checkAutoExit(state, exit);
47
54
  return;
48
55
  }
@@ -51,7 +58,7 @@ export function handleKeypress(input, key, state, render, exit) {
51
58
  handleOverview(input, key, state, render, exit);
52
59
  break;
53
60
  case 'item-review':
54
- handleItemReview(input, key, state, render);
61
+ handleItemReview(input, key, state, render, followUp);
55
62
  checkAutoExit(state, exit);
56
63
  break;
57
64
  case 'final':
@@ -91,7 +98,8 @@ function handleOverview(input, key, state, render, exit) {
91
98
  if (key.return || input === ' ') {
92
99
  state.phase = 'item-review';
93
100
  state.selectedAction = 0;
94
- state.detailExpanded = false;
101
+ state.bodyMode = 'question';
102
+ state.scrollOffset = state.bodyScrollOffsets.question;
95
103
  render();
96
104
  return;
97
105
  }
@@ -124,7 +132,15 @@ function handleOverview(input, key, state, render, exit) {
124
132
  }
125
133
  }
126
134
  // ── Item Review ──────────────────────────────────────────────────────────────
127
- function handleItemReview(input, key, state, render) {
135
+ function handleItemReview(input, key, state, render, followUp) {
136
+ if (input === '?' && state.followUpAvailable) {
137
+ if (state.followUp?.status === 'running')
138
+ followUp?.cancel();
139
+ else
140
+ state.inputMode = { kind: 'follow-up', buffer: '', cursor: 0 };
141
+ render();
142
+ return;
143
+ }
128
144
  const interaction = state.interactions[state.currentIndex];
129
145
  if (input === 'n') {
130
146
  advanceItem(state, 1);
@@ -138,22 +154,18 @@ function handleItemReview(input, key, state, render) {
138
154
  }
139
155
  // q / Esc step back to the deck overview (one level up from a card).
140
156
  if (input === 'q' || key.escape) {
157
+ state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
141
158
  state.phase = 'overview';
142
159
  render();
143
160
  return;
144
161
  }
145
- // Space toggles the focused option for multi-select; otherwise expand context.
162
+ // Space toggles the focused option for multi-select.
146
163
  if (input === ' ' && interaction.multiSelect
147
164
  && state.selectedAction < interaction.options.length) {
148
165
  toggleMulti(state, interaction, interaction.options[state.selectedAction].id);
149
166
  render();
150
167
  return;
151
168
  }
152
- if (input === ' ') {
153
- state.detailExpanded = !state.detailExpanded;
154
- render();
155
- return;
156
- }
157
169
  // Body scroll: u/d, PageUp/PageDown, or Ctrl+D / Ctrl+U (half-page), Ctrl+E / Ctrl+Y (line).
158
170
  // Plain u/d exists because tmux configs commonly bind C-d/C-u for pane scroll
159
171
  // and intercept them before they reach the app. Render clamps state.scrollOffset,
@@ -273,8 +285,20 @@ function handleInteractionAction(input, key, state, interaction, render) {
273
285
  }
274
286
  }
275
287
  // ── Input Mode ───────────────────────────────────────────────────────────────
276
- function handleInputMode(input, key, state, render) {
288
+ function handleInputMode(input, key, state, render, followUp) {
277
289
  const mode = state.inputMode;
290
+ if (mode.kind === 'follow-up' && key.return) {
291
+ const question = mode.buffer.trim();
292
+ if (question.length === 0) {
293
+ state.hint = 'Enter a question, or esc to cancel';
294
+ render();
295
+ return;
296
+ }
297
+ followUp?.request(question);
298
+ state.inputMode = null;
299
+ render();
300
+ return;
301
+ }
278
302
  if (key.escape) {
279
303
  state.inputMode = null;
280
304
  render();
@@ -549,8 +573,9 @@ function advanceItem(state, direction) {
549
573
  }
550
574
  state.currentIndex = next;
551
575
  state.selectedAction = 0;
552
- state.detailExpanded = false;
576
+ state.bodyMode = 'question';
553
577
  state.scrollOffset = 0;
578
+ state.bodyScrollOffsets = { question: 0, visual: 0 };
554
579
  }
555
580
  /**
556
581
  * Move to the next interaction WITHOUT a response, falling through to the
@@ -570,8 +595,9 @@ function advanceToNextUnanswered(state) {
570
595
  }
571
596
  state.currentIndex = next;
572
597
  state.selectedAction = 0;
573
- state.detailExpanded = false;
598
+ state.bodyMode = 'question';
574
599
  state.scrollOffset = 0;
600
+ state.bodyScrollOffsets = { question: 0, visual: 0 };
575
601
  }
576
602
  function actionCount(interaction) {
577
603
  return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
@@ -0,0 +1,2 @@
1
+ export declare function popupLogPath(): string;
2
+ export declare function logPopupEvent(event: string, fields?: Record<string, unknown>): void;
@@ -0,0 +1,53 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ const MAX_BYTES = 5 * 1024 * 1024;
5
+ const ROTATIONS = 2;
6
+ function stateHome() {
7
+ return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
8
+ }
9
+ export function popupLogPath() {
10
+ return join(stateHome(), 'humanloop', 'inbox-popup.log');
11
+ }
12
+ function rotateIfNeeded(path, nextLineBytes) {
13
+ if (!existsSync(path))
14
+ return;
15
+ let size;
16
+ try {
17
+ size = statSync(path).size;
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ if (size + nextLineBytes < MAX_BYTES)
23
+ return;
24
+ try {
25
+ rmSync(`${path}.${ROTATIONS}`, { force: true });
26
+ }
27
+ catch { /* best-effort */ }
28
+ for (let index = ROTATIONS - 1; index >= 1; index -= 1) {
29
+ const source = `${path}.${index}`;
30
+ const target = `${path}.${index + 1}`;
31
+ try {
32
+ if (existsSync(source))
33
+ renameSync(source, target);
34
+ }
35
+ catch { /* best-effort */ }
36
+ }
37
+ try {
38
+ renameSync(path, `${path}.1`);
39
+ }
40
+ catch { /* best-effort */ }
41
+ }
42
+ export function logPopupEvent(event, fields = {}) {
43
+ const path = popupLogPath();
44
+ const line = JSON.stringify({ ts: new Date().toISOString(), component: 'inbox-popup', event, ...fields });
45
+ try {
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ rotateIfNeeded(path, Buffer.byteLength(`${line}\n`, 'utf8'));
48
+ appendFileSync(path, `${line}\n`, 'utf8');
49
+ }
50
+ catch {
51
+ /* Logging is best-effort so the inbox shortcut remains available. */
52
+ }
53
+ }
@@ -134,30 +134,46 @@ function buildItemReviewLayout(state, cols, rows) {
134
134
  // and live in the scrollable region so long content never overflows the fixed
135
135
  // header — agents put rich prose in either field, so both must render markdown.
136
136
  const bodyLines = [];
137
- if (interaction.subtitle) {
138
- bodyLines.push('');
139
- for (const line of renderMarkdown(interaction.subtitle, maxW)) {
140
- bodyLines.push(` ${line}`);
137
+ if (state.bodyMode === 'question') {
138
+ if (interaction.subtitle) {
139
+ bodyLines.push('');
140
+ for (const line of renderMarkdown(interaction.subtitle, maxW)) {
141
+ bodyLines.push(` ${line}`);
142
+ }
141
143
  }
142
- }
143
- if (interaction.body) {
144
- bodyLines.push('');
145
- for (const line of renderMarkdown(interaction.body, maxW)) {
146
- bodyLines.push(` ${line}`);
144
+ if (interaction.body) {
145
+ bodyLines.push('');
146
+ for (const line of renderMarkdown(interaction.body, maxW)) {
147
+ bodyLines.push(` ${line}`);
148
+ }
147
149
  }
148
150
  }
149
- if (visual && visual.status === 'ready' && state.detailExpanded) {
150
- bodyLines.push('');
151
- bodyLines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
152
- for (const vl of visual.content.split('\n')) {
153
- bodyLines.push(` ${vl}`);
151
+ if (state.bodyMode === 'visual') {
152
+ if (visual?.status === 'ready') {
153
+ bodyLines.push('');
154
+ bodyLines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
155
+ for (const vl of visual.content.split('\n')) {
156
+ bodyLines.push(` ${vl}`);
157
+ }
158
+ bodyLines.push(` ${DIM}${hline(maxW)}${RESET}`);
159
+ }
160
+ if (state.followUp !== undefined && state.followUp.status !== 'idle') {
161
+ bodyLines.push('');
162
+ bodyLines.push(` ${DIM}── follow-up ${hline(Math.max(0, maxW - 14))}${RESET}`);
163
+ if (state.followUp.status === 'running')
164
+ bodyLines.push(` ${DIM}consulting…${RESET}`);
165
+ else if (state.followUp.status === 'ready') {
166
+ for (const line of renderMarkdown(state.followUp.markdown, maxW))
167
+ bodyLines.push(` ${line}`);
168
+ }
169
+ else
170
+ bodyLines.push(` ${YELLOW}${state.followUp.error}${RESET}`);
154
171
  }
155
- bodyLines.push(` ${DIM}${hline(maxW)}${RESET}`);
156
172
  }
157
173
  // Post-body: visual status hint, input buffer or actions, footer (always visible)
158
174
  const postLines = [];
159
175
  postLines.push('');
160
- if (visual) {
176
+ if (state.bodyMode === 'visual' && visual) {
161
177
  if (visual.status === 'loading') {
162
178
  postLines.push(` ${DIM}loading context...${RESET}`);
163
179
  postLines.push('');
@@ -166,10 +182,6 @@ function buildItemReviewLayout(state, cols, rows) {
166
182
  postLines.push(` ${YELLOW}visual context unavailable${RESET}`);
167
183
  postLines.push('');
168
184
  }
169
- else if (!state.detailExpanded) {
170
- postLines.push(` ${DIM}[space] expand context${RESET}`);
171
- postLines.push('');
172
- }
173
185
  }
174
186
  if (state.inputMode) {
175
187
  postLines.push(` ${DIM}${hline(maxW)}${RESET}`);
@@ -204,7 +216,7 @@ function buildItemReviewLayout(state, cols, rows) {
204
216
  postLines.push(attachedLine);
205
217
  }
206
218
  postLines.push('');
207
- postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline ${DIM}^O${RESET} editor ${DIM}esc${RESET} cancel`);
219
+ postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline${state.editorAvailable ? ` ${DIM}^O${RESET} editor` : ''} ${DIM}esc${RESET} cancel`);
208
220
  }
209
221
  else {
210
222
  postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
@@ -236,6 +248,7 @@ function buildItemReviewLayout(state, cols, rows) {
236
248
  export function clampItemReviewScroll(state, cols, rows) {
237
249
  const { maxScroll } = buildItemReviewLayout(state, cols, rows);
238
250
  state.scrollOffset = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
251
+ state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
239
252
  }
240
253
  export function renderItemReview(state, cols, rows) {
241
254
  const { interaction, preLines, bodyLines, postLines, maxW, bodyHeight, maxScroll, overflows } = buildItemReviewLayout(state, cols, rows);
@@ -262,18 +275,22 @@ export function renderItemReview(state, cols, rows) {
262
275
  `${DIM}n/p${RESET} prev/next`,
263
276
  `${DIM}space${RESET} toggle`,
264
277
  `${DIM}enter${RESET} confirm`,
278
+ `${DIM}shift-tab${RESET} ${state.bodyMode === 'question' ? 'visual' : 'question'}`,
265
279
  `${DIM}q${RESET} overview`,
266
280
  ]
267
281
  : [
268
282
  `${DIM}n/p${RESET} prev/next`,
269
- `${DIM}space${RESET} expand`,
283
+ `${DIM}shift-tab${RESET} ${state.bodyMode === 'question' ? 'visual' : 'question'}`,
270
284
  `${DIM}q${RESET} overview`,
271
285
  ];
272
286
  if (overflows) {
273
287
  footerParts.unshift(state.inputMode ? `${DIM}pgup/pgdn${RESET} scroll` : `${DIM}u/d${RESET} scroll`);
274
288
  }
275
- if (state.inputMode === null)
289
+ if (state.inputMode === null) {
290
+ if (state.followUpAvailable)
291
+ footerParts.push(`${DIM}?${RESET} follow-up`);
276
292
  footerParts.push(`${DIM}w${RESET} browser`);
293
+ }
277
294
  const footer = ` ${footerParts.join(' ')}`;
278
295
  // Assemble — pad to fill rows so post-body sits at the bottom
279
296
  const lines = [...preLines, ...visibleBody, ...postLines];
@@ -19,6 +19,7 @@ export interface Key {
19
19
  ctrl: boolean;
20
20
  meta: boolean;
21
21
  tab: boolean;
22
+ backTab: boolean;
22
23
  backspace: boolean;
23
24
  }
24
25
  export type KeypressHandler = (input: string, key: Key) => void;
@@ -17,6 +17,7 @@ function emptyKey() {
17
17
  ctrl: false,
18
18
  meta: false,
19
19
  tab: false,
20
+ backTab: false,
20
21
  backspace: false,
21
22
  };
22
23
  }
@@ -69,6 +70,10 @@ export function parseKeypress(data) {
69
70
  key.del = true;
70
71
  return { input: '', key };
71
72
  }
73
+ if (str === '\x1b[Z') {
74
+ key.backTab = true;
75
+ return { input: '', key };
76
+ }
72
77
  // Alt+Enter inserts a newline in freetext (distinct from Enter=submit). Must
73
78
  // precede the bare-ESC and meta-backspace checks so the two-byte sequence
74
79
  // isn't swallowed as a lone escape.