@oxecli/oxe 1.0.85 → 1.0.87

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/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { loadOrPrompt, default_reasoning_effort, max_action_chars, max_resume_history_items, runtimeOsSummary, max_context_tokens, context_overhead_margin, } from "./config.js";
5
5
  import { InferenceEngine, estimateTokens } from "./engine.js";
6
6
  import { saveSession, loadSession, listSessions, nextSessionId, conversationLabel, COMMAND_HELP, stripOrphanCalls, toolOutputFailed, } from "./sessions.js";
7
- import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, toolCallLabel, formatToolResult, truncateEllipsis, stripAnsi, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, } from "./ui.js";
7
+ import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, toolCallLabel, formatToolResult, truncateEllipsis, stripAnsi, plainLen, truncateStyled, terminalWidth, TOOL_RESULT_MAX_LINES, MAX_COMMAND_DISPLAY_CHARS, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, dockAppendContent, dockSetInactive, dockTearDown, } from "./ui.js";
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const require = createRequire(import.meta.url);
10
10
  const packageInfo = require("../package.json");
@@ -49,14 +49,32 @@ export class CLI {
49
49
  const label = started && String(started).trim();
50
50
  const body = String(text).trim();
51
51
  if (label && /^[⎿╰]/.test(stripAnsi(body))) {
52
- process.stdout.write(`${label}\n${body}\n`);
53
- if (diff)
54
- process.stdout.write(diff + "\n\n");
52
+ // The result body was formatted against the terminal width at tool time,
53
+ // but the window may be narrower on resume — re-truncate defensively so
54
+ // long persisted lines never run off the screen.
55
+ const lineMax = Math.max(terminalWidth() - 4, 20);
56
+ const allLines = body.split("\n");
57
+ const lines = allLines
58
+ .slice(0, TOOL_RESULT_MAX_LINES)
59
+ .map((ln) => plainLen(ln) > lineMax ? truncateStyled(ln, lineMax) + "…\x1b[0m" : ln);
60
+ if (allLines.length > TOOL_RESULT_MAX_LINES) {
61
+ lines.push("\x1b[90m…\x1b[0m");
62
+ }
63
+ const labelLine = plainLen(label) > lineMax ? truncateStyled(label, lineMax) + "…\x1b[0m" : label;
64
+ process.stdout.write(`${labelLine}\n${lines.join("\n")}\n`);
65
+ if (diff) {
66
+ const diffLines = diff.split("\n").map((ln) => plainLen(ln) > lineMax ? truncateStyled(ln, lineMax) + "…\x1b[0m" : ln);
67
+ process.stdout.write(diffLines.join("\n") + "\n");
68
+ }
55
69
  return;
56
70
  }
57
71
  const icon = status === "failed" ? "\x1b[31m✗\x1b[0m" : "\x1b[32m✓\x1b[0m";
58
72
  const style = status === "failed" ? "\x1b[31m" : "\x1b[32m";
59
- process.stdout.write(`${icon} ${style}${truncateEllipsis(body, max_action_chars, "text")}\x1b[0m\n`);
73
+ const truncated = truncateEllipsis(body, Math.min(max_action_chars, MAX_COMMAND_DISPLAY_CHARS), "text");
74
+ // truncateEllipsis appends a "…(text truncated: N chars total)" suffix, so
75
+ // re-truncate to the current width to keep the legacy action on one row.
76
+ const displayMax = Math.max(terminalWidth() - 6, 20);
77
+ process.stdout.write(`${icon} ${style}${truncateStyled(truncated, displayMax)}\x1b[0m\n`);
60
78
  }
