@crouton-kit/humanloop 0.3.37 → 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 (44) 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 +36 -1
  10. package/dist/inbox/controller.js +335 -28
  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/render/termrender.js +25 -26
  27. package/dist/tui/app.d.ts +1 -1
  28. package/dist/tui/app.js +80 -88
  29. package/dist/tui/input.d.ts +5 -1
  30. package/dist/tui/input.js +41 -15
  31. package/dist/tui/log.d.ts +2 -0
  32. package/dist/tui/log.js +53 -0
  33. package/dist/tui/render.js +42 -23
  34. package/dist/tui/terminal.d.ts +1 -0
  35. package/dist/tui/terminal.js +5 -0
  36. package/dist/tui/tmux.js +44 -22
  37. package/dist/types.d.ts +39 -2
  38. package/dist/visuals/conversation.d.ts +7 -0
  39. package/dist/visuals/conversation.js +16 -0
  40. package/dist/visuals/generate.d.ts +3 -1
  41. package/dist/visuals/generate.js +8 -6
  42. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  43. package/dist/web/index.html +1 -1
  44. package/package.json +7 -2
@@ -74,7 +74,7 @@ export function renderOverview(state, cols, rows) {
74
74
  lines.push('');
75
75
  }
76
76
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
77
- lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
77
+ lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}w${RESET} browser ${DIM}q${RESET} finish`);
78
78
  while (lines.length < rows)
79
79
  lines.push('');
80
80
  // Overview content extends roughly cols-16 wide for option labels; center
@@ -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,16 +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
  }
289
+ if (state.inputMode === null) {
290
+ if (state.followUpAvailable)
291
+ footerParts.push(`${DIM}?${RESET} follow-up`);
292
+ footerParts.push(`${DIM}w${RESET} browser`);
293
+ }
275
294
  const footer = ` ${footerParts.join(' ')}`;
276
295
  // Assemble — pad to fill rows so post-body sits at the bottom
277
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.
package/dist/tui/tmux.js CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { existsSync, mkdirSync, rmSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
+ import { logPopupEvent } from './log.js';
7
8
  const POPUP_TITLE = 'humanloop · inbox';
8
9
  const POPUP_STYLE = 'bg=#20242d';
9
10
  const POPUP_BORDER_STYLE = 'fg=#5c6370';
@@ -54,33 +55,42 @@ function acquireStartupLock(path) {
54
55
  }
55
56
  export async function toggleInboxPopup(target) {
56
57
  const socket = target?.socket ?? tmuxSocketFromEnvironment();
57
- if (socket === undefined)
58
+ if (socket === undefined) {
59
+ logPopupEvent('toggle.rejected', { reason: 'not_in_tmux' });
58
60
  return 'not_in_tmux';
61
+ }
59
62
  const inferred = target?.client ?? inferTmuxClient(socket, target?.targetPane);
60
- if (inferred === 'ambiguous')
61
- return 'ambiguous_client';
62
- if (inferred === undefined)
63
+ if (inferred === 'ambiguous' || inferred === undefined) {
64
+ logPopupEvent('toggle.rejected', { reason: 'ambiguous_client', socket, targetPane: target?.targetPane });
63
65
  return 'ambiguous_client';
66
+ }
64
67
  const resolved = { socket, client: inferred, targetPane: target?.targetPane };
65
68
  const paths = popupPaths(resolved);
69
+ logPopupEvent('toggle.requested', { ...resolved, controlSocket: paths.controlSocket });
66
70
  mkdirSync(join(paths.controlSocket, '..'), { recursive: true, mode: 0o700 });
67
- if (await requestPopupClose(paths.controlSocket))
71
+ if (await requestPopupClose(paths.controlSocket)) {
72
+ logPopupEvent('toggle.closed', { ...resolved, controlSocket: paths.controlSocket });
68
73
  return 'closed';
69
- // A concurrent toggle for this same client won the startup lock and is opening the popup.
70
- // This gesture must not launch a second one; report a benign no-op close rather than a
71
- // generic failure, so exactly one popup exists and the loser never exits with an error.
72
- if (!acquireStartupLock(paths.startupLock))
74
+ }
75
+ // A concurrent toggle for this same client owns popup startup. This gesture
76
+ // leaves that startup as the sole owner and reports a benign close result.
77
+ if (!acquireStartupLock(paths.startupLock)) {
78
+ logPopupEvent('toggle.coalesced', { ...resolved, controlSocket: paths.controlSocket });
73
79
  return 'closed';
80
+ }
74
81
  try {
75
- // Re-probe under the lock: between the first probe and acquiring the lock a concurrent
76
- // toggle may have brought a live popup up. Deleting its control socket now would orphan
77
- // that popup, so close it instead. The finally below releases the lock on every path.
78
- if (await requestPopupClose(paths.controlSocket))
82
+ // Re-probe under the lock so a popup that became live during lock acquisition
83
+ // is closed through its controller rather than orphaned.
84
+ if (await requestPopupClose(paths.controlSocket)) {
85
+ logPopupEvent('toggle.closed', { ...resolved, controlSocket: paths.controlSocket, phase: 'locked-reprobe' });
79
86
  return 'closed';
87
+ }
80
88
  if (existsSync(paths.controlSocket))
81
89
  rmSync(paths.controlSocket, { force: true });
82
90
  const command = `${quote(process.execPath)} ${quote(fileURLToPath(new URL('../cli.js', import.meta.url)))} inbox open --control-socket ${quote(paths.controlSocket)}`;
83
- return await launchPopup(socket, resolved, paths.controlSocket, command);
91
+ const result = await launchPopup(socket, resolved, paths.controlSocket, command);
92
+ logPopupEvent('toggle.completed', { ...resolved, controlSocket: paths.controlSocket, result });
93
+ return result;
84
94
  }
85
95
  finally {
86
96
  rmSync(paths.startupLock, { recursive: true, force: true });
@@ -100,17 +110,27 @@ async function requestPopupClose(controlSocket) {
100
110
  /** Launch one popup and report `opened` only once its controller owns the control socket. */
101
111
  async function launchPopup(socket, target, controlSocket, command) {
102
112
  const args = ['-S', socket, 'display-popup', '-E', '-c', target.client, ...inboxPopupFlags(), ...(target.targetPane === undefined ? [] : ['-t', target.targetPane]), command];
113
+ const startedAt = Date.now();
103
114
  const child = spawn('tmux', args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
115
+ logPopupEvent('popup.spawned', { ...target, controlSocket, pid: child.pid, args });
104
116
  let stderr = '';
105
- child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
117
+ child.stderr.on('data', (chunk) => {
118
+ stderr += chunk.toString('utf8');
119
+ if (stderr.length > 8192)
120
+ stderr = stderr.slice(-8192);
121
+ });
106
122
  const exited = new Promise((resolve) => {
107
- // `display-popup -E` blocks until our popup closes, so a successful open keeps the child
108
- // alive while the control socket comes up (detected below). An early exit before that means
109
- // the popup command never ran: tmux 3.x silently declines to stack a popup on a client that
110
- // already shows one (exit 0, command dropped), which is exactly the foreign-popup case the
111
- // design must report as `other_popup` without disturbing the existing popup or its process.
112
- child.once('exit', (code) => resolve(code === 0 || /popup/i.test(stderr) ? 'other_popup' : 'failed'));
113
- child.once('error', () => resolve('failed'));
123
+ // `display-popup -E` blocks until the popup closes. An exit before the
124
+ // controller socket is live means tmux declined or failed the launch.
125
+ child.once('exit', (code, signal) => {
126
+ const result = code === 0 || /popup/i.test(stderr) ? 'other_popup' : 'failed';
127
+ logPopupEvent('popup.exited', { ...target, controlSocket, pid: child.pid, code, signal, result, stderr: stderr.trim(), elapsedMs: Date.now() - startedAt });
128
+ resolve(result);
129
+ });
130
+ child.once('error', (error) => {
131
+ logPopupEvent('popup.error', { ...target, controlSocket, pid: child.pid, error: error.message, stderr: stderr.trim(), elapsedMs: Date.now() - startedAt });
132
+ resolve('failed');
133
+ });
114
134
  });
115
135
  const { Socket } = await import('node:net');
116
136
  for (let attempt = 0; attempt < 50; attempt++) {
@@ -125,12 +145,14 @@ async function launchPopup(socket, target, controlSocket, command) {
125
145
  }),
126
146
  ]);
127
147
  if (outcome === 'opened') {
148
+ logPopupEvent('popup.opened', { ...target, controlSocket, pid: child.pid, elapsedMs: Date.now() - startedAt });
128
149
  child.unref();
129
150
  return 'opened';
130
151
  }
131
152
  if (outcome !== 'pending')
132
153
  return outcome;
133
154
  }
155
+ logPopupEvent('popup.startup_timeout', { ...target, controlSocket, pid: child.pid, stderr: stderr.trim(), elapsedMs: Date.now() - startedAt });
134
156
  return 'failed';
135
157
  }
136
158
  /** The static tmux `display-popup` geometry and style flags; the client and target pane are added per-invocation. */
package/dist/types.d.ts CHANGED
@@ -68,6 +68,9 @@ export interface DeckSource {
68
68
  * a crouter canvas node. Lets per-node attention scoping attribute the ask
69
69
  * to the node that raised it rather than every sibling sharing the cwd. */
70
70
  nodeId?: string;
71
+ /** Durable identifier of the conversation session that originated this ask.
72
+ * It is explicit metadata, never inferred from node identity or labels. */
73
+ originatingConversationSessionId?: string;
71
74
  }
72
75
  export interface Deck {
73
76
  title?: string;
@@ -103,8 +106,21 @@ export interface FeedbackResult {
103
106
  export interface VisualBlock {
104
107
  questionId: string;
105
108
  content: string;
109
+ /** Original model output, retained so resize can re-render locally. */
110
+ markdown?: string;
106
111
  status: 'loading' | 'ready' | 'error';
107
112
  }
113
+ export type FollowUpState = {
114
+ status: 'idle';
115
+ } | {
116
+ status: 'running';
117
+ } | {
118
+ status: 'ready';
119
+ markdown: string;
120
+ } | {
121
+ status: 'error';
122
+ error: string;
123
+ };
108
124
  export type Phase = 'overview' | 'item-review' | 'final';
109
125
  export type InputMode = null | {
110
126
  kind: 'comment';
@@ -115,6 +131,10 @@ export type InputMode = null | {
115
131
  kind: 'freetext';
116
132
  buffer: string;
117
133
  cursor: number;
134
+ } | {
135
+ kind: 'follow-up';
136
+ buffer: string;
137
+ cursor: number;
118
138
  };
119
139
  export interface TuiState {
120
140
  phase: Phase;
@@ -128,8 +148,16 @@ export interface TuiState {
128
148
  preAnsweredIds: Set<string>;
129
149
  inputMode: InputMode;
130
150
  selectedAction: number;
131
- detailExpanded: boolean;
151
+ bodyMode: 'question' | 'visual';
132
152
  scrollOffset: number;
153
+ bodyScrollOffsets: {
154
+ question: number;
155
+ visual: number;
156
+ };
157
+ /** The mounting host provided an active Ctrl+O editor callback. */
158
+ editorAvailable: boolean;
159
+ followUpAvailable: boolean;
160
+ followUp?: FollowUpState;
133
161
  /** Transient one-line notice shown in item-review (e.g. an empty multi-select
134
162
  * Enter that was rejected). Cleared on the next keypress. */
135
163
  hint?: string;
@@ -228,7 +256,9 @@ export interface DisplayOpts {
228
256
  /** Pane budget per window before `'auto'` opens a new window. Default 3. */
229
257
  maxPanes?: number;
230
258
  }
231
- export type GenerateVisual = (interaction: Interaction) => Promise<{
259
+ export type GenerateVisual = (interaction: Interaction,
260
+ /** Render width owned by the mounting surface. */
261
+ cols: number) => Promise<{
232
262
  ok: true;
233
263
  ansi: string;
234
264
  markdown: string;
@@ -240,6 +270,11 @@ export interface MountedPanelOpts {
240
270
  deck: Deck;
241
271
  progressPath?: string;
242
272
  generateVisual?: GenerateVisual;
273
+ /** Host callback for Ctrl+O while a comment/freetext buffer is active. */
274
+ onEditorRequest?: () => void;
275
+ followUpAvailable?: boolean;
276
+ onFollowUpRequest?: (question: string) => void;
277
+ onFollowUpCancel?: () => void;
243
278
  cols: number;
244
279
  rows: number;
245
280
  onProgress?: (responses: InteractionResponse[]) => void;
@@ -259,6 +294,8 @@ export interface MountedPanel {
259
294
  loadDeck(deck: Deck, opts?: {
260
295
  progressPath?: string;
261
296
  }): void;
297
+ setFollowUpHandlers(available: boolean, onRequest?: (question: string) => void, onCancel?: () => void): void;
298
+ setFollowUpState(state: FollowUpState): void;
262
299
  canAcceptHostKeys(): boolean;
263
300
  /**
264
301
  * True when the deck is at its top level: overview phase with no active
@@ -0,0 +1,7 @@
1
+ import type { GenerateVisual, Interaction } from '../types.js';
2
+ import { defaultGenerateVisual } from './generate.js';
3
+ type VisualGenerator = (interaction: Interaction, conversationContext: string, cols?: number) => ReturnType<typeof defaultGenerateVisual>;
4
+ type ConversationTextReader = (sessionId: string) => Promise<string>;
5
+ /** Build the standard visual generator from explicit originating-session metadata. */
6
+ export declare function visualGeneratorForConversationSession(sessionId: string, generateVisual?: VisualGenerator, readContext?: ConversationTextReader): GenerateVisual;
7
+ export {};
@@ -0,0 +1,16 @@
1
+ import { readConversationText } from '../conversation/reader.js';
2
+ import { defaultGenerateVisual } from './generate.js';
3
+ /** Build the standard visual generator from explicit originating-session metadata. */
4
+ export function visualGeneratorForConversationSession(sessionId, generateVisual = defaultGenerateVisual, readContext = readConversationText) {
5
+ // One lookup feeds every interaction and resize regeneration in this mounted panel.
6
+ // A failed lookup remains a failed visual rather than falling through to a generic prompt.
7
+ const context = readContext(sessionId);
8
+ return async (interaction, cols) => {
9
+ try {
10
+ return await generateVisual(interaction, await context, cols);
11
+ }
12
+ catch {
13
+ return { ok: false, error: 'visual context unavailable' };
14
+ }
15
+ };
16
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Interaction } from '../types.js';
2
- export declare function defaultGenerateVisual(interaction: Interaction, conversationContext: string): Promise<{
2
+ /** Width shared by model-time and local resize-time visual rendering. */
3
+ export declare function visualRenderWidth(cols: number): number;
4
+ export declare function defaultGenerateVisual(interaction: Interaction, conversationContext: string, cols?: number): Promise<{
3
5
  ok: true;
4
6
  ansi: string;
5
7
  markdown: string;
@@ -42,16 +42,18 @@ async function callHaiku(prompt, systemPrompt) {
42
42
  return null;
43
43
  }
44
44
  }
45
- // defaultGenerateVisual matches the GenerateVisual contract for use with
46
- // mountPanel. Width is read from process.stdout.columns so callers that
47
- // embed humanloop in a sub-region should supply their own closure that bakes
48
- // in the correct width.
45
+ // The mounting surface supplies its actual render width; generated ANSI must
46
+ // fit an embedded panel just as it fits a standalone terminal.
49
47
  // Cap on how much conversation we hand the generator. Recency is what grounds
50
48
  // the decision in front of the human, so when history is long we keep the tail
51
49
  // (most recent messages) rather than the head.
52
50
  const MAX_CONTEXT_CHARS = 24000;
53
- export async function defaultGenerateVisual(interaction, conversationContext) {
54
- const width = Math.max(40, Math.min((process.stdout.columns || 80) - 4, 76));
51
+ /** Width shared by model-time and local resize-time visual rendering. */
52
+ export function visualRenderWidth(cols) {
53
+ return Math.max(1, Math.min(cols - 4, 76));
54
+ }
55
+ export async function defaultGenerateVisual(interaction, conversationContext, cols = (process.stdout.columns || 80)) {
56
+ const width = visualRenderWidth(cols);
55
57
  const optionsSummary = interaction.options.length > 0
56
58
  ? `\nOptions: ${interaction.options.map((o) => o.label).join(' | ')}`
57
59
  : '';