@duetso/agent 0.1.59 → 0.1.61

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.
@@ -2,7 +2,7 @@ import { BoxRenderable, createCliRenderer, decodePasteBytes, fg, ScrollBoxRender
2
2
  import { writeClipboardText } from "./clipboard.js";
3
3
  import { describeMacClipboardTypes, loadImageFromPath, looksLikeImageFilePath, persistPastedImage, sniffImageMimeType, tryReadClipboardImage, tryReadClipboardText, } from "./paste.js";
4
4
  import { parseCopyArgument, selectCopyText } from "./transcript-log.js";
5
- import { activeFileAutocompleteToken, activeSkillAutocompleteToken, AUTOCOMPLETE_LIMITS, BUILT_IN_SLASH_COMMANDS, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, skillAutocompleteMatches, } from "./autocomplete.js";
5
+ import { activeFileAutocompleteToken, activeSkillAutocompleteToken, AUTOCOMPLETE_LIMITS, BUILT_IN_SLASH_COMMANDS, commitActiveAnswer, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionHighlight, moveSkillAutocompleteSelection, NO_HIGHLIGHT, restoreSavedAnswer, skillAutocompleteMatches, } from "./autocomplete.js";
6
6
  import { homedir } from "node:os";
7
7
  import { buildFileIndex } from "./file-index.js";
8
8
  import { DUET_BANNER_LINES_COMPACT, historyDisplayBlocks, limitHistoryDisplayMessages, } from "./history.js";
@@ -12,7 +12,7 @@ import { orderedSelectableStarters, selectStarters } from "./starters.js";
12
12
  import { COLORS, HINT_IDLE, HINT_RUNNING, HINT_SELECTION_COPY } from "./theme.js";
13
13
  // Re-exports preserve the historical `tui/app.js` entry point used by tests
14
14
  // and external callers; the implementations live in focused leaf modules.
15
- export { activeFileAutocompleteToken, activeSkillAutocompleteToken, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, replaceFileAutocompleteToken, replaceSkillAutocompleteToken, skillAutocompleteMatches, } from "./autocomplete.js";
15
+ export { activeFileAutocompleteToken, activeSkillAutocompleteToken, commitActiveAnswer, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionHighlight, moveSkillAutocompleteSelection, NO_HIGHLIGHT, questionPickerAnswer, replaceFileAutocompleteToken, replaceSkillAutocompleteToken, restoreSavedAnswer, skillAutocompleteMatches, } from "./autocomplete.js";
16
16
  export { formatSkillAutocompleteItem } from "./autocomplete.js";
17
17
  export { DUET_BANNER_LINES, historyDisplayBlocks, limitHistoryDisplayMessages, startupHeaderLines, } from "./history.js";
18
18
  import { assembleToolBlock, formatToolBlock, truncateToolText } from "./tool-formatters.js";
@@ -872,11 +872,16 @@ export async function runTui(input) {
872
872
  }
873
873
  }
