@quatcode/agent 0.2.5 → 0.2.7

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/tui.js CHANGED
@@ -10,7 +10,7 @@ import { Picker } from "./picker.js";
10
10
  import { Screen } from "./screen.js";
11
11
  import { TurnController, fmtCost, fmtDuration, fmtTokens, toolArg, toolResultBlock } from "./turn.js";
12
12
  import { renderBanner } from "./logo.js";
13
- import { renderMarkdown } from "./markdown.js";
13
+ import { renderMarkdown, wrapSegments } from "./markdown.js";
14
14
  import { ADD_PROVIDER, allProviders, buildProvider, defaultModelFor, effortsForModel, hasKey, keyEnvFor, probeProviderModels, providerEnvKey, providerHasModel, providerMeta, providerSlug, registerCustomProvider, removeCustomProvider, resolveProviderForModel, } from "./models.js";
15
15
  import { activeThemeId, bg, bold, buildColorRemap, clipAnsi, fg, getThemes, glyphs, padEndTo, palette, remapColors, setTheme, stringWidth, stripAnsi, tone, toolGlyph, } from "./theme.js";
16
16
  import { listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
@@ -24,6 +24,7 @@ const COMMAND_INFO = [
24
24
  { name: "/new", desc: "new conversation (clears the screen)" },
25
25
  { name: "/model", args: "[id]", desc: "pick or set the model" },
26
26
  { name: "/provider", args: "[add|list|remove]", desc: "connect a provider by API key" },
27
+ { name: "/btw", args: "<question>", desc: "ask a side question without interrupting the task" },
27
28
  { name: "/effort", args: "[level]", desc: "reasoning effort (minimal…high)" },
28
29
  { name: "/key", desc: "enter the API key for the current provider" },
29
30
  { name: "/dir", args: "[path]", desc: "change the project directory" },
@@ -136,6 +137,14 @@ class TuiApp {
136
137
  modeBadgeWidth = 0;
137
138
  modeBadgeHover = false;
138
139
  versionNotice = null;
140
+ bottomHintStart = -1;
141
+ bottomHintWidth = 0;
142
+ bottomHintRow = -1;
143
+ queue = [];
144
+ inputScroll = 0;
145
+ lastInputTotalRows = 0;
146
+ hoverRow = -1;
147
+ hoverCol = -1;
139
148
  constructor(options, input, output) {
140
149
  this.opts = options;
141
150
  this.dir = path.resolve(options.dir);
@@ -237,8 +246,10 @@ class TuiApp {
237
246
  const prompt = this.opts.initialPrompt;
238
247
  this.buffer.addHistory(prompt);
239
248
  this.clearSplash();
240
- this.append(this.userBlockLines(prompt));
249
+ this.appendUserBlock(prompt, 0);
241
250
  await this.turn(prompt);
251
+ if (await this.drainQueue())
252
+ return this.finish();
242
253
  }
243
254
  for (;;) {
244
255
  const result = await this.readLine();
@@ -264,7 +275,7 @@ class TuiApp {
264
275
  transcriptLength: transcriptAt,
265
276
  };
266
277
  this.messageCursor = -1;
267
- this.append(this.userBlockLines(input, images.length));
278
+ const blockStart = this.appendUserBlock(input, images.length);
268
279
  if (isCommand) {
269
280
  this.turnUndo = null;
270
281
  const shouldExit = await this.command(input);
@@ -272,9 +283,14 @@ class TuiApp {
272
283
  break;
273
284
  continue;
274
285
  }
275
- this.userMarkers.push({ line: transcriptAt, text: input });
286
+ this.userMarkers.push({ line: blockStart, text: input });
276
287
  await this.turn(input, images);
288
+ if (await this.drainQueue())
289
+ break;
277
290
  }
291
+ return this.finish();
292
+ }
293
+ async finish() {
278
294
  this.printGoodbye();
279
295
  this.renderNow();
280
296
  await this.pause(120);
@@ -374,6 +390,12 @@ class TuiApp {
374
390
  this.moveSuggestion(key.kind === "wheelup" ? -1 : 1);
375
391
  continue;
376
392
  }
393
+ if ((this.mode === "editing" || this.mode === "running") &&
394
+ this.inputOverflow() &&
395
+ this.pointerOverInput()) {
396
+ this.scrollInput(key.kind === "wheelup" ? 3 : -3);
397
+ continue;
398
+ }
377
399
  const delta = key.kind === "wheelup" ? 3 : -3;
378
400
  if (this.mode === "picker" && this.picker) {
379
401
  const dir = key.kind === "wheelup" ? "up" : "down";
@@ -433,10 +455,21 @@ class TuiApp {
433
455
  this.selection = null;
434
456
  this.scheduleRender();
435
457
  }
458
+ if (key.type === "nav" && key.dir === "down" && key.ctrl) {
459
+ this.scrollOffset = 0;
460
+ this.scheduleRender();
461
+ continue;
462
+ }
436
463
  if (this.mode === "editing")
437
464
  this.handleEditKey(key);
438
- else if (this.mode === "running")
439
- this.handleRunKey(key);
465
+ else if (this.mode === "running") {
466
+ const abortKey = key.type === "escape" || (key.type === "control" && key.ch === "c");
467
+ const undoKey = key.type === "control" && key.ch === "z";
468
+ if (abortKey || undoKey)
469
+ this.handleRunKey(key);
470
+ else
471
+ this.handleEditKey(key);
472
+ }
440
473
  else if (this.mode === "permission")
441
474
  this.handleConfirmKey(key);
442
475
  else if (this.mode === "picker")
@@ -466,6 +499,10 @@ class TuiApp {
466
499
  if (key.type === "text" || key.type === "backspace" || key.type === "delete") {
467
500
  this.suggestionIndex = 0;
468
501
  }
502
+ if (key.type === "text" || key.type === "backspace" || key.type === "delete") {
503
+ this.suggestionIndex = 0;
504
+ this.inputScroll = 0;
505
+ }
469
506
  switch (key.type) {
470
507
  case "text":
471
508
  this.buffer.insert(key.text);
@@ -473,14 +510,16 @@ class TuiApp {
473
510
  case "control":
474
511
  switch (key.ch) {
475
512
  case "c":
476
- if (this.buffer.isEmpty())
513
+ if (this.buffer.isEmpty() && this.mode !== "running")
477
514
  this.finishLine({ kind: "interrupt" });
478
515
  else
479
516
  this.buffer.clearLine();
480
517
  break;
481
518
  case "d":
482
- if (this.buffer.isEmpty())
483
- this.finishLine({ kind: "eof" });
519
+ if (this.buffer.isEmpty()) {
520
+ if (this.mode !== "running")
521
+ this.finishLine({ kind: "eof" });
522
+ }
484
523
  else
485
524
  this.buffer.deleteForward();
486
525
  break;
@@ -543,7 +582,7 @@ class TuiApp {
543
582
  if (!this.deleteImageTokenAfter())
544
583
  this.buffer.deleteWordAfter();
545
584
  }
546
- else if (key.ch === "v" || key.ch === "V") {
585
+ else if (key.ch === "v" || key.ch === "V" || key.ch === "м" || key.ch === "М") {
547
586
  void this.pasteFromClipboard();
548
587
  return;
549
588
  }
@@ -554,6 +593,7 @@ class TuiApp {
554
593
  this.moveSuggestion(key.dir === "up" ? -1 : 1);
555
594
  return;
556
595
  }
596
+ this.inputScroll = 0;
557
597
  if (key.dir === "up") {
558
598
  if (!this.buffer.moveVertical(-1))
559
599
  this.buffer.historyPrev();
@@ -588,6 +628,7 @@ class TuiApp {
588
628
  }
589
629
  break;
590
630
  case "newline":
631
+ this.inputScroll = 0;
591
632
  this.buffer.insert("\n");
592
633
  break;
593
634
  case "enter": {
@@ -599,7 +640,7 @@ class TuiApp {
599
640
  const index = Math.max(0, Math.min(this.suggestionIndex, matches.length - 1));
600
641
  const selected = matches[index];
601
642
  if (selected) {
602
- this.finishLine({ kind: "submit", text: selected.name });
643
+ this.submitText(selected.name);
603
644
  return;
604
645
  }
605
646
  }
@@ -612,13 +653,13 @@ class TuiApp {
612
653
  if (unique.length === 1)
613
654
  text = unique[0].name;
614
655
  }
615
- this.finishLine({ kind: "submit", text });
656
+ this.submitText(text);
616
657
  return;
617
658
  }
618
659
  case "tab":
619
660
  if (this.suggestionMatches().length > 0)
620
661
  this.completeCommand();
621
- else
662
+ else if (this.mode !== "running")
622
663
  this.toggleMode();
623
664
  break;
624
665
  case "backspace":
@@ -644,13 +685,104 @@ class TuiApp {
644
685
  this.scheduleRender();
645
686
  resolve?.(result);
646
687
  }
688
+ submitText(raw) {
689
+ if (this.mode !== "running") {
690
+ this.buffer.reset();
691
+ this.inputScroll = 0;
692
+ this.finishLine({ kind: "submit", text: raw });
693
+ return;
694
+ }
695
+ const extracted = extractInputImages(raw, this.pendingImages);
696
+ this.pendingImages = new Map();
697
+ const text = extracted.text;
698
+ const images = extracted.images;
699
+ if (text)
700
+ this.buffer.addHistory(text);
701
+ this.buffer.reset();
702
+ this.inputScroll = 0;
703
+ if (/^\/btw\b/.test(text)) {
704
+ void this.runBtw(text.replace(/^\/btw\s*/, ""));
705
+ return;
706
+ }
707
+ this.queue.push({ text, images });
708
+ this.appendUserBlock(text, images.length, [
709
+ ` ${tone.mist("// QUEUED — will be sent when the current task finishes")}`,
710
+ ]);
711
+ this.scheduleRender();
712
+ }
713
+ async drainQueue() {
714
+ while (this.queue.length > 0) {
715
+ const next = this.queue.shift();
716
+ if (!next.text.trim())
717
+ continue;
718
+ if (next.text.startsWith("/")) {
719
+ const shouldExit = await this.command(next.text);
720
+ if (shouldExit)
721
+ return true;
722
+ continue;
723
+ }
724
+ this.append([` ${tone.mist("// SENDING QUEUED MESSAGE")}`]);
725
+ this.clearSplash();
726
+ this.userMarkers.push({ line: this.transcript.length, text: next.text });
727
+ await this.turn(next.text, next.images);
728
+ }
729
+ return false;
730
+ }
731
+ async runBtw(question) {
732
+ const text = question.trim();
733
+ if (!text) {
734
+ this.append([` ${tone.mist("// usage: /btw <question>")}`]);
735
+ return;
736
+ }
737
+ const provider = this.ensureProvider();
738
+ const inner = Math.min(Math.max(44, this.contentWidth() - 8), 66);
739
+ if (!provider) {
740
+ this.append([
741
+ ` ${tone.red(glyphs.cross)} ${tone.mist("// BTW needs a configured provider")}`,
742
+ ]);
743
+ return;
744
+ }
745
+ const pending = box("BTW", [tone.silver(clipAnsi(text, inner - 2)), "", tone.mist("// thinking…")], inner, palette.steel);
746
+ this.append([...pending, ""]);
747
+ const start = Math.max(0, this.transcript.length - pending.length - 1);
748
+ try {
749
+ const response = await provider.complete({
750
+ model: this.model,
751
+ system: `${this.system ? `${this.system}\n\n` : ""}# side channel\nThe user is asking a quick side question while the main task runs. Answer briefly and directly. Do not use tools, do not change the main task, and do not ask to run anything.`,
752
+ messages: [
753
+ ...this.session.getMessages(),
754
+ { role: "user", content: [{ type: "text", text }] },
755
+ ],
756
+ maxTokens: 2048,
757
+ });
758
+ const answer = response.content
759
+ .map((part) => (part.type === "text" ? part.text : ""))
760
+ .join("")
761
+ .trim();
762
+ const answerLines = answer
763
+ ? renderMarkdown(answer, { width: inner - 2, indent: 2 })
764
+ : [tone.mist("// the model returned no answer")];
765
+ const card = box("BTW", [tone.silver(clipAnsi(text, inner - 2)), "", ...answerLines], inner, palette.steel);
766
+ this.transcript.splice(start, pending.length + 1, ...card, "");
767
+ }
768
+ catch (err) {
769
+ const card = box("BTW", [
770
+ tone.silver(clipAnsi(text, inner - 2)),
771
+ "",
772
+ tone.red(`// ${errorMessage(err)}`),
773
+ ], inner, palette.red);
774
+ this.transcript.splice(start, pending.length + 1, ...card, "");
775
+ }
776
+ this.scheduleRender();
777
+ }
647
778
  readLine(overrides = {}) {
648
779
  const initial = overrides.initial ?? this.pendingInitial ?? "";
649
780
  this.pendingInitial = null;
650
781
  if (initial)
651
782
  this.buffer.setText(initial);
652
- else
783
+ else if (this.buffer.text().trim() === "")
653
784
  this.buffer.reset();
785
+ this.inputScroll = 0;
654
786
  this.inputPrompt = overrides.prompt ?? defaultPrompt();
655
787
  this.inputMask = overrides.mask ?? false;
656
788
  this.inputPlaceholder = overrides.placeholder ?? "ask anything, or /help";
@@ -735,17 +867,58 @@ class TuiApp {
735
867
  if (!rendered)
736
868
  return false;
737
869
  const top = this.lastHeaderRows + this.lastViewport + this.lastInputOffset;
738
- const lineIndex = row - top;
739
- if (lineIndex < 0 || lineIndex >= rendered.lines.length)
870
+ const rowIndex = row - top;
871
+ if (rowIndex < 0 || rowIndex >= rendered.lines.length)
872
+ return false;
873
+ const visual = rendered.rows[rowIndex];
874
+ if (!visual)
740
875
  return false;
741
- const prefixWidth = rendered.prefixWidths[lineIndex] ?? 0;
742
- const textCol = col - this.padX - prefixWidth;
743
- const start = rendered.lineStarts[lineIndex] ?? 0;
744
- this.buffer.setCursorFromDisplay(lineIndex, Math.max(0, textCol), start);
876
+ const textCol = col - this.padX - visual.prefixWidth;
877
+ this.buffer.setCursorFromDisplay(visual.line, Math.max(0, textCol), visual.start);
745
878
  this.scheduleRender();
746
879
  return true;
747
880
  }
881
+ inputWindowSize() {
882
+ return Math.max(3, Math.floor(this.screen.rows / 2));
883
+ }
884
+ inputOverflow() {
885
+ return this.lastInputTotalRows > this.inputWindowSize();
886
+ }
887
+ pointerOverInput() {
888
+ if (!this.lastInputRender || this.hoverRow < 0)
889
+ return false;
890
+ const top = this.lastHeaderRows + this.lastViewport + this.lastInputOffset;
891
+ return (this.hoverRow >= top && this.hoverRow < top + this.lastInputRender.lines.length);
892
+ }
893
+ scrollInput(delta) {
894
+ const maxOffset = Math.max(0, this.lastInputTotalRows - this.inputWindowSize());
895
+ this.inputScroll = Math.max(0, Math.min(this.inputScroll + delta, maxOffset));
896
+ this.scheduleRender();
897
+ }
898
+ bottomHintHit(row, col) {
899
+ if (this.bottomHintRow < 0 || this.bottomHintStart < 0)
900
+ return false;
901
+ if (row !== this.bottomHintRow)
902
+ return false;
903
+ const start = this.padX + this.bottomHintStart;
904
+ return col >= start && col < start + this.bottomHintWidth;
905
+ }
906
+ bottomRuleLine() {
907
+ const width = this.contentWidth();
908
+ const label = `${glyphs.arrowDown} bottom · ctrl+${glyphs.arrowDown}`;
909
+ this.bottomHintStart = -1;
910
+ this.bottomHintWidth = 0;
911
+ if (this.scrollOffset > 0 && this.mode !== "picker" && width > 36) {
912
+ const dashes = Math.max(8, width - 2 - stringWidth(label) - 2);
913
+ this.bottomHintStart = 2 + dashes + 2;
914
+ this.bottomHintWidth = stringWidth(label);
915
+ return ` ${tone.mist("─".repeat(dashes))} ${tone.silver(label)}`;
916
+ }
917
+ return ` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`;
918
+ }
748
919
  handleHover(row, col) {
920
+ this.hoverRow = row;
921
+ this.hoverCol = col;
749
922
  if (row === 1) {
750
923
  const hover = this.badgeHit(col);
751
924
  if (hover !== this.modeBadgeHover) {
@@ -767,11 +940,11 @@ class TuiApp {
767
940
  }
768
941
  return;
769
942
  }
770
- if (this.mode === "editing") {
943
+ if (this.mode === "editing" || this.mode === "running") {
771
944
  const matches = this.suggestionMatches();
772
945
  if (matches.length === 0)
773
946
  return;
774
- const top = this.lastHeaderRows + this.lastViewport + 1;
947
+ const top = this.lastHeaderRows + this.lastViewport + 1 + (this.status ? 1 : 0);
775
948
  const index = row - top;
776
949
  if (index >= 0 && index < matches.length && index !== this.suggestionIndex) {
777
950
  this.suggestionIndex = index;
@@ -791,6 +964,11 @@ class TuiApp {
791
964
  this.toggleMode();
792
965
  return;
793
966
  }
967
+ if (this.bottomHintHit(row, col)) {
968
+ this.scrollOffset = 0;
969
+ this.scheduleRender();
970
+ return;
971
+ }
794
972
  if (this.mode === "permission") {
795
973
  const index = this.permissionOptionAt(row, col);
796
974
  if (index !== null) {
@@ -802,16 +980,16 @@ class TuiApp {
802
980
  }
803
981
  return;
804
982
  }
805
- if (this.mode === "editing") {
983
+ if (this.mode === "editing" || this.mode === "running") {
806
984
  if (this.placeInputCursor(row, col))
807
985
  return;
808
986
  const matches = this.suggestionMatches();
809
987
  if (matches.length > 0) {
810
- const top = this.lastHeaderRows + this.lastViewport + 1;
988
+ const top = this.lastHeaderRows + this.lastViewport + 1 + (this.status ? 1 : 0);
811
989
  const index = row - top;
812
990
  if (index >= 0 && index < matches.length) {
813
991
  this.suggestionIndex = index;
814
- this.finishLine({ kind: "submit", text: matches[index].name });
992
+ this.submitText(matches[index].name);
815
993
  }
816
994
  }
817
995
  return;
@@ -1036,6 +1214,8 @@ class TuiApp {
1036
1214
  const viewport = Math.max(3, rows - header.length - footer.length - this.padTop - this.padBottom);
1037
1215
  this.lastHeaderRows = header.length + this.padTop;
1038
1216
  this.lastViewport = viewport;
1217
+ this.bottomHintRow =
1218
+ this.bottomHintStart >= 0 ? this.lastHeaderRows + viewport + footer.length - 1 : -1;
1039
1219
  let content;
1040
1220
  if (this.mode === "picker" && this.picker) {
1041
1221
  content = this.picker.render(this.contentWidth() - 2, viewport);
@@ -1051,7 +1231,10 @@ class TuiApp {
1051
1231
  while (lines.length < rows)
1052
1232
  lines.push("");
1053
1233
  let cursor = null;
1054
- if (this.mode === "editing" && this.lastInputRender) {
1234
+ if ((this.mode === "editing" || this.mode === "running") &&
1235
+ this.lastInputRender &&
1236
+ this.lastInputRender.cursorRow >= 0 &&
1237
+ this.lastInputRender.cursorRow < this.lastInputRender.lines.length) {
1055
1238
  const inputTop = this.padTop + header.length + viewport + this.lastInputOffset;
1056
1239
  cursor = {
1057
1240
  row: inputTop + this.lastInputRender.cursorRow,
@@ -1116,7 +1299,8 @@ class TuiApp {
1116
1299
  footerLines() {
1117
1300
  const width = this.contentWidth();
1118
1301
  const rule = ` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`;
1119
- if (this.mode === "editing") {
1302
+ const bottom = this.bottomRuleLine();
1303
+ if (this.mode === "editing" || this.mode === "running") {
1120
1304
  const rendered = this.buffer.render({
1121
1305
  width,
1122
1306
  prompt: this.inputPrompt,
@@ -1124,28 +1308,55 @@ class TuiApp {
1124
1308
  mask: this.inputMask,
1125
1309
  style: styleInputLine,
1126
1310
  });
1127
- this.lastInputRender = rendered;
1311
+ const maxRows = this.inputWindowSize();
1312
+ const total = rendered.lines.length;
1313
+ this.lastInputTotalRows = total;
1314
+ if (total > maxRows) {
1315
+ const maxOffset = total - maxRows;
1316
+ const fromBottom = Math.max(0, Math.min(this.inputScroll, maxOffset));
1317
+ this.inputScroll = fromBottom;
1318
+ const start = maxOffset - fromBottom;
1319
+ this.lastInputRender = {
1320
+ lines: rendered.lines.slice(start, start + maxRows),
1321
+ rows: rendered.rows.slice(start, start + maxRows),
1322
+ cursorRow: rendered.cursorRow - start,
1323
+ cursorCol: rendered.cursorCol,
1324
+ };
1325
+ }
1326
+ else {
1327
+ this.inputScroll = 0;
1328
+ this.lastInputRender = rendered;
1329
+ }
1128
1330
  const suggestions = this.suggestions();
1129
- this.lastInputOffset = 1 + suggestions.length;
1130
- return [rule, ...suggestions, ...rendered.lines, rule];
1331
+ const statusLines = this.status
1332
+ ? [this.statusLine()]
1333
+ : this.mode === "running"
1334
+ ? [` ${tone.mist("// esc interrupts · enter queues a message")}`]
1335
+ : [];
1336
+ this.lastInputOffset = 1 + statusLines.length + suggestions.length;
1337
+ return [rule, ...statusLines, ...suggestions, ...this.lastInputRender.lines, bottom];
1131
1338
  }
1132
1339
  this.lastInputRender = null;
1340
+ this.lastInputTotalRows = 0;
1133
1341
  if (this.status)
1134
- return [rule, this.statusLine(), rule];
1135
- if (this.mode === "running") {
1136
- return [rule, ` ${tone.mist("// esc interrupts")}`, rule];
1137
- }
1342
+ return [rule, this.statusLine(), bottom];
1138
1343
  if (this.mode === "permission")
1139
- return [rule, ` ${this.permissionHint}`, rule];
1344
+ return [rule, ` ${this.permissionHint}`, bottom];
1140
1345
  if (this.mode === "picker" && this.picker) {
1141
- return [rule, this.picker.footer(this.picker.itemCount()), rule];
1346
+ return [rule, this.picker.footer(this.picker.itemCount()), bottom];
1142
1347
  }
1143
- return [rule, "", rule];
1348
+ return [rule, "", bottom];
1144
1349
  }
1145
1350
  statusLine() {
1146
1351
  const spin = tone.accent(glyphs.spinner[this.spinnerFrame % glyphs.spinner.length] ?? "-");
1147
1352
  const elapsed = ((performance.now() - this.statusStartedAt) / 1000).toFixed(1);
1148
- const meta = [this.status?.meta, `${elapsed}s`].filter(Boolean).join(" · ");
1353
+ const meta = [
1354
+ this.status?.meta,
1355
+ `${elapsed}s`,
1356
+ this.queue.length > 0 ? `${this.queue.length} queued` : "",
1357
+ ]
1358
+ .filter(Boolean)
1359
+ .join(" · ");
1149
1360
  return ` ${spin} ${this.status?.label ?? ""} ${tone.mist(meta)}`;
1150
1361
  }
1151
1362
  visibleTranscript(viewport) {
@@ -1900,6 +2111,9 @@ class TuiApp {
1900
2111
  case "help":
1901
2112
  this.printHelp();
1902
2113
  return false;
2114
+ case "btw":
2115
+ await this.runBtw(arg);
2116
+ return false;
1903
2117
  case "new":
1904
2118
  this.session = new Session(this.system);
1905
2119
  this.conversationId = makeSessionId();
@@ -2144,8 +2358,12 @@ class TuiApp {
2144
2358
  .flatMap((part) => (part.type === "text" ? [part.text] : []))
2145
2359
  .join("\n");
2146
2360
  const images = message.content.filter((part) => part.type === "image").length;
2147
- if (text.trim() || images > 0)
2361
+ if (text.trim() || images > 0) {
2362
+ if (lines.length > 0 && lines[lines.length - 1] !== "")
2363
+ lines.push("");
2148
2364
  lines.push(...this.userBlockLines(text, images));
2365
+ lines.push("");
2366
+ }
2149
2367
  continue;
2150
2368
  }
2151
2369
  if (message.role === "assistant") {
@@ -2687,6 +2905,8 @@ class TuiApp {
2687
2905
  ["sessions picker", "ctrl+r rename · ctrl+p pin · ctrl+d delete"],
2688
2906
  ["↑ ↓", "input history — previous / next message"],
2689
2907
  ["wheel", "scroll the conversation"],
2908
+ ["ctrl+↓", "jump to the bottom of the chat (or click the ↓ button)"],
2909
+ ["enter while running", "queue the message · /btw asks without interrupting"],
2690
2910
  ["ctrl+p/ctrl+n", "input history (same as ↑ ↓)"],
2691
2911
  ["tab / badge", "switch plan/build · click the header badge · complete commands"],
2692
2912
  ["ctrl+z", "undo the last message (works while thinking)"],
@@ -2704,14 +2924,31 @@ class TuiApp {
2704
2924
  }
2705
2925
  this.append(lines);
2706
2926
  }
2927
+ appendUserBlock(input, imageCount, extra = []) {
2928
+ const lines = this.userBlockLines(input, imageCount);
2929
+ const last = this.transcript[this.transcript.length - 1];
2930
+ const leading = this.transcript.length > 0 && last !== "" ? [""] : [];
2931
+ this.append([...leading, ...lines, ...extra, ""]);
2932
+ return Math.max(0, this.transcript.length - (extra.length + 1) - lines.length);
2933
+ }
2707
2934
  userBlockLines(input, imageCount = 0) {
2935
+ const width = Math.max(20, this.contentWidth() - 2);
2708
2936
  const lines = input.split("\n");
2709
- const head = ` ${tone.accent(glyphs.prompt)} ${bold(tone.bone(lines[0] ?? ""))}`;
2710
- const rest = lines.slice(1).map((line) => ` ${tone.bone(line)}`);
2711
- const images = imageCount > 0
2712
- ? [` ${tone.mist(`[${imageCount} image${imageCount === 1 ? "" : "s"} attached]`)}`]
2713
- : [];
2714
- return [head, ...rest, ...images];
2937
+ const out = [];
2938
+ for (let index = 0; index < lines.length; index++) {
2939
+ const line = lines[index];
2940
+ if (line === "") {
2941
+ out.push("");
2942
+ continue;
2943
+ }
2944
+ const prefix = index === 0 ? `${tone.accent(glyphs.prompt)} ` : " ";
2945
+ const kind = index === 0 ? "bold" : "plain";
2946
+ out.push(...wrapSegments([{ text: line, kind }], prefix, " ", width));
2947
+ }
2948
+ if (imageCount > 0) {
2949
+ out.push(` ${tone.mist(`[${imageCount} image${imageCount === 1 ? "" : "s"} attached]`)}`);
2950
+ }
2951
+ return out;
2715
2952
  }
2716
2953
  async saveTurn(input, result, durationMs) {
2717
2954
  const cost = estimateCost(result.usage, this.model, this.providerId);