@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/picker.d.ts.map +1 -1
- package/dist/picker.js +11 -4
- package/dist/picker.js.map +1 -1
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +15 -1
- package/dist/theme.js.map +1 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +286 -48
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
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
|
|
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: "
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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()
|
|
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"
|
|
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 =
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
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 = [
|
|
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
|
-
|
|
1203
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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(`//
|
|
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 ?
|
|
2769
|
-
const kind =
|
|
2770
|
-
out.push(...wrapSegments([{ text: line, kind }], prefix, "
|
|
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(`
|
|
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.
|
|
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
|
}
|