@rind-ai/cli 0.6.1 → 0.7.0

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.
package/lib/rendering.js CHANGED
@@ -1,10 +1,363 @@
1
- import { clipCells, middleClipCells, textWidth, wrapTextCells } from "./text-width.js";
1
+ import { clipCells, graphemes, middleClipCells, stripAnsi, textWidth, wrapTextCells } from "./text-width.js";
2
+ import { formatDuration } from "./tool-display.js";
2
3
  import { paint, flavorSwatch } from "./theme.js";
3
4
  import { homedir } from "node:os";
4
5
 
5
6
  const MAX_STARTUP_BANNER_WIDTH = 80;
6
7
  const MAX_COMPOSER_WIDTH = 78;
7
8
  const MAX_FILE_CHANGE_LINES = 20;
9
+ const BOARD_MIN_WIDTH = 60;
10
+ const BOARD_MAX_WIDTH = 100;
11
+ const BOARD_PALETTE_ROLES = ["accent", "success", "warning", "danger", "notice", "path", "code", "fence"];
12
+ const BOARD_SECTION_SHORT_LABELS = {
13
+ chat_user: "user",
14
+ chat_assistant: "chat",
15
+ reasoning: "reasoning",
16
+ system_prompt: "system",
17
+ skill_catalog: "skills",
18
+ rind_docs: "RIND",
19
+ rind_docs_user: "RIND",
20
+ rind_docs_project: "RIND",
21
+ compaction_handoff: "compact",
22
+ goal_policy: "goal",
23
+ delegate: "delegate",
24
+ team_agent_catalog: "team",
25
+ "tool:other": "other",
26
+ };
27
+
28
+ export function boardWidth(width) {
29
+ const columns = Number(width ?? process.stdout.columns);
30
+ if (!Number.isFinite(columns) || columns <= 0) {
31
+ return BOARD_MAX_WIDTH;
32
+ }
33
+ return Math.max(BOARD_MIN_WIDTH, Math.min(BOARD_MAX_WIDTH, Math.floor(columns)));
34
+ }
35
+
36
+ export function occupancyTone(percent) {
37
+ const value = Number(percent);
38
+ if (!Number.isFinite(value)) {
39
+ return "neutral";
40
+ }
41
+ if (value > 0.85) {
42
+ return "err";
43
+ }
44
+ if (value >= 0.6) {
45
+ return "warn";
46
+ }
47
+ return "neutral";
48
+ }
49
+
50
+ const BOARD_FOOTER = "Tab switch page · Esc exit";
51
+
52
+ export function contextBoardText(page = {}, width) {
53
+ const frameWidth = boardWidth(width);
54
+ const breakdown = isBoardRecord(page.breakdown) ? page.breakdown : null;
55
+ const usage = isBoardRecord(page.latest_usage) ? page.latest_usage : null;
56
+ const plain = page.plain === true;
57
+ const title = "Context · last sampling";
58
+ if (!breakdown || !Array.isArray(breakdown.sections) || !breakdown.sections.length) {
59
+ return boardPanel({
60
+ title,
61
+ lines: boardEmptyLines(frameWidth, "No context sampled yet"),
62
+ footer: BOARD_FOOTER,
63
+ page,
64
+ frameWidth,
65
+ plain,
66
+ });
67
+ }
68
+ const windowTokens = boardNumber(breakdown.context_window_tokens);
69
+ const estimated = boardNumber(breakdown.estimated_total);
70
+ const measured = Math.max(0, boardNumber(usage?.input_tokens));
71
+ const usedPercent = windowTokens > 0 ? estimated / windowTokens : 0;
72
+ const lines = contextMetaLines(windowTokens, usedPercent, measured, estimated, frameWidth - 4);
73
+ const innerWidth = frameWidth - 4;
74
+ if (windowTokens > 0) {
75
+ lines.push(stackedShareBar(breakdown.sections, windowTokens, innerWidth));
76
+ lines.push(barUnderline(breakdown.sections, windowTokens, innerWidth));
77
+ }
78
+ lines.push(...contextSectionRows(breakdown.sections, estimated, innerWidth));
79
+ return boardPanel({
80
+ title: contextTitle(breakdown, title),
81
+ lines,
82
+ footer: BOARD_FOOTER,
83
+ page,
84
+ frameWidth,
85
+ plain,
86
+ });
87
+ }
88
+
89
+ export function usageBoardText(page = {}, width) {
90
+ const frameWidth = boardWidth(width);
91
+ const summary = isBoardRecord(page.summary) ? page.summary : {};
92
+ const totals = isBoardRecord(summary.totals) ? summary.totals : {};
93
+ const plain = page.plain === true;
94
+ const days = boardNumber(summary.days) || 7;
95
+ const title = `Token usage · last ${days} day${days === 1 ? "" : "s"}`;
96
+ if (!boardNumber(totals.samples)) {
97
+ return boardPanel({
98
+ title,
99
+ lines: boardEmptyLines(frameWidth, "No usage recorded yet"),
100
+ footer: BOARD_FOOTER,
101
+ page,
102
+ frameWidth,
103
+ plain,
104
+ });
105
+ }
106
+ const lines = usageHeroLines(totals, frameWidth - 4);
107
+ lines.push(dim(`${boardNumber(totals.samples)} samples`));
108
+ const byDay = Array.isArray(summary.by_day) ? summary.by_day : [];
109
+ if (byDay.length) {
110
+ lines.push("", bold("By day"), ...byDayRows(byDay, frameWidth - 4));
111
+ }
112
+ const byModel = Array.isArray(summary.by_model) ? summary.by_model : [];
113
+ if (byModel.length) {
114
+ lines.push("", bold("By model"), ...rightRows(byModel.map((row) => [boardText(row?.model), boardCompact(row?.tokens)]), frameWidth - 4));
115
+ }
116
+ const sessions = Array.isArray(summary.recent_sessions) ? summary.recent_sessions : [];
117
+ if (sessions.length) {
118
+ lines.push(
119
+ "",
120
+ bold(`By session (latest ${sessions.length})`),
121
+ ...rightRows(sessions.map((row) => [sessionRowLabel(row), boardCompact(row?.tokens)]), frameWidth - 4),
122
+ );
123
+ }
124
+ const compactions = boardNumber(totals.compactions);
125
+ const footer = compactions > 0
126
+ ? `${formatBoardNumber(compactions)} compaction call${compactions === 1 ? "" : "s"} · ${BOARD_FOOTER}`
127
+ : BOARD_FOOTER;
128
+ return boardPanel({ title, lines, footer, page, frameWidth, plain });
129
+ }
130
+
131
+ function contextTitle(breakdown, fallback) {
132
+ const turn = boardText(breakdown.turn_id);
133
+ const time = boardText(breakdown.captured_at).slice(11, 19);
134
+ const parts = [fallback];
135
+ if (turn) {
136
+ parts.push(`turn ${turn.slice(0, 4)}`);
137
+ }
138
+ if (/^\d{2}:\d{2}:\d{2}$/.test(time)) {
139
+ parts.push(time);
140
+ }
141
+ return parts.join(" · ");
142
+ }
143
+
144
+ function contextMetaLines(windowTokens, usedPercent, measured, estimated, inner) {
145
+ const tone = occupancyTone(usedPercent);
146
+ const paintTone = tone === "err" ? red : tone === "warn" ? paint.warning : (text) => text;
147
+ const windowPart = `Window ${formatBoardNumber(windowTokens)} · ${paintTone(`used ${Math.round(usedPercent * 100)}%`)}`;
148
+ const measuredPart = measured > 0
149
+ ? `measured ${formatBoardNumber(measured)} · estimated ~${formatBoardNumber(estimated)} ${estimatedDeviation(measured, estimated)}`
150
+ : "";
151
+ if (measuredPart && textWidth(`${windowPart} ${measuredPart}`) > inner) {
152
+ return [windowPart, measuredPart];
153
+ }
154
+ return [measuredPart ? `${windowPart} ${measuredPart}` : windowPart];
155
+ }
156
+
157
+ function estimatedDeviation(measured, estimated) {
158
+ if (estimated <= 0) {
159
+ return "";
160
+ }
161
+ const percent = Math.round(((measured - estimated) / estimated) * 100);
162
+ return `(${percent >= 0 ? "+" : ""}${percent}%)`;
163
+ }
164
+
165
+ function stackedShareBar(sections, windowTokens, cells) {
166
+ const spans = boardSpans(sections, windowTokens, cells);
167
+ const remaining = Math.max(0, cells - spans.reduce((sum, span) => sum + span.cells, 0));
168
+ const bar = spans.map((span, index) => (
169
+ boardPalette(index)(BOARD_BAR_CELL.repeat(span.cells))
170
+ )).join("");
171
+ return `${bar}${dim(BOARD_REMAINDER_CELL.repeat(remaining))}`;
172
+ }
173
+
174
+ function barUnderline(sections, windowTokens, cells) {
175
+ const spans = boardSpans(sections, windowTokens, cells);
176
+ const pieces = [];
177
+ for (const [index, span] of spans.entries()) {
178
+ const label = boardShortLabel(span);
179
+ const labelWidth = textWidth(label);
180
+ if (label && span.cells >= labelWidth + 1) {
181
+ pieces.push(boardPalette(index)(label));
182
+ pieces.push(dim(BOARD_UNDERLINE_CELL.repeat(span.cells - labelWidth)));
183
+ } else {
184
+ pieces.push(dim(BOARD_UNDERLINE_CELL.repeat(span.cells)));
185
+ }
186
+ }
187
+ pieces.push(dim(BOARD_UNDERLINE_CELL.repeat(Math.max(0, cells - spans.reduce((sum, span) => sum + span.cells, 0)))));
188
+ return pieces.join("");
189
+ }
190
+
191
+ function boardSpans(sections, windowTokens, cells) {
192
+ return sections.map((section, index) => ({
193
+ ...section,
194
+ paletteIndex: index,
195
+ cells: Math.max(0, Math.round((boardNumber(section.tokens) / windowTokens) * cells)),
196
+ }));
197
+ }
198
+
199
+ function contextSectionRows(sections, total, innerWidth) {
200
+ const nameWidth = Math.min(
201
+ Math.max(12, ...sections.map((section) => textWidth(boardText(section.label)))),
202
+ Math.max(12, innerWidth - 24),
203
+ );
204
+ const tokenTexts = sections.map((section) => formatBoardNumber(section.tokens));
205
+ const tokenWidth = Math.max(6, ...tokenTexts.map(textWidth));
206
+ const messageTexts = sections.map((section) => boardMessagesLabel(section.messages));
207
+ const messageWidth = Math.max(6, ...messageTexts.map(textWidth));
208
+ return sections.map((section, index) => {
209
+ const percent = total > 0 ? Math.round((boardNumber(section.tokens) / total) * 100) : 0;
210
+ return [
211
+ ` ${padRight(clipSingleLine(section.label, nameWidth), nameWidth)}`,
212
+ padLeft(tokenTexts[index], tokenWidth),
213
+ padLeft(`${percent}%`, 4),
214
+ padLeft(messageTexts[index], messageWidth),
215
+ ].join(" ");
216
+ });
217
+ }
218
+
219
+ function boardMessagesLabel(count) {
220
+ const value = Math.max(0, boardNumber(count));
221
+ return `${formatBoardNumber(value)} ${value === 1 ? "msg" : "msgs"}`;
222
+ }
223
+
224
+ function usageHeroLine(totals) {
225
+ const input = Math.max(0, boardNumber(totals.input));
226
+ const cached = Math.max(0, boardNumber(totals.cached));
227
+ const hit = input > 0 ? Math.round((cached / input) * 100) : 0;
228
+ return [
229
+ `Input ${boardCompact(totals.input)}`,
230
+ `Cache hit ${boardCompact(cached)}·${hit}%`,
231
+ `Output ${boardCompact(totals.output)}`,
232
+ `Reasoning ${boardCompact(totals.reasoning)}`,
233
+ ].join(" ");
234
+ }
235
+
236
+ function usageHeroLines(totals, inner) {
237
+ const line = usageHeroLine(totals);
238
+ if (textWidth(stripAnsi(line)) <= inner) {
239
+ return [line];
240
+ }
241
+ return [
242
+ `Input ${boardCompact(totals.input)} Cache hit ${boardCompact(totals.cached)}`,
243
+ `Output ${boardCompact(totals.output)} Reasoning ${boardCompact(totals.reasoning)}`,
244
+ ];
245
+ }
246
+
247
+ function byDayRows(byDay, innerWidth) {
248
+ const values = byDay.map((row) => boardCompact(row?.tokens));
249
+ const valueWidth = Math.max(5, ...values.map(textWidth));
250
+ const peak = Math.max(1, ...byDay.map((row) => Math.max(0, boardNumber(row?.tokens))));
251
+ const barCells = Math.max(3, innerWidth - 11 - valueWidth);
252
+ return byDay.map((row, index) => {
253
+ const filled = Math.max(row && boardNumber(row.tokens) > 0 ? 1 : 0, Math.round((Math.max(0, boardNumber(row?.tokens)) / peak) * barCells));
254
+ const bar = accent(BOARD_BAR_CELL.repeat(Math.min(barCells, filled)));
255
+ const region = bar + " ".repeat(Math.max(0, barCells - Math.min(barCells, filled)));
256
+ return ` ${boardDayLabel(row?.day)} ${region} ${padLeft(values[index], valueWidth)}`;
257
+ });
258
+ }
259
+
260
+ function boardDayLabel(day) {
261
+ const text = boardText(day);
262
+ return /^\d{2}-\d{2}$/.test(text) ? text : " ".repeat(5);
263
+ }
264
+
265
+ function rightRows(rows, innerWidth) {
266
+ const valueWidth = Math.max(5, ...rows.map((row) => textWidth(row[1])));
267
+ const labelWidth = Math.max(1, innerWidth - 2 - valueWidth - 2);
268
+ const clipped = rows.map((row) => clipSingleLine(row[0], labelWidth));
269
+ const columnWidth = Math.max(1, ...clipped.map(textWidth));
270
+ return rows.map((row, index) => ` ${padRight(clipped[index], columnWidth)} ${padLeft(row[1], valueWidth)}`);
271
+ }
272
+
273
+ function sessionRowLabel(row) {
274
+ const updated = boardText(row?.updated_at);
275
+ const day = /^\d{4}-\d{2}-\d{2}/.test(updated) ? updated.slice(5, 10) : "";
276
+ return [day, boardText(row?.session_id)].filter(Boolean).join(" ");
277
+ }
278
+
279
+ function boardShortLabel(section) {
280
+ if (BOARD_SECTION_SHORT_LABELS[section.key]) {
281
+ return BOARD_SECTION_SHORT_LABELS[section.key];
282
+ }
283
+ if (String(section.key || "").startsWith("tool:")) {
284
+ return String(section.key).slice(5);
285
+ }
286
+ const label = boardText(section.label);
287
+ return label.split("·")[0].trim().slice(0, 12);
288
+ }
289
+
290
+ function boardPalette(index) {
291
+ const painter = paint[BOARD_PALETTE_ROLES[index % BOARD_PALETTE_ROLES.length]];
292
+ return typeof painter === "function" ? painter : (text) => text;
293
+ }
294
+
295
+ function boardPanel({ title, lines, footer, page, frameWidth, plain }) {
296
+ const index = Math.max(1, boardNumber(page?.index) || 1);
297
+ const count = Math.max(index, boardNumber(page?.count) || 1);
298
+ if (plain) {
299
+ // Interaction hints are meaningless in frame-less pipe output.
300
+ return [bold(clipSingleLine(title, frameWidth)), ...lines].join("\n");
301
+ }
302
+ const inner = frameWidth - 4;
303
+ const pageLabel = count > 1 ? `${index}/${count}` : "";
304
+ const titlePart = ` ${clipSingleLine(title, inner - pageLabel.length - 4)} `;
305
+ const dashes = Math.max(1, frameWidth - 2 - textWidth(titlePart) - (pageLabel ? pageLabel.length + 2 : 0));
306
+ const top = `┌${accent(titlePart)}${dim("─".repeat(dashes))}${pageLabel ? ` ${dim(pageLabel)} ` : ""}┐`;
307
+ const footerLines = footer ? [dim(footer)] : [];
308
+ const bodyRows = [...lines, ...footerLines].map((line) => `${dim("│")} ${padRight(line, inner)} ${dim("│")}`);
309
+ const bottom = `${dim("└")}${dim("─".repeat(frameWidth - 2))}${dim("┘")}`;
310
+ return [top, ...bodyRows, bottom].join("\n");
311
+ }
312
+
313
+ function boardEmptyLines(frameWidth, label) {
314
+ const inner = frameWidth - 4;
315
+ return [
316
+ boardCentered(bold(label || "Nothing sampled yet"), inner),
317
+ boardCentered(dim("Send a message first, then try again."), inner),
318
+ ];
319
+ }
320
+
321
+ function boardCentered(text, inner) {
322
+ return `${" ".repeat(Math.max(0, Math.floor((inner - textWidth(text)) / 2)))}${text}`;
323
+ }
324
+
325
+ function isBoardRecord(value) {
326
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
327
+ }
328
+
329
+ function boardText(value) {
330
+ return String(value ?? "").replace(/\s+/g, " ").trim();
331
+ }
332
+
333
+ function boardNumber(value) {
334
+ const number = Number(value);
335
+ return Number.isFinite(number) ? number : 0;
336
+ }
337
+
338
+ function formatBoardNumber(value) {
339
+ const number = Math.max(0, Math.round(boardNumber(value)));
340
+ return String(number).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
341
+ }
342
+
343
+ function boardCompact(value) {
344
+ const number = Math.max(0, boardNumber(value));
345
+ if (number >= 1e6) {
346
+ return `${(number / 1e6).toFixed(2)}M`;
347
+ }
348
+ if (number >= 1e5) {
349
+ return `${Math.round(number / 1e3)}K`;
350
+ }
351
+ if (number >= 1e3) {
352
+ return `${(number / 1e3).toFixed(1)}K`.replace(/\.0K$/, "K");
353
+ }
354
+ return String(Math.round(number));
355
+ }
356
+
357
+ const BOARD_BAR_CELL = "█";
358
+ const BOARD_REMAINDER_CELL = "░";
359
+ const BOARD_UNDERLINE_CELL = "▔";
360
+
8
361
 
