@quatcode/agent 0.2.6 → 0.2.8

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
@@ -13,8 +13,8 @@ import { renderBanner } from "./logo.js";
13
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
- import { listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
17
- import { configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sortConversations, updateConversation, } from "./store.js";
16
+ import { listSessions, listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
17
+ import { configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sameDir, sortConversations, updateConversation } from "./store.js";
18
18
  import { MCP_USAGE, isMcpUrl, mcpInstructionsSection, parseMcpAdd } from "./mcp.js";
19
19
  import { copyToClipboard, readClipboardImage, readClipboardText, } from "./clipboard.js";
20
20
  import { createLoadSkillTool, fetchSkillFromUrl, importSkillFile, listSkills, parseSkill, removeSkill, saveSkill, skillsRoot, skillsSystemSection, } from "./skills.js";
@@ -24,9 +24,10 @@ 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
- { name: "/dir", args: "[path]", desc: "change the project directory" },
30
+ { name: "/dir", args: "[path]", desc: "move this session to another project directory" },
30
31
  { name: "/context", desc: "project summary" },
31
32
  { name: "/usage", desc: "tokens, cost and time" },
32
33
  { name: "/sessions", desc: "list and resume recent sessions" },
@@ -136,12 +137,20 @@ class TuiApp {
136
137
  modeBadgeWidth = 0;
137
138
  modeBadgeHover = false;
138
139
  versionNotice = null;
140
+ sessionDir;
141
+ conversationTitle = null;
139
142
  bottomHintStart = -1;
140
143
  bottomHintWidth = 0;
141
144
  bottomHintRow = -1;
145
+ queue = [];
146
+ inputScroll = 0;
147
+ lastInputTotalRows = 0;
148
+ hoverRow = -1;
149
+ hoverCol = -1;
142
150
  constructor(options, input, output) {
143
151
  this.opts = options;
144
152
  this.dir = path.resolve(options.dir);
153
+ this.sessionDir = this.dir;
145
154
  this.gateMode = options.allowAll ? "always" : "ask";
146
155
  this.out = output;
147
156
  this.kb = new Keyboard(input, output);
@@ -240,8 +249,10 @@ class TuiApp {
240
249
  const prompt = this.opts.initialPrompt;
241
250
  this.buffer.addHistory(prompt);
242
251
  this.clearSplash();
243
- this.append(this.userBlockLines(prompt));
252
+ this.appendUserBlock(prompt, 0);
244
253
  await this.turn(prompt);
254
+ if (await this.drainQueue())
255
+ return this.finish();
245
256
  }
246
257
  for (;;) {
247
258
  const result = await this.readLine();
@@ -267,7 +278,7 @@ class TuiApp {
267
278
  transcriptLength: transcriptAt,
268
279
  };
269
280
  this.messageCursor = -1;
270
- this.append(this.userBlockLines(input, images.length));
281
+ const blockStart = this.appendUserBlock(input, images.length);
271
282
  if (isCommand) {
272
283
  this.turnUndo = null;
273
284
  const shouldExit = await this.command(input);
@@ -275,9 +286,14 @@ class TuiApp {
275
286
  break;
276
287
  continue;
277
288
  }
278
- this.userMarkers.push({ line: transcriptAt, text: input });
289
+ this.userMarkers.push({ line: blockStart, text: input });
279
290
  await this.turn(input, images);
291
+ if (await this.drainQueue())
292
+ break;
280
293
  }
294
+ return this.finish();
295
+ }
296
+ async finish() {
281
297
  this.printGoodbye();
282
298
  this.renderNow();
283
299
  await this.pause(120);
@@ -377,6 +393,12 @@ class TuiApp {
377
393
  this.moveSuggestion(key.kind === "wheelup" ? -1 : 1);
378
394
  continue;
379
395
  }
396
+ if ((this.mode === "editing" || this.mode === "running") &&
397
+ this.inputOverflow() &&
398
+ this.pointerOverInput()) {
399
+ this.scrollInput(key.kind === "wheelup" ? 3 : -3);
400
+ continue;
401
+ }
380
402
  const delta = key.kind === "wheelup" ? 3 : -3;
381
403
  if (this.mode === "picker" && this.picker) {
382
404
  const dir = key.kind === "wheelup" ? "up" : "down";
@@ -443,8 +465,14 @@ class TuiApp {
443
465
  }
444
466
  if (this.mode === "editing")
445
467
  this.handleEditKey(key);
446
- else if (this.mode === "running")
447
- this.handleRunKey(key);
468
+ else if (this.mode === "running") {
469
+ const abortKey = key.type === "escape" || (key.type === "control" && key.ch === "c");
470
+ const undoKey = key.type === "control" && key.ch === "z";
471
+ if (abortKey || undoKey)
472
+ this.handleRunKey(key);
473
+ else
474
+ this.handleEditKey(key);
475
+ }
448
476
  else if (this.mode === "permission")
449
477
  this.handleConfirmKey(key);
450
478
  else if (this.mode === "picker")
@@ -474,6 +502,10 @@ class TuiApp {
474
502
  if (key.type === "text" || key.type === "backspace" || key.type === "delete") {
475
503
  this.suggestionIndex = 0;
476
504
  }
505
+ if (key.type === "text" || key.type === "backspace" || key.type === "delete") {
506
+ this.suggestionIndex = 0;
507
+ this.inputScroll = 0;
508
+ }
477
509
  switch (key.type) {
478
510
  case "text":
479
511
  this.buffer.insert(key.text);
@@ -481,14 +513,16 @@ class TuiApp {
481
513
  case "control":
482
514
  switch (key.ch) {
483
515
  case "c":
484
- if (this.buffer.isEmpty())
516
+ if (this.buffer.isEmpty() && this.mode !== "running")
485
517
  this.finishLine({ kind: "interrupt" });
486
518
  else
487
519
  this.buffer.clearLine();
488
520
  break;
489
521
  case "d":
490
- if (this.buffer.isEmpty())
491
- this.finishLine({ kind: "eof" });
522
+ if (this.buffer.isEmpty()) {
523
+ if (this.mode !== "running")
524
+ this.finishLine({ kind: "eof" });
525
+ }
492
526
  else
493
527
  this.buffer.deleteForward();
494
528
  break;
@@ -551,7 +585,7 @@ class TuiApp {
551
585
  if (!this.deleteImageTokenAfter())
552
586
  this.buffer.deleteWordAfter();
553
587
  }
554
- else if (key.ch === "v" || key.ch === "V") {
588
+ else if (key.ch === "v" || key.ch === "V" || key.ch === "м" || key.ch === "М") {
555
589
  void this.pasteFromClipboard();
556
590
  return;
557
591
  }
@@ -562,6 +596,7 @@ class TuiApp {
562
596
  this.moveSuggestion(key.dir === "up" ? -1 : 1);
563
597
  return;
564
598
  }
599
+ this.inputScroll = 0;
565
600
  if (key.dir === "up") {
566
601
  if (!this.buffer.moveVertical(-1))
567
602
  this.buffer.historyPrev();
@@ -596,6 +631,7 @@ class TuiApp {
596
631
  }
597
632
  break;
598
633
  case "newline":
634
+ this.inputScroll = 0;
599
635
  this.buffer.insert("\n");
600
636
  break;
601
637
  case "enter": {
@@ -607,7 +643,7 @@ class TuiApp {
607
643
  const index = Math.max(0, Math.min(this.suggestionIndex, matches.length - 1));
608
644
  const selected = matches[index];
609
645
  if (selected) {
610
- this.finishLine({ kind: "submit", text: selected.name });
646
+ this.submitText(selected.name);
611
647
  return;
612
648
  }
613
649
  }
@@ -620,13 +656,13 @@ class TuiApp {
620
656
  if (unique.length === 1)
621
657
  text = unique[0].name;
622
658
  }
623
- this.finishLine({ kind: "submit", text });
659
+ this.submitText(text);
624
660
  return;
625
661
  }
626
662
  case "tab":
627
663
  if (this.suggestionMatches().length > 0)
628
664
  this.completeCommand();
629
- else
665
+ else if (this.mode !== "running")
630
666
  this.toggleMode();
631
667
  break;
632
668
  case "backspace":
@@ -652,13 +688,104 @@ class TuiApp {
652
688
  this.scheduleRender();
653
689
  resolve?.(result);
654
690
  }
691
+ submitText(raw) {
692
+ if (this.mode !== "running") {
693
+ this.buffer.reset();
694
+ this.inputScroll = 0;
695
+ this.finishLine({ kind: "submit", text: raw });
696
+ return;
697
+ }
698
+ const extracted = extractInputImages(raw, this.pendingImages);
699
+ this.pendingImages = new Map();
700
+ const text = extracted.text;
701
+ const images = extracted.images;
702
+ if (text)
703
+ this.buffer.addHistory(text);
704
+ this.buffer.reset();
705
+ this.inputScroll = 0;
706
+ if (/^\/btw\b/.test(text)) {
707
+ void this.runBtw(text.replace(/^\/btw\s*/, ""));
708
+ return;
709
+ }
710
+ this.queue.push({ text, images });
711
+ this.appendUserBlock(text, images.length, [
712
+ ` ${tone.mist("// QUEUED — will be sent when the current task finishes")}`,
713
+ ]);
714
+ this.scheduleRender();
715
+ }
716
+ async drainQueue() {
717
+ while (this.queue.length > 0) {
718
+ const next = this.queue.shift();
719
+ if (!next.text.trim())
720
+ continue;
721
+ if (next.text.startsWith("/")) {
722
+ const shouldExit = await this.command(next.text);
723
+ if (shouldExit)
724
+ return true;
725
+ continue;
726
+ }
727
+ this.append([` ${tone.mist("// SENDING QUEUED MESSAGE")}`]);
728
+ this.clearSplash();
729
+ this.userMarkers.push({ line: this.transcript.length, text: next.text });
730
+ await this.turn(next.text, next.images);
731
+ }
732
+ return false;
733
+ }
734
+ async runBtw(question) {
735
+ const text = question.trim();
736
+ if (!text) {
737
+ this.append([` ${tone.mist("// usage: /btw <question>")}`]);
738
+ return;
739
+ }
740
+ const provider = this.ensureProvider();
741
+ const inner = Math.min(Math.max(44, this.contentWidth() - 8), 66);
742
+ if (!provider) {
743
+ this.append([
744
+ ` ${tone.red(glyphs.cross)} ${tone.mist("// BTW needs a configured provider")}`,
745
+ ]);
746
+ return;
747
+ }
748
+ const pending = box("BTW", [tone.silver(clipAnsi(text, inner - 2)), "", tone.mist("// thinking…")], inner, palette.steel);
749
+ this.append([...pending, ""]);
750
+ const start = Math.max(0, this.transcript.length - pending.length - 1);
751
+ try {
752
+ const response = await provider.complete({
753
+ model: this.model,
754
+ 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.`,
755
+ messages: [
756
+ ...this.session.getMessages(),
757
+ { role: "user", content: [{ type: "text", text }] },
758
+ ],
759
+ maxTokens: 2048,
760
+ });
761
+ const answer = response.content
762
+ .map((part) => (part.type === "text" ? part.text : ""))
763
+ .join("")
764
+ .trim();
765
+ const answerLines = answer
766
+ ? renderMarkdown(answer, { width: inner - 2, indent: 2 })
767
+ : [tone.mist("// the model returned no answer")];
768
+ const card = box("BTW", [tone.silver(clipAnsi(text, inner - 2)), "", ...answerLines], inner, palette.steel);
769
+ this.transcript.splice(start, pending.length + 1, ...card, "");
770
+ }
771
+ catch (err) {
772
+ const card = box("BTW", [
773
+ tone.silver(clipAnsi(text, inner - 2)),
774
+ "",
775
+ tone.red(`// ${errorMessage(err)}`),
776
+ ], inner, palette.red);
777
+ this.transcript.splice(start, pending.length + 1, ...card, "");
778
+ }
779
+ this.scheduleRender();
780
+ }
655
781
  readLine(overrides = {}) {
656
782
  const initial = overrides.initial ?? this.pendingInitial ?? "";
657
783
  this.pendingInitial = null;
658
784
  if (initial)
659
785
  this.buffer.setText(initial);
660
- else
786
+ else if (this.buffer.text().trim() === "")
661
787
  this.buffer.reset();
788
+ this.inputScroll = 0;
662
789
  this.inputPrompt = overrides.prompt ?? defaultPrompt();
663
790
  this.inputMask = overrides.mask ?? false;
664
791
  this.inputPlaceholder = overrides.placeholder ?? "ask anything, or /help";
@@ -754,6 +881,23 @@ class TuiApp {
754
881
  this.scheduleRender();
755
882
  return true;
756
883
  }
884
+ inputWindowSize() {
885
+ return Math.max(3, Math.floor(this.screen.rows / 2));
886
+ }
887
+ inputOverflow() {
888
+ return this.lastInputTotalRows > this.inputWindowSize();
889
+ }
890
+ pointerOverInput() {
891
+ if (!this.lastInputRender || this.hoverRow < 0)
892
+ return false;
893
+ const top = this.lastHeaderRows + this.lastViewport + this.lastInputOffset;
894
+ return (this.hoverRow >= top && this.hoverRow < top + this.lastInputRender.lines.length);
895
+ }
896
+ scrollInput(delta) {
897
+ const maxOffset = Math.max(0, this.lastInputTotalRows - this.inputWindowSize());
898
+ this.inputScroll = Math.max(0, Math.min(this.inputScroll + delta, maxOffset));
899
+ this.scheduleRender();
900
+ }
757
901
  bottomHintHit(row, col) {
758
902
  if (this.bottomHintRow < 0 || this.bottomHintStart < 0)
759
903
  return false;
@@ -776,6 +920,8 @@ class TuiApp {
776
920
  return ` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`;
777
921
  }
778
922
  handleHover(row, col) {
923
+ this.hoverRow = row;
924
+ this.hoverCol = col;
779
925
  if (row === 1) {
780
926
  const hover = this.badgeHit(col);
781
927
  if (hover !== this.modeBadgeHover) {
@@ -797,11 +943,11 @@ class TuiApp {
797
943
  }
798
944
  return;
799
945
  }
800
- if (this.mode === "editing") {
946
+ if (this.mode === "editing" || this.mode === "running") {
801
947
  const matches = this.suggestionMatches();
802
948
  if (matches.length === 0)
803
949
  return;
804
- const top = this.lastHeaderRows + this.lastViewport + 1;
950
+ const top = this.lastHeaderRows + this.lastViewport + 1 + (this.status ? 1 : 0);
805
951
  const index = row - top;
806
952
  if (index >= 0 && index < matches.length && index !== this.suggestionIndex) {
807
953
  this.suggestionIndex = index;
@@ -837,16 +983,16 @@ class TuiApp {
837
983
  }
838
984
  return;
839
985
  }
840
- if (this.mode === "editing") {
986
+ if (this.mode === "editing" || this.mode === "running") {
841
987
  if (this.placeInputCursor(row, col))
842
988
  return;
843
989
  const matches = this.suggestionMatches();
844
990
  if (matches.length > 0) {
845
- const top = this.lastHeaderRows + this.lastViewport + 1;
991
+ const top = this.lastHeaderRows + this.lastViewport + 1 + (this.status ? 1 : 0);
846
992
  const index = row - top;
847
993
  if (index >= 0 && index < matches.length) {
848
994
  this.suggestionIndex = index;
849
- this.finishLine({ kind: "submit", text: matches[index].name });
995
+ this.submitText(matches[index].name);
850
996
  }
851
997
  }
852
998
  return;
@@ -1075,7 +1221,7 @@ class TuiApp {
1075
1221
  this.bottomHintStart >= 0 ? this.lastHeaderRows + viewport + footer.length - 1 : -1;
1076
1222
  let content;
1077
1223
  if (this.mode === "picker" && this.picker) {
1078
- content = this.picker.render(this.contentWidth() - 2, viewport);
1224
+ content = this.picker.render(this.contentWidth(), viewport);
1079
1225
  }
1080
1226
  else {
1081
1227
  content = this.visibleTranscript(viewport);
@@ -1088,7 +1234,10 @@ class TuiApp {
1088
1234
  while (lines.length < rows)
1089
1235
  lines.push("");
1090
1236
  let cursor = null;
1091
- if (this.mode === "editing" && this.lastInputRender) {
1237
+ if ((this.mode === "editing" || this.mode === "running") &&
1238
+ this.lastInputRender &&
1239
+ this.lastInputRender.cursorRow >= 0 &&
1240
+ this.lastInputRender.cursorRow < this.lastInputRender.lines.length) {
1092
1241
  const inputTop = this.padTop + header.length + viewport + this.lastInputOffset;
1093
1242
  cursor = {
1094
1243
  row: inputTop + this.lastInputRender.cursorRow,
@@ -1154,7 +1303,7 @@ class TuiApp {
1154
1303
  const width = this.contentWidth();
1155
1304
  const rule = ` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`;
1156
1305
  const bottom = this.bottomRuleLine();
1157
- if (this.mode === "editing") {
1306
+ if (this.mode === "editing" || this.mode === "running") {
1158
1307
  const rendered = this.buffer.render({
1159
1308
  width,
1160
1309
  prompt: this.inputPrompt,
@@ -1162,9 +1311,14 @@ class TuiApp {
1162
1311
  mask: this.inputMask,
1163
1312
  style: styleInputLine,
1164
1313
  });
1165
- const maxRows = Math.max(3, Math.floor(this.screen.rows / 2));
1166
- if (rendered.lines.length > maxRows) {
1167
- const start = Math.max(0, Math.min(rendered.cursorRow - maxRows + 1, rendered.lines.length - maxRows));
1314
+ const maxRows = this.inputWindowSize();
1315
+ const total = rendered.lines.length;
1316
+ this.lastInputTotalRows = total;
1317
+ if (total > maxRows) {
1318
+ const maxOffset = total - maxRows;
1319
+ const fromBottom = Math.max(0, Math.min(this.inputScroll, maxOffset));
1320
+ this.inputScroll = fromBottom;
1321
+ const start = maxOffset - fromBottom;
1168
1322
  this.lastInputRender = {
1169
1323
  lines: rendered.lines.slice(start, start + maxRows),
1170
1324
  rows: rendered.rows.slice(start, start + maxRows),
@@ -1173,18 +1327,22 @@ class TuiApp {
1173
1327
  };
1174
1328
  }
1175
1329
  else {
1330
+ this.inputScroll = 0;
1176
1331
  this.lastInputRender = rendered;
1177
1332
  }
1178
1333
  const suggestions = this.suggestions();
1179
- this.lastInputOffset = 1 + suggestions.length;
1180
- return [rule, ...suggestions, ...this.lastInputRender.lines, bottom];
1334
+ const statusLines = this.status
1335
+ ? [this.statusLine()]
1336
+ : this.mode === "running"
1337
+ ? [` ${tone.mist("// esc interrupts · enter queues a message")}`]
1338
+ : [];
1339
+ this.lastInputOffset = 1 + statusLines.length + suggestions.length;
1340
+ return [rule, ...statusLines, ...suggestions, ...this.lastInputRender.lines, bottom];
1181
1341
  }
1182
1342
  this.lastInputRender = null;
1343
+ this.lastInputTotalRows = 0;
1183
1344
  if (this.status)
1184
1345
  return [rule, this.statusLine(), bottom];
1185
- if (this.mode === "running") {
1186
- return [rule, ` ${tone.mist("// esc interrupts")}`, bottom];
1187
- }
1188
1346
  if (this.mode === "permission")
1189
1347
  return [rule, ` ${this.permissionHint}`, bottom];
1190
1348
  if (this.mode === "picker" && this.picker) {
@@ -1195,14 +1353,22 @@ class TuiApp {
1195
1353
  statusLine() {
1196
1354
  const spin = tone.accent(glyphs.spinner[this.spinnerFrame % glyphs.spinner.length] ?? "-");
1197
1355
  const elapsed = ((performance.now() - this.statusStartedAt) / 1000).toFixed(1);
1198
- const meta = [this.status?.meta, `${elapsed}s`].filter(Boolean).join(" · ");
1356
+ const meta = [
1357
+ this.status?.meta,
1358
+ `${elapsed}s`,
1359
+ this.queue.length > 0 ? `${this.queue.length} queued` : "",
1360
+ ]
1361
+ .filter(Boolean)
1362
+ .join(" · ");
1199
1363
  return ` ${spin} ${this.status?.label ?? ""} ${tone.mist(meta)}`;
1200
1364
  }
1201
1365
  visibleTranscript(viewport) {
1202
- const total = this.transcript.length;
1203
- const maxOffset = Math.max(0, total - viewport);
1366
+ let contentEnd = this.transcript.length;
1367
+ while (contentEnd > 0 && this.transcript[contentEnd - 1] === "")
1368
+ contentEnd -= 1;
1369
+ const maxOffset = Math.max(0, contentEnd - viewport);
1204
1370
  this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
1205
- const end = total - this.scrollOffset;
1371
+ const end = contentEnd - this.scrollOffset;
1206
1372
  const start = Math.max(0, end - viewport);
1207
1373
  this.lastVisibleStart = start;
1208
1374
  return this.transcript.slice(start, end);
@@ -1950,10 +2116,15 @@ class TuiApp {
1950
2116
  case "help":
1951
2117
  this.printHelp();
1952
2118
  return false;
2119
+ case "btw":
2120
+ await this.runBtw(arg);
2121
+ return false;
1953
2122
  case "new":
1954
2123
  this.session = new Session(this.system);
1955
2124
  this.conversationId = makeSessionId();
1956
2125
  this.providerSessionId = makeSessionId();
2126
+ this.sessionDir = this.dir;
2127
+ this.conversationTitle = null;
1957
2128
  this.lastTurn = null;
1958
2129
  this.pendingImages = new Map();
1959
2130
  this.transcript = [];
@@ -2071,7 +2242,14 @@ class TuiApp {
2071
2242
  const records = await listSessionsForDir(this.dir, max);
2072
2243
  const conversations = sortConversations(latestPerConversation(records));
2073
2244
  if (conversations.length === 0) {
2074
- this.append([` ${tone.mist(`// NO SESSIONS YET IN ${shortenHome(this.dir).toUpperCase()}`)}`]);
2245
+ const lines = [
2246
+ ` ${tone.mist(`// NO SESSIONS YET IN ${shortenHome(this.dir).toUpperCase()}`)}`,
2247
+ ];
2248
+ const elsewhere = await this.latestSessionElsewhere();
2249
+ if (elsewhere) {
2250
+ lines.push(` ${tone.mist(`// latest: "${clipAnsi(displayTitle(elsewhere), 48)}" in ${shortenHome(elsewhere.cwd)}`)}`, ` ${tone.mist(`// resume it with: /dir ${shortenHome(elsewhere.cwd)} then /sessions`)}`);
2251
+ }
2252
+ this.append(lines);
2075
2253
  return;
2076
2254
  }
2077
2255
  const labelWidth = Math.max(20, this.contentWidth() - 62);
@@ -2079,7 +2257,7 @@ class TuiApp {
2079
2257
  kind: "item",
2080
2258
  value: conversationKey(record),
2081
2259
  label: `${record.pinned ? `${glyphs.pin} ` : ""}${clipAnsi(displayTitle(record), labelWidth)}`,
2082
- hint: `${record.pinned ? "pinned · " : ""}${record.createdAt.slice(0, 16).replace("T", " ")} · ${record.provider ? `${record.provider} · ` : ""}${record.model} · ${record.status} · ${record.steps} steps`,
2260
+ hint: `${record.pinned ? "pinned · " : ""}${record.provider ? `${record.provider} · ` : ""}${record.model} · ${record.createdAt.slice(0, 16).replace("T", " ")}`,
2083
2261
  }));
2084
2262
  const chosen = await this.openPicker(new Picker({
2085
2263
  title: "sessions · enter resumes",
@@ -2135,6 +2313,7 @@ class TuiApp {
2135
2313
  if (!title)
2136
2314
  return;
2137
2315
  await updateConversation(conversationKey(record), { title });
2316
+ this.conversationTitle = title;
2138
2317
  this.append([
2139
2318
  ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SESSION RENAMED → ${clipAnsi(title, 60)}`)}`,
2140
2319
  ]);
@@ -2143,6 +2322,8 @@ class TuiApp {
2143
2322
  const messages = record.messages ?? [];
2144
2323
  this.session = new Session(this.system, messages);
2145
2324
  this.conversationId = conversationKey(record);
2325
+ this.sessionDir = record.cwd ?? this.dir;
2326
+ this.conversationTitle = record.title ?? null;
2146
2327
  this.providerSessionId = makeSessionId();
2147
2328
  if (record.model) {
2148
2329
  const resolved = record.provider && providerHasModel(record.provider, record.model)
@@ -2194,8 +2375,12 @@ class TuiApp {
2194
2375
  .flatMap((part) => (part.type === "text" ? [part.text] : []))
2195
2376
  .join("\n");
2196
2377
  const images = message.content.filter((part) => part.type === "image").length;
2197
- if (text.trim() || images > 0)
2378
+ if (text.trim() || images > 0) {
2379
+ if (lines.length > 0 && lines[lines.length - 1] !== "")
2380
+ lines.push("");
2198
2381
  lines.push(...this.userBlockLines(text, images));
2382
+ lines.push("");
2383
+ }
2199
2384
  continue;
2200
2385
  }
2201
2386
  if (message.role === "assistant") {
@@ -2618,28 +2803,56 @@ class TuiApp {
2618
2803
  this.scheduleRender();
2619
2804
  return choice === "allow" || choice === "always";
2620
2805
  }
2806
+ async latestSessionElsewhere() {
2807
+ const records = await listSessions(50).catch(() => []);
2808
+ return (records.find((record) => record.cwd && !sameDir(record.cwd, this.dir)) ?? null);
2809
+ }
2810
+ /**
2811
+ * On Windows a leading "/" is the drive root, so "/Downloads" resolves to
2812
+ * C:\Downloads instead of the user's Downloads folder. Treat it (and "~")
2813
+ * as home-relative so Unix-style paths do what users expect.
2814
+ */
2815
+ resolveDirArg(arg) {
2816
+ let target = arg.trim();
2817
+ if (target === "~") {
2818
+ target = homedir();
2819
+ }
2820
+ else if (target.startsWith("~/") || target.startsWith("~\\")) {
2821
+ target = path.join(homedir(), target.slice(2));
2822
+ }
2823
+ else if (process.platform === "win32" &&
2824
+ target.startsWith("/") &&
2825
+ !target.startsWith("//")) {
2826
+ target = path.join(homedir(), target);
2827
+ }
2828
+ return path.resolve(this.dir, target);
2829
+ }
2621
2830
  async changeDir(arg) {
2622
2831
  if (!arg) {
2623
2832
  this.append([` ${tone.mist(`// DIR ${this.dir}`)}`]);
2624
2833
  return;
2625
2834
  }
2626
- const next = path.resolve(this.dir, arg);
2835
+ const next = this.resolveDirArg(arg);
2627
2836
  if (next === this.dir) {
2628
2837
  this.append([` ${tone.mist("// already in this directory")}`]);
2629
2838
  return;
2630
2839
  }
2840
+ const messages = this.session.getMessages().map((message) => ({ ...message }));
2631
2841
  this.dir = next;
2632
2842
  this.rebuildTools();
2633
- this.session = new Session("");
2843
+ this.lastTurn = null;
2634
2844
  try {
2635
2845
  const meta = await this.loadProject();
2636
- this.session = new Session(this.system);
2846
+ this.session = new Session(this.system, messages);
2637
2847
  this.append([
2638
- ` ${tone.mist(`// DIR → ${shortenHome(this.dir)}`)}`,
2848
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SESSION MOVED → ${shortenHome(this.dir)}`)}`,
2639
2849
  ` ${tone.mist("// PROJECT")} ${meta}`,
2850
+ ` ${tone.mist(`// ${messages.length} message${messages.length === 1 ? "" : "s"} carried over`)}`,
2851
+ ` ${tone.mist("// in-memory only — the session keeps its original history")}`,
2640
2852
  ]);
2641
2853
  }
2642
2854
  catch (err) {
2855
+ this.session = new Session(this.system, messages);
2643
2856
  this.append([` ${tone.red(`// failed to scan project: ${errorMessage(err)}`)}`]);
2644
2857
  }
2645
2858
  }
@@ -2738,6 +2951,7 @@ class TuiApp {
2738
2951
  ["↑ ↓", "input history — previous / next message"],
2739
2952
  ["wheel", "scroll the conversation"],
2740
2953
  ["ctrl+↓", "jump to the bottom of the chat (or click the ↓ button)"],
2954
+ ["enter while running", "queue the message · /btw asks without interrupting"],
2741
2955
  ["ctrl+p/ctrl+n", "input history (same as ↑ ↓)"],
2742
2956
  ["tab / badge", "switch plan/build · click the header badge · complete commands"],
2743
2957
  ["ctrl+z", "undo the last message (works while thinking)"],
@@ -2755,6 +2969,13 @@ class TuiApp {
2755
2969
  }
2756
2970
  this.append(lines);
2757
2971
  }
2972
+ appendUserBlock(input, imageCount, extra = []) {
2973
+ const lines = this.userBlockLines(input, imageCount);
2974
+ const last = this.transcript[this.transcript.length - 1];
2975
+ const leading = this.transcript.length > 0 && last !== "" ? [""] : [];
2976
+ this.append([...leading, ...lines, ...extra, ""]);
2977
+ return Math.max(0, this.transcript.length - (extra.length + 1) - lines.length);
2978
+ }
2758
2979
  userBlockLines(input, imageCount = 0) {
2759
2980
  const width = Math.max(20, this.contentWidth() - 2);
2760
2981
  const lines = input.split("\n");
@@ -2765,12 +2986,12 @@ class TuiApp {
2765
2986
  out.push("");
2766
2987
  continue;
2767
2988
  }
2768
- const prefix = index === 0 ? ` ${tone.accent(glyphs.prompt)} ` : " ";
2769
- const kind = index === 0 ? "bold" : "plain";
2770
- out.push(...wrapSegments([{ text: line, kind }], prefix, " ", width));
2989
+ const prefix = index === 0 ? `${tone.accent(glyphs.prompt)} ` : " ";
2990
+ const kind = "bold";
2991
+ out.push(...wrapSegments([{ text: line, kind }], prefix, " ", width));
2771
2992
  }
2772
2993
  if (imageCount > 0) {
2773
- out.push(` ${tone.mist(`[${imageCount} image${imageCount === 1 ? "" : "s"} attached]`)}`);
2994
+ out.push(` ${tone.mist(`[${imageCount} image${imageCount === 1 ? "" : "s"} attached]`)}`);
2774
2995
  }
2775
2996
  return out;
2776
2997
  }
@@ -2779,8 +3000,9 @@ class TuiApp {
2779
3000
  const record = {
2780
3001
  id: makeSessionId(),
2781
3002
  createdAt: new Date().toISOString(),
2782
- cwd: this.dir,
3003
+ cwd: this.sessionDir,
2783
3004
  task: input,
3005
+ title: this.conversationTitle ?? this.sessionTitleFromMessages(),
2784
3006
  provider: this.providerId,
2785
3007
  model: this.model,
2786
3008
  status: result.status,
@@ -2804,6 +3026,22 @@ class TuiApp {
2804
3026
  }
2805
3027
  return record.id;
2806
3028
  }
3029
+ sessionTitleFromMessages() {
3030
+ for (const message of this.session.getMessages()) {
3031
+ if (message.role !== "user")
3032
+ continue;
3033
+ const text = message.content
3034
+ .map((part) => (part.type === "text" ? part.text : ""))
3035
+ .join(" ")
3036
+ .replace(/\[Image \d+\]/g, " ")
3037
+ .replace(/\s+/g, " ")
3038
+ .trim();
3039
+ if (!text)
3040
+ continue;
3041
+ return text.length > 60 ? `${text.slice(0, 59)}…` : text;
3042
+ }
3043
+ return "(untitled)";
3044
+ }
2807
3045
  printGoodbye() {
2808
3046
  this.append(["", ` ${tone.mist(`// SESSION CLOSED · ${this.summaryLine()}`)}`]);
2809
3047
  }