874
874
  function formatMemoryEventBody(event) {
875
- if (!event.observations || event.observations.length === 0) {
875
+ const hasObservations = Boolean(event.observations && event.observations.length > 0);
876
+ const hasBumps = Boolean(event.usageBumpedObservations && event.usageBumpedObservations.length > 0);
877
+ if (!hasObservations && !hasBumps) {
876
878
  return "";
877
879
  }
878
- const content = event.observations.map((observation) => observation.content).join("\n\n");
879
- return truncateToolText(`${event.message}\n${content}`);
880
+ const sections = [event.message];
881
+ if (hasObservations) {
882
+ sections.push(event.observations.map((observation) => observation.content).join("\n\n"));
883
+ }
884
+ return truncateToolText(sections.join("\n"));
880
885
  }
881
886
  // ---- input handling --------------------------------------------------------
882
887
  let skillAutocompleteSkills = [];
@@ -892,7 +897,20 @@ export async function runTui(input) {
892
897
  let fileAutocompleteItems = [];
893
898
  let fileAutocompleteSelectedIndex = 0;
894
899
  let pendingQuestions = [];
895
- let questionOptionSelectedIndex = 0;
900
+ let questionActiveIndex = 0;
901
+ // `NO_HIGHLIGHT` (-1) means no row is highlighted yet — the user must press
902
+ // Up/Down to land on a concrete row. Single-select live-records the
903
+ // highlight as the answer; multi-select uses highlight purely for
904
+ // navigation and toggles the checked set on Space/Enter.
905
+ let questionOptionSelectedIndex = NO_HIGHLIGHT;
906
+ // Per-question checked indices for the active multi-select question. Reset
907
+ // when the picker advances to the next question; single-select questions
908
+ // simply ignore this set and use `questionOptionSelectedIndex` instead.
909
+ let questionMultiSelectChecked = new Set();
910
+ // Answers collected while walking the picker, keyed by question text. We
911
+ // dispatch the full map once the user finishes the last question, or flush
912
+ // it early when they decide to type a free-form prompt instead.
913
+ let questionAccumulatedAnswers = {};
896
914
  let suppressNextEscapeExit = false;
897
915
  function skillAutocompleteIsOpen() {
898
916
  return Boolean(skillAutocompleteToken && skillAutocompleteItems.length > 0);
@@ -901,9 +919,12 @@ export async function runTui(input) {
901
919
  return Boolean(fileAutocompleteToken && fileAutocompleteItems.length > 0);
902
920
  }
903
921
  function questionPickerIsOpen() {
904
- const question = pendingQuestions[0];
922
+ const question = pendingQuestions[questionActiveIndex];
905
923
  return Boolean(question && question.options.length > 0);
906
924
  }
925
+ function activeQuestion() {
926
+ return pendingQuestions[questionActiveIndex];
927
+ }
907
928
  function hideSkillAutocomplete() {
908
929
  skillAutocompleteToken = undefined;
909
930
  skillAutocompleteItems = [];
@@ -928,7 +949,10 @@ export async function runTui(input) {
928
949
  }
929
950
  function hideQuestions() {
930
951
  pendingQuestions = [];
931
- questionOptionSelectedIndex = 0;
952
+ questionActiveIndex = 0;
953
+ questionOptionSelectedIndex = NO_HIGHLIGHT;
954
+ questionMultiSelectChecked = new Set();
955
+ questionAccumulatedAnswers = {};
932
956
  questionPanel.visible = false;
933
957
  for (const row of questionRows) {
934
958
  row.visible = false;
@@ -937,46 +961,176 @@ export async function runTui(input) {
937
961
  }
938
962
  function showQuestions(questions) {
939
963
  pendingQuestions = questions;
940
- questionOptionSelectedIndex = 0;
964
+ questionActiveIndex = 0;
965
+ questionOptionSelectedIndex = NO_HIGHLIGHT;
966
+ questionMultiSelectChecked = new Set();
967
+ questionAccumulatedAnswers = {};
941
968
  renderQuestions();
942
969
  }
970
+ /**
971
+ * Total navigable rows for the active question. The Up/Down handler clamps
972
+ * navigation to what is actually rendered on screen so a user cannot land
973
+ * the highlight on a row they cannot see.
974
+ */
975
+ function activeRowCount() {
976
+ const question = activeQuestion();
977
+ if (!question)
978
+ return 0;
979
+ const optionLimit = question.multiSelect ? QUESTION_OPTION_LIMIT - 1 : QUESTION_OPTION_LIMIT;
980
+ const visibleOptionCount = Math.min(question.options.length, optionLimit);
981
+ return visibleOptionCount + (question.multiSelect ? 1 : 0);
982
+ }
983
+ /**
984
+ * Row index of the synthetic Done row when the active question is
985
+ * multi-select; `undefined` otherwise so callers don't compare against a
986
+ * sentinel value. The Done row sits one past the last visible option,
987
+ * clamped to the same limit `renderQuestions` uses.
988
+ */
989
+ function activeQuestionDoneIndex() {
990
+ const question = activeQuestion();
991
+ if (!question?.multiSelect)
992
+ return undefined;
993
+ const optionLimit = QUESTION_OPTION_LIMIT - 1;
994
+ return Math.min(question.options.length, optionLimit);
995
+ }
943
996
  function renderQuestions() {
944
- const question = pendingQuestions[0];
997
+ const question = activeQuestion();
945
998
  if (!question || question.options.length === 0) {
946
999
  hideQuestions();
947
1000
  return;
948
1001
  }
949
1002
  questionPanel.visible = true;
950
- questionTitle.content = question.header
1003
+ const baseTitle = question.header
951
1004
  ? `${question.header}: ${question.question}`
952
1005
  : question.question;
953
- const visibleOptions = question.options.slice(0, QUESTION_OPTION_LIMIT);
1006
+ const positionPrefix = pendingQuestions.length > 1 ? `(${questionActiveIndex + 1}/${pendingQuestions.length}) ` : "";
1007
+ const navHint = pendingQuestions.length > 1 ? " [←/→ navigate]" : "";
1008
+ questionTitle.content = `${positionPrefix}${baseTitle}${navHint}`;
1009
+ const optionLimit = question.multiSelect ? QUESTION_OPTION_LIMIT - 1 : QUESTION_OPTION_LIMIT;
1010
+ const visibleOptions = question.options.slice(0, optionLimit);
1011
+ const doneIndex = activeQuestionDoneIndex();
954
1012
  for (const [index, row] of questionRows.entries()) {
955
- const option = visibleOptions[index];
956
- if (!option) {
957
- row.visible = false;
958
- row.content = "";
1013
+ if (index < visibleOptions.length) {
1014
+ const option = visibleOptions[index];
1015
+ const highlighted = index === questionOptionSelectedIndex;
1016
+ const checkbox = question.multiSelect
1017
+ ? questionMultiSelectChecked.has(index)
1018
+ ? "[x] "
1019
+ : "[ ] "
1020
+ : "";
1021
+ const labelColor = highlighted ? COLORS.status : COLORS.user;
1022
+ const description = formatQuestionOptionDescription(option.description);
1023
+ const labelLine = `${checkbox}${option.label}`;
1024
+ row.content = description
1025
+ ? t `${fg(labelColor)(labelLine)}\n${description}`
1026
+ : t `${fg(labelColor)(labelLine)}`;
1027
+ row.fg = highlighted ? COLORS.agent : COLORS.hint;
1028
+ row.visible = true;
959
1029
  continue;
960
1030
  }
961
- const selected = index === questionOptionSelectedIndex;
962
- const labelColor = selected ? COLORS.status : COLORS.user;
963
- const description = formatQuestionOptionDescription(option.description);
964
- row.content = description
965
- ? t `${fg(labelColor)(option.label)}\n${description}`
966
- : t `${fg(labelColor)(option.label)}`;
967
- row.fg = selected ? COLORS.agent : COLORS.hint;
968
- row.visible = true;
1031
+ if (question.multiSelect && index === visibleOptions.length) {
1032
+ // Synthetic Done row carries no checkbox prefix and self-documents
1033
+ // its purpose so users discover how to advance from a multi-select.
1034
+ const highlighted = doneIndex === questionOptionSelectedIndex;
1035
+ const labelColor = highlighted ? COLORS.status : COLORS.user;
1036
+ const description = formatQuestionOptionDescription("Advance to next question");
1037
+ row.content = t `${fg(labelColor)("Done")}\n${description}`;
1038
+ row.fg = highlighted ? COLORS.agent : COLORS.hint;
1039
+ row.visible = true;
1040
+ continue;
1041
+ }
1042
+ row.visible = false;
1043
+ row.content = "";
1044
+ }
1045
+ }
1046
+ function moveActiveQuestionHighlight(direction) {
1047
+ const question = activeQuestion();
1048
+ if (!question)
1049
+ return;
1050
+ questionOptionSelectedIndex = moveQuestionHighlight(questionOptionSelectedIndex, activeRowCount(), direction);
1051
+ // Single-select live-records the highlight as the answer so a
1052
+ // prompt-flush or arrow-nav captures it without requiring Space/Enter.
1053
+ // Multi-select keeps highlight separate from the toggled set.
1054
+ if (!question.multiSelect) {
1055
+ questionAccumulatedAnswers = commitActiveAnswer(question, questionOptionSelectedIndex, questionMultiSelectChecked, questionAccumulatedAnswers);
1056
+ }
1057
+ renderQuestions();
1058
+ }
1059
+ function toggleActiveMultiSelectOption() {
1060
+ const question = activeQuestion();
1061
+ if (!question?.multiSelect)
1062
+ return;
1063
+ if (questionMultiSelectChecked.has(questionOptionSelectedIndex)) {
1064
+ questionMultiSelectChecked.delete(questionOptionSelectedIndex);
1065
+ }
1066
+ else {
1067
+ questionMultiSelectChecked.add(questionOptionSelectedIndex);
969
1068
  }
1069
+ questionAccumulatedAnswers = commitActiveAnswer(question, questionOptionSelectedIndex, questionMultiSelectChecked, questionAccumulatedAnswers);
1070
+ renderQuestions();
1071
+ }
1072
+ function navigateActiveQuestion(direction) {
1073
+ if (pendingQuestions.length <= 1)
1074
+ return false;
1075
+ const nextIndex = questionActiveIndex + direction;
1076
+ if (nextIndex < 0 || nextIndex >= pendingQuestions.length)
1077
+ return false;
1078
+ questionAccumulatedAnswers = commitActiveAnswer(activeQuestion(), questionOptionSelectedIndex, questionMultiSelectChecked, questionAccumulatedAnswers);
1079
+ questionActiveIndex = nextIndex;
1080
+ const restored = restoreSavedAnswer(activeQuestion(), questionAccumulatedAnswers);
1081
+ questionOptionSelectedIndex = restored.selectedIndex;
1082
+ questionMultiSelectChecked = restored.checked;
1083
+ renderQuestions();
1084
+ return true;
1085
+ }
1086
+ function describeAnswerLabels(question, labels) {
1087
+ if (labels.length === 0)
1088
+ return question.multiSelect ? "(no selection)" : "";
1089
+ return labels.join(", ");
1090
+ }
1091
+ /**
1092
+ * Handle Space/Enter when the picker is open and the composer is empty.
1093
+ * Multi-select on a regular row toggles; multi-select on the Done row
1094
+ * advances. Single-select always advances (highlight = answer is already
1095
+ * live-recorded by Up/Down). No-op when nothing is highlighted yet so the
1096
+ * user is forced to make an explicit choice (or skip via Right-arrow).
1097
+ */
1098
+ function confirmActiveSelection() {
1099
+ const question = activeQuestion();
1100
+ if (!question)
1101
+ return false;
1102
+ if (questionOptionSelectedIndex === NO_HIGHLIGHT)
1103
+ return false;
1104
+ if (question.multiSelect && questionOptionSelectedIndex !== activeQuestionDoneIndex()) {
1105
+ toggleActiveMultiSelectOption();
1106
+ return true;
1107
+ }
1108
+ return advanceOrSubmit();
970
1109
  }
971
- function submitSelectedQuestionOption() {
972
- const answers = questionPickerAnswerPayload(pendingQuestions, questionOptionSelectedIndex);
973
- if (!answers)
1110
+ function advanceOrSubmit() {
1111
+ const question = activeQuestion();
1112
+ if (!question)
974
1113
  return false;
975
- const answerText = Object.values(answers).join("\n");
976
- recordTranscriptEntry("user", answerText);
977
- appendBlock("you:", answerText, COLORS.user);
1114
+ const accumulatedForActive = questionAccumulatedAnswers[question.question] ?? [];
1115
+ const transcriptText = describeAnswerLabels(question, accumulatedForActive);
1116
+ if (transcriptText) {
1117
+ recordTranscriptEntry("user", transcriptText);
1118
+ appendBlock("you:", transcriptText, COLORS.user);
1119
+ }
1120
+ if (questionActiveIndex < pendingQuestions.length - 1) {
1121
+ questionActiveIndex += 1;
1122
+ const restored = restoreSavedAnswer(activeQuestion(), questionAccumulatedAnswers);
1123
+ questionOptionSelectedIndex = restored.selectedIndex;
1124
+ questionMultiSelectChecked = restored.checked;
1125
+ renderQuestions();
1126
+ return true;
1127
+ }
978
1128
  void input.session
979
- .answer({ questions: pendingQuestions, answers, behavior: "follow_up" })
1129
+ .answer({
1130
+ questions: pendingQuestions,
1131
+ answers: questionAccumulatedAnswers,
1132
+ behavior: "follow_up",
1133
+ })
980
1134
  .catch(reportError);
981
1135
  hideQuestions();
982
1136
  markRunning();
@@ -1314,17 +1468,33 @@ export async function runTui(input) {
1314
1468
  }
1315
1469
  if (questionPickerIsOpen()) {
1316
1470
  if (key.name === "up") {
1317
- questionOptionSelectedIndex = moveQuestionOptionSelection(questionOptionSelectedIndex, Math.min(pendingQuestions[0]?.options.length ?? 0, QUESTION_OPTION_LIMIT), -1);
1318
- renderQuestions();
1471
+ moveActiveQuestionHighlight(-1);
1319
1472
  key.preventDefault();
1320
1473
  return;
1321
1474
  }
1322
1475
  if (key.name === "down") {
1323
- questionOptionSelectedIndex = moveQuestionOptionSelection(questionOptionSelectedIndex, Math.min(pendingQuestions[0]?.options.length ?? 0, QUESTION_OPTION_LIMIT), 1);
1324
- renderQuestions();
1476
+ moveActiveQuestionHighlight(1);
1477
+ key.preventDefault();
1478
+ return;
1479
+ }
1480
+ // Space confirms the active selection only when the composer is empty
1481
+ // so users can still type a free-form prompt that includes spaces.
1482
+ if (key.name === "space" && inputField.plainText.length === 0) {
1325
1483
  key.preventDefault();
1484
+ confirmActiveSelection();
1326
1485
  return;
1327
1486
  }
1487
+ // Left/Right navigate between questions, but only when the composer is
1488
+ // empty so editing a typed prompt with arrow keys still works.
1489
+ if ((key.name === "left" || key.name === "right") &&
1490
+ inputField.plainText.length === 0 &&
1491
+ pendingQuestions.length > 1) {
1492
+ const direction = key.name === "left" ? -1 : 1;
1493
+ if (navigateActiveQuestion(direction)) {
1494
+ key.preventDefault();
1495
+ return;
1496
+ }
1497
+ }
1328
1498
  if (key.name === "escape") {
1329
1499
  key.preventDefault();
1330
1500
  suppressNextEscapeExit = true;
@@ -1348,7 +1518,7 @@ export async function runTui(input) {
1348
1518
  submit(value);
1349
1519
  }
1350
1520
  else if (questionPickerIsOpen()) {
1351
- submitSelectedQuestionOption();
1521
+ confirmActiveSelection();
1352
1522
  }
1353
1523
  else if (startersAreVisible()) {
1354
1524
  submitHighlightedStarter();
@@ -1631,11 +1801,29 @@ export async function runTui(input) {
1631
1801
  const lines = submittedImages.map((p) => `📎 ${p.label}: ${p.path}`).join("\n");
1632
1802
  appendBlock(null, lines, COLORS.hint);
1633
1803
  }
1634
- hideQuestions();
1635
1804
  const images = submittedImages.map((p) => p.attachment);
1636
1805
  // Reset before dispatch so an in-flight error does not leave the user
1637
1806
  // double-charged with the same attachments on retry.
1638
1807
  clearPendingImages();
1808
+ // If the question picker is open, treat the typed message as a flush:
1809
+ // dispatch whatever answers were already collected together with the new
1810
+ // prompt text so the model sees one combined turn instead of dropping
1811
+ // the partial answers on the floor.
1812
+ if (pendingQuestions.length > 0) {
1813
+ void input.session
1814
+ .answer({
1815
+ questions: pendingQuestions,
1816
+ answers: questionAccumulatedAnswers,
1817
+ behavior: "follow_up",
1818
+ message,
1819
+ images,
1820
+ })
1821
+ .catch(reportError);
1822
+ hideQuestions();
1823
+ if (!running)
1824
+ markRunning();
1825
+ return;
1826
+ }
1639
1827
  // Every submit — running or idle — is a follow_up. While the agent is
1640
1828
  // running this queues; while idle it kicks off a fresh turn. Single
1641
1829
  // mental model: type, press Enter, your message lands.