9
362
  export function startupText(info = {}, width) {
10
363
  const header = startupBannerText(info, width);
@@ -14,8 +367,8 @@ export function startupText(info = {}, width) {
14
367
  return sections.filter(Boolean).join("\n\n");
15
368
  }
16
369
 
17
- export function promptText(info = {}, _stats = {}, state = {}, frameWidth) {
18
- return inputPromptFrame(promptHeaderLine(info, frameWidth), state, frameWidth);
370
+ export function promptText(info = {}, stats = {}, state = {}, frameWidth) {
371
+ return inputPromptFrame(promptHeaderLine(info, stats, frameWidth), state, frameWidth);
19
372
  }
20
373
 
21
374
  export function promptActivityLine(state = {}) {
@@ -27,11 +380,23 @@ export function promptActivityLine(state = {}) {
27
380
  return ` ${accent(activityFrame(state.frame))} ${bold(label)} ${dim(`(${elapsed}) ctrl+c interrupt`)}`;
28
381
  }
29
382
 
383
+ export function promptHintLine(state = {}) {
384
+ if (state.menuOpen) {
385
+ return "";
386
+ }
387
+ const text = state.inputMode === "question"
388
+ ? " ↑↓ choose · enter confirm · esc cancel"
389
+ : state.running
390
+ ? " enter steer · tab queue · ctrl+c stop · ctrl+b tasks"
391
+ : " enter send · ↑↓ history · / commands · ? help";
392
+ return dim(clipCells(text, composerWidth(state.frameWidth)));
393
+ }
394
+
30
395
  export function promptPlaceholderText() {
31
396
  return "Ask Rind to do anything";
32
397
  }
33
398
 
34
- export function userInputText(text, width) {
399
+ export function userInputText(text, width, source = "") {
35
400
  const lines = messageLines(text);
36
401
  if (!lines.length) {
37
402
  return "";
@@ -40,7 +405,8 @@ export function userInputText(text, width) {
40
405
  const physicalLines = lines.flatMap((line) => (
41
406
  wrapTextCells(line, contentWidth, contentWidth).map((chunk) => ` ${chunk.text}`)
42
407
  ));
43
- return `${accent("▷")} ${bold("You")}\n${physicalLines.join("\n")}`;
408
+ const origin = source ? dim(` · via ${source}`) : "";
409
+ return `${accent("▷")} ${bold("You")}${origin}\n${physicalLines.join("\n")}`;
44
410
  }
45
411
 
46
412
  export function assistantHeaderText() {
@@ -197,6 +563,7 @@ export function questionMenuFrame(
197
563
  editing = false,
198
564
  customLabel = "Type your own answer",
199
565
  width = 76,
566
+ editorCursor = null,
200
567
  ) {
201
568
  const entries = [
202
569
  ...(Array.isArray(options) ? options : []),
@@ -230,14 +597,24 @@ export function questionMenuFrame(
230
597
  lastPushedLine = lines.length - 1;
231
598
  }
232
599
  if (active && editing && isCustom) {
233
- const cursorLine = customText ? Math.max(0, lastPushedLine) : Math.max(0, firstPushedLine);
234
- const cursorColumn = customText
235
- ? textWidth(lines[cursorLine])
236
- : firstPrefixWidth;
237
- cursor = {
238
- line: cursorLine,
239
- column: Math.max(0, cursorColumn),
240
- };
600
+ if (editorCursor) {
601
+ cursor = customAnswerCursor(
602
+ customText,
603
+ editorCursor,
604
+ Math.max(0, firstPushedLine),
605
+ firstPrefixWidth,
606
+ Math.max(1, width - 4),
607
+ );
608
+ } else {
609
+ const cursorLine = customText ? Math.max(0, lastPushedLine) : Math.max(0, firstPushedLine);
610
+ const cursorColumn = customText
611
+ ? textWidth(lines[cursorLine])
612
+ : firstPrefixWidth;
613
+ cursor = {
614
+ line: cursorLine,
615
+ column: Math.max(0, cursorColumn),
616
+ };
617
+ }
241
618
  }
242
619
  if (option.description) {
243
620
  const descriptionLines = wrapQuestionLines(option.description, Math.max(1, width - 6));
@@ -257,6 +634,23 @@ function wrapQuestionLines(value, width) {
257
634
  return wrapTextCells(text, Math.max(1, width), Math.max(1, width)).map((chunk) => chunk.text);
258
635
  }
259
636
 
637
+ function customAnswerCursor(customText, editorCursor, firstRow, prefixWidth, labelWidth) {
638
+ const rawLines = String(customText || "").split("\n");
639
+ const line = Math.min(rawLines.length - 1, Math.max(0, Math.floor(Number(editorCursor.line) || 0)));
640
+ const column = Math.min(
641
+ graphemes(rawLines[line]).length,
642
+ Math.max(0, Math.floor(Number(editorCursor.column) || 0)),
643
+ );
644
+ const before = [...rawLines.slice(0, line), graphemes(rawLines[line]).slice(0, column).join("")]
645
+ .join(" ")
646
+ .trim();
647
+ const chunks = wrapTextCells(before, Math.max(1, labelWidth), Math.max(1, labelWidth));
648
+ return {
649
+ line: firstRow + chunks.length - 1,
650
+ column: prefixWidth + textWidth(chunks[chunks.length - 1].text),
651
+ };
652
+ }
653
+
260
654
  export function backgroundMonitorText(tasks = [], selectedIndex = 0, selectedTask = null, width = 76) {
261
655
  const items = Array.isArray(tasks) ? tasks : [];
262
656
  const lines = [dim(" ←→ page · ↑↓/j/k select · esc/ctrl+b close")];
@@ -433,10 +827,6 @@ export function commandResultText(text, detail = "") {
433
827
  return `${green("✓")} ${bold(clipSingleLine(text, 96))}${extra ? dim(` — ${extra}`) : ""}`;
434
828
  }
435
829
 
436
- export function modelUsageText() {
437
- return notice("Model command", "/model set <name>");
438
- }
439
-
440
830
  export function contextBuiltLine(event) {
441
831
  const decisions = event.decisions && typeof event.decisions === "object" ? event.decisions : {};
442
832
  if (!decisions.rind_docs_truncated) {
@@ -448,10 +838,6 @@ export function contextBuiltLine(event) {
448
838
  return notice("Context trimmed", `RIND.md: ${clipSingleLine(scopes, 96)}`);
449
839
  }
450
840
 
451
- export function unknownCommandText() {
452
- return notice("Unknown command", "type / to browse commands or ? for shortcuts");
453
- }
454
-
455
841
  function notice(label, ...details) {
456
842
  const lines = [`${accent("◆")} ${bold(label)}`];
457
843
  for (const detail of details.flat()) {
@@ -462,18 +848,18 @@ function notice(label, ...details) {
462
848
  return lines.join("\n");
463
849
  }
464
850
 
465
- export function toolRequestedLine(event) {
851
+ export function toolRequestedLine(event) {
466
852
  const name = event.tool_name || "unknown";
467
853
  const detail = toolDetail(name, parseJsonObject(event.args_preview));
468
854
  const label = toolLabel(name);
469
855
  const line = `${accent("◌")} ${bold("Tool")} ${dim("·")} ${toolActiveVerb(name)} ${label}`;
470
- return indentToolText(detail ? `${line}\n${dim(toolDetailLine(name, detail))}` : line);
471
- }
856
+ return indentToolText(detail ? `${line}\n${dim(toolDetailLine(name, detail))}` : line);
857
+ }
472
858
 
473
- export function toolStartedLine(event) {
474
- const name = event.tool_name || "tool";
475
- return indentToolText(`${accent("◌")} ${bold("Tool")} ${dim("·")} ${toolActiveVerb(name)} ${toolLabel(name)}`);
476
- }
859
+ export function toolStartedLine(event) {
860
+ const name = event.tool_name || "tool";
861
+ return indentToolText(`${accent("◌")} ${bold("Tool")} ${dim("·")} ${toolActiveVerb(name)} ${toolLabel(name)}`);
862
+ }
477
863
 
478
864
  export function toolResultLine(event, fileChange) {
479
865
  const name = event.tool_name || "unknown";
@@ -483,7 +869,7 @@ export function toolResultLine(event, fileChange) {
483
869
  const suffix = event.error_type ? ` (${event.error_type})` : "";
484
870
  const detail = toolErrorDetail(event.result);
485
871
  const line = `${red("⊘")} ${bold("Tool")} ${dim("·")} ${label} failed in ${duration}${suffix}`;
486
- return indentToolText(detail ? `${line}\n${dim(detailLine(detail))}` : line);
872
+ return indentToolText(detail ? `${line}\n${dim(detailLine(detail))}` : line);
487
873
  }
488
874
  const result = toolResultSummary(event.result);
489
875
  if (result.status === "running" && (name === "bash" || name === "bash_output")) {
@@ -492,53 +878,41 @@ export function toolResultLine(event, fileChange) {
492
878
  : "command running in background";
493
879
  const line = `${accent("◌")} ${bold("Tool")} ${dim("·")} ${runningText} in ${duration}`;
494
880
  const output = result.output;
495
- return indentToolText([line, output ? dim(detailLine(output)) : "", fileChangeLine(fileChange)]
496
- .filter(Boolean)
497
- .join("\n"));
881
+ return indentToolText([line, output ? dim(detailLine(output)) : "", fileChangeLine(fileChange)]
882
+ .filter(Boolean)
883
+ .join("\n"));
498
884
  }
499
885
  const line = result.exitCode
500
886
  ? `${red("⊘")} ${bold("Tool")} ${dim("·")} ${label} exited ${result.exitCode} in ${duration}`
501
887
  : `${green("◉")} ${bold("Tool")} ${dim("·")} ${completedToolText(name, label)} in ${duration}`;
502
888
  const output = result.output;
503
- return indentToolText([line, output ? dim(detailLine(output)) : "", fileChangeLine(fileChange)]
504
- .filter(Boolean)
505
- .join("\n"));
506
- }
507
-
508
- function indentToolText(value) {
509
- return String(value || "")
510
- .split("\n")
511
- .map((line) => ` ${line}`)
512
- .join("\n");
513
- }
514
-
515
- export function planUpdatedLine(plan) {
516
- const items = Array.isArray(plan) ? plan : [];
517
- if (!items.length) {
518
- return ` ${green("◉")} ${bold("Plan cleared")}`;
519
- }
520
-
521
- const lines = [` ${green("◉")} ${bold("Plan updated")}`];
522
- for (const item of items) {
523
- const step = clipSingleLine(item?.step, detailTextWidth());
524
- if (step) {
525
- lines.push(` ${planStatusIcon(item?.status)} ${step}`);
526
- }
527
- }
528
- return lines.join("\n");
889
+ return indentToolText([line, output ? dim(detailLine(output)) : "", fileChangeLine(fileChange)]
890
+ .filter(Boolean)
891
+ .join("\n"));
529
892
  }
530
893
 
531
- export function goalContinuedLine(round) {
532
- return `${accent("◌")} ${bold("Goal continued")} ${dim(`· round ${Number(round) || 0}`)}`;
894
+ function indentToolText(value) {
895
+ return String(value || "")
896
+ .split("\n")
897
+ .map((line) => ` ${line}`)
898
+ .join("\n");
533
899
  }
534
900
 
535
- export function toolProgressLine(event) {
536
- const name = event.tool_name || "tool";
537
- const message = progressMessage(event.payload);
538
- return message
539
- ? indentToolText(`${accent("◌")} ${bold("Tool")} ${dim("·")} ${toolLabel(name)}\n${dim(` ↳ ${message}`)}`)
540
- : "";
541
- }
901
+ export function planUpdatedLine(plan) {
902
+ const items = Array.isArray(plan) ? plan : [];
903
+ if (!items.length) {
904
+ return ` ${green("◉")} ${bold("Plan cleared")}`;
905
+ }
906
+
907
+ const lines = [` ${green("◉")} ${bold("Plan updated")}`];
908
+ for (const item of items) {
909
+ const step = clipSingleLine(item?.step, detailTextWidth());
910
+ if (step) {
911
+ lines.push(` ${planStatusIcon(item?.status)} ${step}`);
912
+ }
913
+ }
914
+ return lines.join("\n");
915
+ }
542
916
 
543
917
  export function errorLine(error) {
544
918
  const detail = clipSingleLine(error, 120);
@@ -966,19 +1340,6 @@ function nonZeroExitCode(value) {
966
1340
  return Number.isInteger(code) && code !== 0 ? code : 0;
967
1341
  }
968
1342
 
969
- function progressMessage(payload) {
970
- if (!payload || typeof payload !== "object") {
971
- return "";
972
- }
973
- for (const key of ["message", "status", "text"]) {
974
- const value = clipSingleLine(payload[key], 120);
975
- if (value) {
976
- return value;
977
- }
978
- }
979
- return "";
980
- }
981
-
982
1343
  function fileChangeLine(fileChange) {
983
1344
  if (!fileChange || typeof fileChange !== "object") {
984
1345
  return "";
@@ -1062,23 +1423,6 @@ function userInputContentWidth(width) {
1062
1423
  return Number.isFinite(columns) && columns > 0 ? Math.max(1, Math.floor(columns - 2)) : 78;
1063
1424
  }
1064
1425
 
1065
- function formatDuration(durationMs) {
1066
- const value = Number(durationMs || 0);
1067
- if (!Number.isFinite(value) || value <= 0) {
1068
- return "0ms";
1069
- }
1070
- if (value < 1000) {
1071
- return `${Math.trunc(value)}ms`;
1072
- }
1073
- if (value >= 60000) {
1074
- const totalSeconds = Math.round(value / 1000);
1075
- const minutes = Math.floor(totalSeconds / 60);
1076
- const seconds = String(totalSeconds % 60).padStart(2, "0");
1077
- return `${minutes}m ${seconds}s`;
1078
- }
1079
- return `${(value / 1000).toFixed(2)}s`;
1080
- }
1081
-
1082
1426
  function formatActivityDuration(durationMs) {
1083
1427
  const value = Math.max(0, Number(durationMs || 0));
1084
1428
  if (!Number.isFinite(value) || value < 60000) {
@@ -1193,6 +1537,10 @@ function inputPromptFrame(header = "", state = {}, frameWidth) {
1193
1537
  if (header) {
1194
1538
  lines.push(header);
1195
1539
  }
1540
+ const hint = promptHintLine({ ...state, frameWidth });
1541
+ if (hint) {
1542
+ lines.push(hint);
1543
+ }
1196
1544
  lines.push(inputDivider(frameWidth));
1197
1545
  lines.push(" ▷ ");
1198
1546
  return lines.join("\n");
@@ -1226,6 +1574,7 @@ function pendingInputLines(entries, frameWidth) {
1226
1574
  const hints = [];
1227
1575
  if (entries.some((entry) => entry?.mode === "follow_up")) {
1228
1576
  hints.push("alt+up recall queue");
1577
+ hints.push("alt+right steer now");
1229
1578
  }
1230
1579
  if (entries.some((entry) => entry?.mode === "steering")) {
1231
1580
  hints.push("alt+down recall steer");
@@ -1246,11 +1595,15 @@ function padRight(text, width) {
1246
1595
  return `${text}${" ".repeat(Math.max(0, width - visibleLength(text)))}`;
1247
1596
  }
1248
1597
 
1598
+ function padLeft(text, width) {
1599
+ return `${" ".repeat(Math.max(0, width - visibleLength(text)))}${text}`;
1600
+ }
1601
+
1249
1602
  function visibleLength(text) {
1250
1603
  return textWidth(text);
1251
1604
  }
1252
1605
 
1253
- function promptHeaderLine(info, frameWidth) {
1606
+ function promptHeaderLine(info, stats = {}, frameWidth) {
1254
1607
  const backgroundCount = Number(info.background_count);
1255
1608
  const delegateCount = Number(info.delegate_count);
1256
1609
  const taskHints = [];
@@ -1263,6 +1616,7 @@ function promptHeaderLine(info, frameWidth) {
1263
1616
  const taskHint = taskHints.length
1264
1617
  ? dim(` · ${taskHints.join(" ")} (ctrl+b monitor)`)
1265
1618
  : "";
1619
+ const ctxSegment = contextUsageSegment(stats);
1266
1620
  const model = singleLine(info.model);
1267
1621
  const effort = singleLine(info.reasoning_effort);
1268
1622
  const cwd = middleClip(info.cwd, 56);
@@ -1270,15 +1624,25 @@ function promptHeaderLine(info, frameWidth) {
1270
1624
  const effortSegment = effort ? `${dim(" · ")}${promptModel(effort)}` : "";
1271
1625
  if (model && cwd) {
1272
1626
  const separator = " · ";
1273
- const pathWidth = width - visibleLength(model) - visibleLength(separator) - visibleLength(taskHint) - visibleLength(effortSegment);
1627
+ const pathWidth = width - visibleLength(model) - visibleLength(separator) - visibleLength(taskHint) - visibleLength(effortSegment) - visibleLength(ctxSegment);
1274
1628
  if (pathWidth > 0) {
1275
- return ` ${promptModel(clipSingleLine(model, width))}${effortSegment}${dim(separator)}${promptPath(clipSingleLine(cwd, pathWidth))}${taskHint}`;
1629
+ return ` ${promptModel(clipSingleLine(model, width))}${effortSegment}${dim(separator)}${promptPath(clipSingleLine(cwd, pathWidth))}${taskHint}${ctxSegment}`;
1276
1630
  }
1277
1631
  }
1278
1632
  if (model) {
1279
- return ` ${promptModel(clipSingleLine(model, width))}${taskHint}`;
1633
+ return ` ${promptModel(clipSingleLine(model, width))}${taskHint}${ctxSegment}`;
1634
+ }
1635
+ return cwd ? ` ${promptPath(clipSingleLine(cwd, width))}${taskHint}${ctxSegment}` : "";
1636
+ }
1637
+
1638
+ function contextUsageSegment(stats) {
1639
+ const ratio = Number(stats.context_usage_percent);
1640
+ if (!Number.isFinite(ratio) || ratio <= 0) {
1641
+ return "";
1280
1642
  }
1281
- return cwd ? ` ${promptPath(clipSingleLine(cwd, width))}${taskHint}` : "";
1643
+ const percent = `ctx ${Math.round(ratio * 100)}%`;
1644
+ const tone = occupancyTone(ratio);
1645
+ return `${dim(" · ")}${tone === "err" ? red(percent) : tone === "warn" ? paint.warning(percent) : percent}`;
1282
1646
  }
1283
1647
 
1284
1648
  function composerWidth(frameWidth) {