61
79
  printAssistantBlock(text) {
62
80
  const rendered = aiMarkdown(text);
@@ -73,6 +91,7 @@ export class CLI {
73
91
  process.stdout.write("\n");
74
92
  }
75
93
  async showHelp() {
94
+ dockTearDown();
76
95
  const rows = COMMAND_HELP.map(([cmd, desc]) => {
77
96
  const styled = cmd.replace(/(<[^>]+>)/g, "\x1b[36m$1\x1b[0m");
78
97
  return {
@@ -86,7 +105,7 @@ export class CLI {
86
105
  titleAlign: "left",
87
106
  colGap: 3,
88
107
  });
89
- process.stdout.write("\n" + panel + "\n");
108
+ process.stdout.write(panel + "\n");
90
109
  if (process.stdin.isTTY) {
91
110
  enableRawStdin();
92
111
  try {
@@ -99,7 +118,7 @@ export class CLI {
99
118
  disableRawStdin();
100
119
  }
101
120
  const n = panel.split("\n").length;
102
- process.stdout.write(`\x1b[${n + 1}A\r\x1b[J`);
121
+ process.stdout.write(`\x1b[${n}A\r\x1b[J`);
103
122
  }
104
123
  }
105
124
  async awaitRawCloseKey() {
@@ -291,7 +310,6 @@ export class CLI {
291
310
  async pickConversation() {
292
311
  const recs = listSessions();
293
312
  if (!recs.length) {
294
- process.stdout.write("\n");
295
313
  renderPanel("No saved conversations yet. Type a prompt to start one.", "Conversations");
296
314
  return null;
297
315
  }
@@ -300,10 +318,11 @@ export class CLI {
300
318
  return null;
301
319
  }
302
320
  enableRawStdin();
321
+ dockTearDown();
303
322
  let selected = 0;
304
323
  let lastRows = 0;
305
324
  const draw = () => {
306
- const frame = "\n" + this.renderPickerPanel(recs, selected);
325
+ const frame = this.renderPickerPanel(recs, selected);
307
326
  const n = frame.split("\n").length;
308
327
  if (lastRows > 0)
309
328
  process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
@@ -341,13 +360,11 @@ export class CLI {
341
360
  async resumeConversation(engine, num) {
342
361
  const sid = parseInt(num, 10);
343
362
  if (Number.isNaN(sid)) {
344
- process.stdout.write("\n");
345
363
  renderPanel(`Invalid conversation number: ${num}`, "Error", "", true, "31");
346
364
  return;
347
365
  }
348
366
  const rec = loadSession(sid);
349
367
  if (!rec) {
350
- process.stdout.write("\n");
351
368
  renderPanel(`Conversation ${sid} not found.`, "Error", "", true, "31");
352
369
  return;
353
370
  }
@@ -358,10 +375,11 @@ export class CLI {
358
375
  this.sessionId = sid;
359
376
  engine.reasoningEffort = String(rec["reasoning_effort"] ?? default_reasoning_effort);
360
377
  clearScreen();
378
+ dockSetInactive();
361
379
  this.renderHeader();
362
380
  process.stdout.write("\n");
363
381
  process.stdout.write(`\x1b[90mResumed:\x1b[0m \x1b[1m${truncateLabel(rec["label"])}\x1b[0m\n`);
364
- process.stdout.write(`\x1b[90m(${this.inputItems.length} items · ${estimateTokens(this.inputItems).toLocaleString()} tokens)\x1b[0m\n\n`);
382
+ process.stdout.write(`\x1b[90m(${this.inputItems.length} items · ${estimateTokens(this.inputItems).toLocaleString()} tokens)\x1b[0m\n`);
365
383
  renderPanel("Conversation history restored", "Restored");
366
384
  process.stdout.write("\n");
367
385
  if (this.story.length)
@@ -403,6 +421,7 @@ export class CLI {
403
421
  this.story = [];
404
422
  this.sessionId = null;
405
423
  clearScreen();
424
+ dockSetInactive();
406
425
  this.renderHeader();
407
426
  continue;
408
427
  }
@@ -424,15 +443,14 @@ export class CLI {
424
443
  const effort = parts[1]?.toLowerCase() ?? "";
425
444
  if (["none", "low", "high"].includes(effort)) {
426
445
  engine.reasoningEffort = effort;
427
- process.stdout.write(`\n\x1b[32m✓\x1b[0m \x1b[90mReasoning effort set to \x1b[1;36m${effort}\x1b[0m\x1b[90m.\x1b[0m\n`);
446
+ dockAppendContent(`\x1b[32m✓\x1b[0m \x1b[90mReasoning effort set to \x1b[1;36m${effort}\x1b[0m\x1b[90m.\x1b[0m\n`);
428
447
  }
429
448
  else {
430
- process.stdout.write("\n");
431
449
  renderPanel("Usage: /effort none|low|high", "Error", "", true, "31");
432
450
  }
433
451
  continue;
434
452
  }
435
- process.stdout.write(`\n\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n\n`);
453
+ dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
436
454
  queryStarted = true;
437
455
  await engine.executeQuery(input, this.inputItems, this.story, spans);
438
456
  queryStarted = false;
@@ -448,7 +466,7 @@ export class CLI {
448
466
  if (err?.message === "interrupt") {
449
467
  const wasActive = queryStarted || engine.inQuery;
450
468
  if (wasActive && !engine.queryHasOutput && !engine.queryCalledTool) {
451
- process.stdout.write(aiMarkdown("What else can I help you with?") + "\n");
469
+ dockAppendContent(aiMarkdown("What else can I help you with?") + "\n");
452
470
  }
453
471
  engine.inQuery = false;
454
472
  stripOrphanCalls(this.inputItems);
@@ -470,8 +488,7 @@ export class CLI {
470
488
  break;
471
489
  }
472
490
  this.interruptPending = true;
473
- process.stdout.write("\n");
474
- process.stdout.write("\x1b[90mInterrupted agent. Press Ctrl+C again to exit, or type a new prompt.\x1b[0m\n");
491
+ dockAppendContent("\x1b[90mInterrupted agent. Press Ctrl+C again to exit, or type a new prompt.\x1b[0m\n");
475
492
  continue;
476
493
  }
477
494
  throw err;
package/dist/engine.js CHANGED
@@ -2,7 +2,7 @@ import OpenAI from "openai";
2
2
  import { max_output_tokens, max_empty_retries, max_agent_steps, max_context_tokens, context_overhead_margin, compact_keep_recent_turns, max_summary_source_chars, max_read_file_stored_chars, } from "./config.js";
3
3
  import { getSystemPrompt } from "./system.js";
4
4
  import { buildTools, truncateToolOutput, toolReadFile, toolWriteFile, toolEditFile, toolBash, toolGlob, toolGrep, toolLoadSkill, } from "./tools.js";
5
- import { mutedMarkdown, aiMarkdown, tickDuration, displayRows, safeCommitPoint, printAiChunk, toolCallLabel, formatToolResult, renderPanel, Spinner, hideCursor, } from "./ui.js";
5
+ import { mutedMarkdown, aiMarkdown, tickDuration, safeCommitPoint, printAiChunk, toolCallLabel, formatToolResult, renderPanel, Spinner, hideCursor, dockAppendContent, dockReplaceTransient, dockTransientStart, } from "./ui.js";
6
6
  import { reportUsage } from "./api.js";
7
7
  import { stripOrphanCalls, persistCompactionSummary, toolOutputFailed, } from "./sessions.js";
8
8
  import { takePendingDiffOutput } from "./tools.js";
@@ -43,8 +43,6 @@ export class InferenceEngine {
43
43
  reasoningEffort;
44
44
  keyData;
45
45
  client;
46
- workActive = false;
47
- workRows = 0;
48
46
  inQuery = false;
49
47
  activeAbort = null;
50
48
  interrupted = false;
@@ -146,22 +144,6 @@ export class InferenceEngine {
146
144
  incompleteReason(response) {
147
145
  return response?.incomplete_details?.reason ?? null;
148
146
  }
149
- overwriteWorkLine(text) {
150
- for (let i = 0; i < this.workRows; i++) {
151
- process.stdout.write("\x1b[1A\x1b[2K");
152
- }
153
- process.stdout.write(mutedMarkdown(text) + "\n\n");
154
- this.workRows = displayRows(mutedMarkdown(text)) + 1;
155
- }
156
- clearWorkLine() {
157
- if (!this.workActive || this.workRows < 1)
158
- return;
159
- for (let i = 0; i < this.workRows; i++) {
160
- process.stdout.write("\x1b[1A\x1b[2K");
161
- }
162
- this.workActive = false;
163
- this.workRows = 0;
164
- }
165
147
  async streamOnce(inputItems, stats, story, previousResponseId) {
166
148
  let content = "";
167
149
  let committedLen = 0;
@@ -169,6 +151,7 @@ export class InferenceEngine {
169
151
  let sawReasoning = false;
170
152
  let status = null;
171
153
  let thinkingStart = null;
154
+ dockTransientStart();
172
155
  const workStatus = new Spinner();
173
156
  const workingStarted = Date.now();
174
157
  workStatus.startWithRender(() => `Working ${tickDuration((Date.now() - workingStarted) / 1000)}`);
@@ -187,14 +170,7 @@ export class InferenceEngine {
187
170
  const elapsed = (Date.now() - workingStarted) / 1000;
188
171
  const t = tickDuration(elapsed);
189
172
  story.push({ type: "worked", text: `Worked for ${t}` });
190
- if (this.workActive) {
191
- this.overwriteWorkLine(`Worked for ${t}`);
192
- }
193
- else {
194
- process.stdout.write(mutedMarkdown(`Worked for ${t}`) + "\n\n");
195
- this.workActive = true;
196
- this.workRows = displayRows(mutedMarkdown(`Worked for ${t}`)) + 1;
197
- }
173
+ dockReplaceTransient(mutedMarkdown(`Worked for ${t}`) + "\n");
198
174
  };
199
175
  const finishThinking = (report) => {
200
176
  if (thinkingStart === null)
@@ -208,8 +184,7 @@ export class InferenceEngine {
208
184
  if (report) {
209
185
  const t = tickDuration(elapsed);
210
186
  story.push({ type: "thought", text: `Thought for ${t}` });
211
- process.stdout.write(mutedMarkdown(`Thought for ${t}`) + "\n\n");
212
- this.workActive = false;
187
+ dockReplaceTransient(mutedMarkdown(`Thought for ${t}`) + "\n");
213
188
  }
214
189
  thinkingStart = null;
215
190
  };
@@ -248,6 +223,7 @@ export class InferenceEngine {
248
223
  etype === "response.reasoning.summary.delta") {
249
224
  sawReasoning = true;
250
225
  silenceWorking();
226
+ dockTransientStart();
251
227
  if (thinkingStart === null) {
252
228
  thinkingStart = Date.now();
253
229
  status = new Spinner();
@@ -288,9 +264,7 @@ export class InferenceEngine {
288
264
  workStatus.stop();
289
265
  const stillThinking = thinkingStart !== null;
290
266
  finishThinking(stillThinking);
291
- if (this.interrupted && !this.queryHasOutput)
292
- this.clearWorkLine();
293
- else
267
+ if (!this.interrupted || this.queryHasOutput)
294
268
  reportWorking();
295
269
  if (content.slice(committedLen))
296
270
  printAiChunk(content.slice(committedLen));
@@ -398,6 +372,7 @@ export class InferenceEngine {
398
372
  if (!this.contextOverBudget(conversation))
399
373
  return [conversation, false];
400
374
  const started = Date.now();
375
+ dockTransientStart();
401
376
  const comp = new Spinner();
402
377
  comp.start(`Compacting conversation ${tickDuration(0)}`);
403
378
  const compTimer = setInterval(() => {
@@ -416,14 +391,11 @@ export class InferenceEngine {
416
391
  persistCompactionSummary(inputItems, compacted[0]);
417
392
  const text = `Compacted conversation in ${tickDuration((Date.now() - started) / 1000)}`;
418
393
  story.push({ type: "compacted", text });
419
- this.workActive = false;
420
- process.stdout.write(mutedMarkdown(text) + "\n\n");
394
+ dockReplaceTransient(mutedMarkdown(text) + "\n");
421
395
  return [compacted, true];
422
396
  }
423
397
  async executeQuery(userPrompt, inputItems, story, pasteSpans) {
424
398
  this.inQuery = true;
425
- this.workActive = false;
426
- this.workRows = 0;
427
399
  this.interrupted = false;
428
400
  this.queryHasOutput = false;
429
401
  this.queryCalledTool = false;
@@ -553,36 +525,24 @@ export class InferenceEngine {
553
525
  continue;
554
526
  }
555
527
  dropRetryPrompts(conversation, inputItems, retryPrompts);
556
- process.stdout.write(aiMarkdown(emptyMessage) + "\n");
557
- process.stdout.write("\n" + summary() + "\n");
558
- this.workActive = false;
559
- this.workRows = 0;
528
+ dockAppendContent(aiMarkdown(emptyMessage) + "\n");
529
+ dockAppendContent(summary() + "\n");
560
530
  return;
561
531
  }
562
532
  if (!calls.length) {
563
- // Exactly one blank row between the message and the footer summary.
564
- const trailing = (text.match(/\n+$/) ?? [""])[0].length;
565
- process.stdout.write("\n".repeat(Math.max(0, 2 - trailing)) + summary() + "\n");
566
533
  story.push({ type: "footer", text: footerText() });
567
534
  attachFooter(conversation);
568
535
  attachFooter(inputItems);
569
- this.workActive = false;
570
- this.workRows = 0;
536
+ dockAppendContent(summary() + "\n");
571
537
  return;
572
538
  }
573
- if (text.trim()) {
574
- // Ensure exactly one blank row between the assistant message and the
575
- // first tool call, no matter how many newlines the message ended with.
576
- const trailing = (text.match(/\n+$/) ?? [""])[0].length;
577
- process.stdout.write("\n".repeat(Math.max(0, 2 - trailing)));
578
- }
579
539
  for (const c of calls) {
580
- this.workActive = false;
581
540
  this.queryCalledTool = true;
582
541
  const started = toolCallLabel(c.name, c.arguments);
583
542
  // Print the tool label the instant the command starts, then show a
584
543
  // "╰─ Working..." dots line that swaps to the real result in place.
585
- process.stdout.write(`${started}\n`);
544
+ dockAppendContent(`${started}\n`);
545
+ dockTransientStart();
586
546
  const toolSpinner = new Spinner();
587
547
  toolSpinner.startDots("\x1b[90m╰─\x1b[0m Working");
588
548
  const rawResult = await this.runTool(c.name, c.arguments);
@@ -602,12 +562,13 @@ export class InferenceEngine {
602
562
  });
603
563
  // Swap the "╰─ Working..." line for the real result. If the tool
604
564
  // produced a diff it renders directly below the action line with no
605
- // gap; otherwise a blank line separates the action from what follows.
606
- toolSpinner.replaceWith(action);
607
- if (diff)
608
- process.stdout.write(diff + "\n\n");
609
- else
610
- process.stdout.write("\n");
565
+ // gap; otherwise the dock gap separates the action from what follows.
566
+ toolSpinner.stop();
567
+ dockReplaceTransient(action + "\n");
568
+ if (diff) {
569
+ dockTransientStart();
570
+ dockReplaceTransient(diff + "\n");
571
+ }
611
572
  const truncatedResult = c.name === "read_file"
612
573
  ? truncateToolOutput(rawResult, max_read_file_stored_chars)
613
574
  : truncateToolOutput(rawResult);
@@ -634,10 +595,6 @@ export class InferenceEngine {
634
595
  stripOrphanCalls(inputItems);
635
596
  }
636
597
  catch (err) {
637
- if (this.interrupted) {
638
- this.workActive = false;
639
- this.workRows = 0;
640
- }
641
598
  if (this.interrupted)
642
599
  throw new Error("interrupt");
643
600
  if (err?.message === "interrupt" || err?.message === "eof") {
package/dist/ui.js CHANGED
@@ -411,7 +411,7 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
411
411
  return out.join("\n");
412
412
  }
413
413
  export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
414
- process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
414
+ dockAppendContent(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
415
415
  }
416
416
  // ---------------------------------------------------------------------------
417
417
  // Table Panel Rendering
@@ -462,7 +462,7 @@ export function tickDuration(seconds) {
462
462
  export function printAiChunk(chunk) {
463
463
  if (!chunk)
464
464
  return;
465
- process.stdout.write(markdownToAnsi(chunk));
465
+ dockAppendContent(markdownToAnsi(chunk));
466
466
  }
467
467
  const FENCE_MARKER_RE = /`{3,}/g;
468
468
  export function safeCommitPoint(text) {
@@ -497,6 +497,134 @@ export function displayRows(text) {
497
497
  return rows;
498
498
  }
499
499
  // ---------------------------------------------------------------------------
500
+ // Persistent Prompt Dock
501
+ // ---------------------------------------------------------------------------
502
+ // The prompt box stays on screen at all times (docked). The cursor is parked
503
+ // at the box's TOP row; incoming content is written there and the box is
504
+ // re-rendered below it, so the box is pushed down by content while exactly
505
+ // one blank row is preserved between the latest content and the box.
506
+ //
507
+ // dockRel: how many rows the cursor sits above boxTop. 0 = parked at boxTop,
508
+ // 1 = one row up (the gap row), where transient spinners render.
509
+ // dockGap: true when a blank row already exists above the current row (after
510
+ // the box is torn down), so askBottomPrompt does not add a second one.
511
+ let docked = false;
512
+ let dockRel = 0;
513
+ let dockBoxRows = 0;
514
+ let dockGap = false;
515
+ let dockFrameLines = [];
516
+ export function dockIsDocked() {
517
+ return docked;
518
+ }
519
+ export function dockHasGap() {
520
+ return dockGap;
521
+ }
522
+ export function dockSetFrame(frame, totalRows) {
523
+ dockFrameLines = frame.split("\n");
524
+ dockBoxRows = totalRows;
525
+ }
526
+ export function dockSetInactive() {
527
+ docked = false;
528
+ dockRel = 0;
529
+ dockBoxRows = 0;
530
+ dockGap = false;
531
+ dockFrameLines = [];
532
+ }
533
+ export function dockEnterDocked() {
534
+ docked = true;
535
+ dockRel = 0;
536
+ dockGap = false;
537
+ }
538
+ /** Erase the docked box and leave the cursor at boxTop with the gap above it,
539
+ * so a pager/replay can write there and a later prompt re-docks cleanly. */
540
+ export function dockTearDown() {
541
+ const wasDocked = docked;
542
+ dockEraseBox();
543
+ if (wasDocked) {
544
+ dockGap = true;
545
+ dockBoxRows = 0;
546
+ dockFrameLines = [];
547
+ }
548
+ }
549
+ /** Park the cursor on the gap row (above the box) for a transient spinner. */
550
+ export function dockTransientStart() {
551
+ if (!docked || dockBoxRows < 1)
552
+ return;
553
+ if (dockRel === 0) {
554
+ process.stdout.write("\x1b[1A\r");
555
+ dockRel = 1;
556
+ }
557
+ }
558
+ function dockEraseBox() {
559
+ if (!docked || dockBoxRows < 1)
560
+ return;
561
+ process.stdout.write(`\x1b[${dockBoxRows - 1 + dockRel}B`);
562
+ for (let i = 0; i < dockBoxRows; i++) {
563
+ process.stdout.write("\r\x1b[K");
564
+ if (i < dockBoxRows - 1)
565
+ process.stdout.write("\x1b[1A");
566
+ }
567
+ docked = false;
568
+ dockRel = 0;
569
+ dockGap = true;
570
+ }
571
+ function dockEraseBoxKeep() {
572
+ if (!docked || dockBoxRows < 1)
573
+ return;
574
+ process.stdout.write(`\x1b[${dockBoxRows - 1 + dockRel}B`);
575
+ for (let i = 0; i < dockBoxRows; i++) {
576
+ process.stdout.write("\r\x1b[K");
577
+ if (i < dockBoxRows - 1)
578
+ process.stdout.write("\x1b[1A");
579
+ }
580
+ process.stdout.write(`\x1b[${dockRel}A`);
581
+ docked = false;
582
+ dockRel = 0;
583
+ dockGap = true;
584
+ }
585
+ /** Write exactly one blank row then the box frame below the current row, and
586
+ * park the cursor at the box top. */
587
+ function dockRenderBox() {
588
+ if (dockBoxRows < 1)
589
+ return;
590
+ process.stdout.write("\n\n" + dockFrameLines.join("\n"));
591
+ process.stdout.write(`\x1b[${dockBoxRows - 1}A\r`);
592
+ dockRel = 0;
593
+ docked = true;
594
+ dockGap = false;
595
+ }
596
+ /** Commit content at the box top, pushing the box down, preserving exactly one
597
+ * blank row between the content and the box. */
598
+ export function dockAppendContent(text) {
599
+ if (!text)
600
+ return;
601
+ if (!docked) {
602
+ process.stdout.write(text);
603
+ return;
604
+ }
605
+ dockEraseBox();
606
+ process.stdout.write(text);
607
+ const tb = displayRows(text) - displayRows(text.replace(/\n+$/, ""));
608
+ if (tb > 0)
609
+ process.stdout.write(`\x1b[${tb}A`);
610
+ dockRenderBox();
611
+ }
612
+ /** Replace a transient spinner row (the gap row) with content, then re-dock. */
613
+ export function dockReplaceTransient(text) {
614
+ if (!text)
615
+ return;
616
+ if (!docked) {
617
+ process.stdout.write(text);
618
+ return;
619
+ }
620
+ dockEraseBoxKeep();
621
+ process.stdout.write(text);
622
+ const tb = displayRows(text) - displayRows(text.replace(/\n+$/, ""));
623
+ if (tb > 0)
624
+ process.stdout.write(`\x1b[${tb}A`);
625
+ dockRenderBox();
626
+ }
627
+ // ---------------------------------------------------------------------------
500
628
  // Cursor & Spinner
501
629
  // ---------------------------------------------------------------------------
502
630
  let cursorHidden = false;
@@ -681,10 +809,13 @@ const TOOL_DISPLAY_NAMES = {
681
809
  grep: "Grep",
682
810
  load_skill: "Load",
683
811
  };
684
- const TOOL_RESULT_MAX_CHARS = 400;
685
- const TOOL_RESULT_MAX_LINES = 6;
686
- const TOOL_RESULT_MAX_LINE_CHARS = 160;
687
- /** First line of a tool entry: `Bash(rm -f "…")` — cyan tool name, primary arg in parens. */
812
+ export const TOOL_RESULT_MAX_LINES = 6;
813
+ /** Cap on the visible command/arg shown in a tool label, so the entry always
814
+ * fits on a single row regardless of terminal width. */
815
+ export const MAX_COMMAND_DISPLAY_CHARS = 100;
816
+ /** First line of a tool entry: `Bash(rm -f "…")` — cyan tool name, primary arg
817
+ * in parens. Newlines collapse to spaces and the arg is width-capped so the
818
+ * label never wraps past one row. */
688
819
  export function toolCallLabel(name, argumentsJson) {
689
820
  let args = {};
690
821
  try {
@@ -713,11 +844,12 @@ export function toolCallLabel(name, argumentsJson) {
713
844
  arg = String(args["skill_name"] ?? "");
714
845
  break;
715
846
  }
716
- arg = arg.trim();
847
+ arg = arg.trim().replace(/\s*\r?\n\s*/g, " ");
717
848
  if (!arg)
718
849
  return `\x1b[1;36m${display}\x1b[0m`;
719
- if (arg.length > 400)
720
- arg = arg.slice(0, 400) + "…";
850
+ const cap = Math.max(Math.min(terminalWidth() - plainLen(display) - 4, MAX_COMMAND_DISPLAY_CHARS), 20);
851
+ if (plainLen(arg) > cap)
852
+ arg = truncateStyled(arg, cap) + "…";
721
853
  return `\x1b[1;36m${display}\x1b[0m(${arg})`;
722
854
  }
723
855
  /**
@@ -737,17 +869,15 @@ export function formatToolResult(rawResult, failed) {
737
869
  if (failed && (!clean || clean === "(no output)") && code) {
738
870
  return `${corner}\x1b[31mexit code: ${code}\x1b[0m`;
739
871
  }
740
- let body = clean;
741
- const clipped = body.length > TOOL_RESULT_MAX_CHARS;
742
- if (clipped)
743
- body = body.slice(0, TOOL_RESULT_MAX_CHARS);
744
- const lines = body
745
- .split("\n")
872
+ // Truncate to the terminal width (rows OR per-line length, whichever trips
873
+ // first) so long tool output — e.g. Glob paths — never runs off the screen.
874
+ const termW = terminalWidth();
875
+ const lineMax = Math.max(termW - 4, 20);
876
+ const allLines = clean.split("\n");
877
+ const lines = allLines
746
878
  .slice(0, TOOL_RESULT_MAX_LINES)
747
- .map((ln) => ln.length > TOOL_RESULT_MAX_LINE_CHARS
748
- ? ln.slice(0, TOOL_RESULT_MAX_LINE_CHARS) + "…"
749
- : ln);
750
- if (clipped || body.split("\n").length > TOOL_RESULT_MAX_LINES)
879
+ .map((ln) => (ln.length > lineMax ? ln.slice(0, lineMax) + "…" : ln));
880
+ if (allLines.length > TOOL_RESULT_MAX_LINES)
751
881
  lines.push("…");
752
882
  const styleOpen = failed ? "\x1b[31m" : "\x1b[90m";
753
883
  const inner = lines
@@ -1058,7 +1188,15 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1058
1188
  process.stdin.setRawMode(false);
1059
1189
  if (pasteBurstTimer)
1060
1190
  clearTimeout(pasteBurstTimer);
1061
- clearBox();
1191
+ if (isErr) {
1192
+ clearBox();
1193
+ dockSetInactive();
1194
+ }
1195
+ else {
1196
+ process.stdout.write(`\x1b[${lastCursorRow}A\r`);
1197
+ dockSetFrame((prevFrameLines ?? []).join("\n"), lastTotalRows);
1198
+ dockEnterDocked();
1199
+ }
1062
1200
  process.stdin.removeListener("keypress", onKeypress);
1063
1201
  process.stdin.pause();
1064
1202
  showCursor();
@@ -1071,7 +1209,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1071
1209
  const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor, hints);
1072
1210
  const newLines = frame.split("\n");
1073
1211
  if (isFirst) {
1074
- process.stdout.write("\n");
1212
+ if (dockIsDocked()) {
1213
+ dockEraseBox();
1214
+ }
1215
+ else if (!dockHasGap()) {
1216
+ process.stdout.write("\n");
1217
+ }
1075
1218
  for (let i = 0; i < newLines.length; i++) {
1076
1219
  process.stdout.write(newLines[i]);
1077
1220
  if (i < newLines.length - 1)
@@ -1080,6 +1223,8 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1080
1223
  prevFrameLines = newLines;
1081
1224
  lastCursorRow = cursorRow;
1082
1225
  lastTotalRows = totalRows;
1226
+ dockSetFrame(frame, totalRows);
1227
+ dockEnterDocked();
1083
1228
  const up = totalRows - 1 - cursorRow;
1084
1229
  process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
1085
1230
  }
@@ -1103,6 +1248,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1103
1248
  prevFrameLines = newLines;
1104
1249
  lastCursorRow = cursorRow;
1105
1250
  lastTotalRows = totalRows;
1251
+ dockSetFrame(frame, totalRows);
1106
1252
  }
1107
1253
  };
1108
1254
  const shiftSpansAfterInsert = (pos, delta) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.85",
3
+ "version": "1.0.87",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },