mini-coder 0.5.12 → 0.5.14

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/src/ui/help.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { AppState } from "../index.ts";
8
- import { COMMANDS } from "../input.ts";
8
+ import { COMMANDS, SKILL_COMMAND } from "../input.ts";
9
9
  import { abbreviatePath } from "./status.ts";
10
10
 
11
11
  /** Help text inputs derived from application state. */
@@ -18,12 +18,12 @@ export interface HelpRenderState {
18
18
  agentsMd: AppState["agentsMd"];
19
19
  /** Discovered skills. */
20
20
  skills: AppState["skills"];
21
- /** Active plugins. */
22
- plugins: AppState["plugins"];
23
21
  /** Whether reasoning blocks are shown in the log. */
24
22
  showReasoning: AppState["showReasoning"];
25
23
  /** Whether verbose tool rendering is enabled in the log. */
26
24
  verbose: AppState["verbose"];
25
+ /** Configured MCP servers and their current on/off state. */
26
+ mcpServers: AppState["mcpServers"];
27
27
  }
28
28
 
29
29
  /** Command descriptions for `/help` and command autocomplete. */
@@ -36,12 +36,20 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
36
36
  undo: "Undo last turn",
37
37
  reasoning: "Toggle thinking display",
38
38
  verbose: "Toggle verbose tool rendering",
39
+ mcp: "Toggle configured MCP servers",
39
40
  todo: "Show the current todo list",
40
41
  login: "OAuth login",
41
42
  logout: "OAuth logout",
42
43
  help: "Show help",
43
44
  };
44
45
 
46
+ /** Slash-skill label shown in `/help` and command autocomplete. */
47
+ export const SKILL_REFERENCE_LABEL = `/${SKILL_COMMAND}:name`;
48
+
49
+ /** Description for the slash-skill input helper. */
50
+ export const SKILL_REFERENCE_DESCRIPTION =
51
+ "Insert a discovered skill into the next message";
52
+
45
53
  /**
46
54
  * Get the `/help` description for a command, including current state when relevant.
47
55
  *
@@ -71,6 +79,12 @@ function formatInlineCodeList(items: readonly string[]): string {
71
79
  return items.map((item) => formatInlineCode(item)).join(", ");
72
80
  }
73
81
 
82
+ function formatMcpServerState(
83
+ server: HelpRenderState["mcpServers"][number],
84
+ ): string {
85
+ return `${formatInlineCode(server.name)} (${server.enabled ? "on" : "off"})`;
86
+ }
87
+
74
88
  /**
75
89
  * Build the `/help` text shown in the conversation log.
76
90
  *
@@ -85,15 +99,21 @@ export function buildHelpText(state: HelpRenderState): string {
85
99
  `- ${formatInlineCode(`/${command}`)} — ${getHelpCommandDescription(command, state)}`,
86
100
  );
87
101
  }
102
+ lines.push(
103
+ `- ${formatInlineCode(SKILL_REFERENCE_LABEL)} — ${SKILL_REFERENCE_DESCRIPTION}; submit ${formatInlineCode(`/${SKILL_COMMAND}`)} to open the skill picker.`,
104
+ );
88
105
 
89
106
  const providerNames = Array.from(state.providers.keys());
107
+ const mcpServerStates = state.mcpServers.map((server) =>
108
+ formatMcpServerState(server),
109
+ );
90
110
  lines.push(
91
111
  "",
92
112
  "## Keyboard",
93
113
  "",
94
114
  "- `Enter` submits the current draft.",
95
115
  "- `Shift+Enter` inserts a newline.",
96
- "- `Tab` opens command autocomplete when the draft starts with `/`.",
116
+ "- `Tab` opens command autocomplete when the draft starts with `/`, preserving the current draft.",
97
117
  "- Otherwise, `Tab` autocompletes file paths and can open a path picker when there are multiple matches.",
98
118
  "- `Ctrl+R` opens global input history search.",
99
119
  "- `Escape` closes the current overlay and returns focus to the input.",
@@ -111,6 +131,9 @@ export function buildHelpText(state: HelpRenderState): string {
111
131
  state.model
112
132
  ? `- Model: ${formatInlineCode(`${state.model.provider}/${state.model.id}`)}`
113
133
  : "- Model: none — use `/model`",
134
+ mcpServerStates.length > 0
135
+ ? `- MCP servers: ${mcpServerStates.join(", ")}`
136
+ : "- MCP servers: none — configure them in `settings.json`",
114
137
  );
115
138
 
116
139
  if (state.agentsMd.length > 0) {
@@ -131,12 +154,5 @@ export function buildHelpText(state: HelpRenderState): string {
131
154
  }
132
155
  }
133
156
 
134
- if (state.plugins.length > 0) {
135
- lines.push("", "## Plugins", "");
136
- for (const plugin of state.plugins) {
137
- lines.push(`- ${formatInlineCode(plugin.entry.name)}`);
138
- }
139
- }
140
-
141
157
  return lines.join("\n");
142
158
  }
package/src/ui.ts CHANGED
@@ -27,7 +27,7 @@ import type { Theme } from "./theme.ts";
27
27
  import { createUiAgentController } from "./ui/agent.ts";
28
28
  import { createCommandController } from "./ui/commands.ts";
29
29
  import {
30
- buildConversationLogNodes,
30
+ buildConversationLayoutSnapshot,
31
31
  CONVERSATION_GAP,
32
32
  resetConversationRenderCache,
33
33
  } from "./ui/conversation.ts";
@@ -74,8 +74,8 @@ const TERMINAL_TITLE_FRAMES = [
74
74
  "[o=---]",
75
75
  ] as const;
76
76
 
77
- /** Maximum number of committed messages rendered before older history is chunked. */
78
- const CONVERSATION_CHUNK_MESSAGES = 50;
77
+ /** Overscan applied above and below the viewport for virtualized log rendering. */
78
+ const CONVERSATION_OVERSCAN_ROWS = 6;
79
79
 
80
80
  /** Minimum delay between coalesced streaming renders. */
81
81
  const STREAM_RENDER_MIN_INTERVAL_MS = 33;
@@ -97,6 +97,14 @@ const QUIT_RULES: Readonly<{
97
97
  keysWhenEmptyInput: new Set(["ctrl+d"]),
98
98
  };
99
99
 
100
+ /** Keypresses that still bubble while the queued steering draft is readonly. */
101
+ const READONLY_INPUT_BUBBLE_KEYS = new Set([
102
+ "ctrl+c",
103
+ "ctrl+d",
104
+ "ctrl+z",
105
+ "escape",
106
+ ]);
107
+
100
108
  // ---------------------------------------------------------------------------
101
109
  // UI state (module-scoped, not in AppState)
102
110
  // ---------------------------------------------------------------------------
@@ -107,12 +115,12 @@ let scrollOffset = 0;
107
115
  /** Whether the log auto-scrolls to the bottom. */
108
116
  let stickToBottom = true;
109
117
 
110
- /** First visible committed message when older history is chunked. */
111
- let visibleConversationStart = 0;
112
-
113
118
  /** Current text in the input area. */
114
119
  let inputValue = "";
115
120
 
121
+ /** Whether the visible input draft is temporarily readonly. */
122
+ let inputReadOnly = false;
123
+
116
124
  /** Whether the text input is focused. */
117
125
  let inputFocused = true;
118
126
 
@@ -166,8 +174,8 @@ export function isQuitKey(key: string, input: string): boolean {
166
174
  export function resetUiState(): void {
167
175
  scrollOffset = 0;
168
176
  stickToBottom = true;
169
- visibleConversationStart = 0;
170
177
  inputValue = "";
178
+ inputReadOnly = false;
171
179
  inputFocused = true;
172
180
  dividerTick = 0;
173
181
  stopDividerAnimation();
@@ -516,62 +524,151 @@ function renderDivider(state: AppState, width: number): Node {
516
524
  // Conversation log
517
525
  // ---------------------------------------------------------------------------
518
526
 
519
- function getLatestConversationChunkStart(messageCount: number): number {
520
- return Math.max(0, messageCount - CONVERSATION_CHUNK_MESSAGES);
527
+ function measureConversationSliceHeight(
528
+ prefixHeights: readonly number[],
529
+ startIndex: number,
530
+ endIndex: number,
531
+ ): number {
532
+ const count = endIndex - startIndex;
533
+ if (count <= 0) {
534
+ return 0;
535
+ }
536
+ return (
537
+ prefixHeights[endIndex]! -
538
+ prefixHeights[startIndex]! +
539
+ CONVERSATION_GAP * Math.max(0, count - 1)
540
+ );
541
+ }
542
+
543
+ function getConversationItemTop(
544
+ prefixHeights: readonly number[],
545
+ index: number,
546
+ ): number {
547
+ return prefixHeights[index]! + CONVERSATION_GAP * index;
548
+ }
549
+
550
+ function findConversationStartIndex(
551
+ itemHeights: readonly { height: number }[],
552
+ prefixHeights: readonly number[],
553
+ offset: number,
554
+ ): number {
555
+ let low = 0;
556
+ let high = itemHeights.length;
557
+
558
+ while (low < high) {
559
+ const mid = Math.floor((low + high) / 2);
560
+ const bottom =
561
+ getConversationItemTop(prefixHeights, mid) + itemHeights[mid]!.height;
562
+ if (bottom > offset) {
563
+ high = mid;
564
+ } else {
565
+ low = mid + 1;
566
+ }
567
+ }
568
+
569
+ return Math.min(low, Math.max(0, itemHeights.length - 1));
521
570
  }
522
571
 
523
- function getVisibleConversationStart(messageCount: number): number {
524
- if (stickToBottom) {
525
- return getLatestConversationChunkStart(messageCount);
572
+ function findConversationEndIndex(
573
+ items: readonly { height: number }[],
574
+ prefixHeights: readonly number[],
575
+ offset: number,
576
+ ): number {
577
+ let low = 0;
578
+ let high = items.length;
579
+
580
+ while (low < high) {
581
+ const mid = Math.floor((low + high) / 2);
582
+ if (getConversationItemTop(prefixHeights, mid) < offset) {
583
+ low = mid + 1;
584
+ } else {
585
+ high = mid;
586
+ }
526
587
  }
527
588
 
528
- visibleConversationStart = Math.min(
529
- visibleConversationStart,
530
- getLatestConversationChunkStart(messageCount),
531
- );
532
- return visibleConversationStart;
589
+ return low;
590
+ }
591
+
592
+ function renderConversationSpacer(height: number): Node | null {
593
+ const rows = Math.max(0, Math.floor(height));
594
+ if (rows === 0) {
595
+ return null;
596
+ }
597
+
598
+ const paddingY = Math.floor(rows / 2);
599
+ if (rows % 2 === 0) {
600
+ return VStack({ padding: { y: paddingY } }, []);
601
+ }
602
+
603
+ return VStack({ padding: { y: paddingY } }, [Text("")]);
533
604
  }
534
605
 
535
606
  /** Build the full conversation log as an array of nodes. */
536
607
  export function buildConversationLog(
537
608
  state: AppState,
538
609
  width = Number.POSITIVE_INFINITY,
610
+ viewportHeight = Number.POSITIVE_INFINITY,
539
611
  ): Node[] {
540
- return buildConversationLogNodes(
612
+ const layout = buildConversationLayoutSnapshot(
541
613
  state,
542
614
  agentController.getStreamingConversationState(),
543
- getVisibleConversationStart(state.messages.length),
544
615
  width,
545
616
  );
546
- }
617
+ const { items, prefixHeights } = layout;
618
+ if (!Number.isFinite(viewportHeight) || items.length <= 1) {
619
+ return items.map((item) => item.node);
620
+ }
547
621
 
548
- function measureConversationHeight(
549
- state: AppState,
550
- width: number,
551
- startIndex: number,
552
- ): number {
553
- return measureContentHeight(
554
- VStack(
555
- { gap: CONVERSATION_GAP },
556
- buildConversationLogNodes(
557
- state,
558
- agentController.getStreamingConversationState(),
559
- startIndex,
560
- width,
561
- ),
562
- ),
563
- { width: Math.max(1, width) },
622
+ const totalHeight = measureConversationSliceHeight(
623
+ prefixHeights,
624
+ 0,
625
+ items.length,
626
+ );
627
+ const visibleHeight = Math.max(1, Math.floor(viewportHeight));
628
+ const maxOffset = Math.max(0, totalHeight - visibleHeight);
629
+ const effectiveScrollOffset = stickToBottom
630
+ ? maxOffset
631
+ : Math.max(0, Math.min(scrollOffset, maxOffset));
632
+ const paddedTop = Math.max(
633
+ 0,
634
+ effectiveScrollOffset - CONVERSATION_OVERSCAN_ROWS,
635
+ );
636
+ const paddedBottom = Math.min(
637
+ totalHeight,
638
+ effectiveScrollOffset + visibleHeight + CONVERSATION_OVERSCAN_ROWS,
564
639
  );
565
- }
566
640
 
567
- function prependConversationChunk(state: AppState, width: number): void {
568
- const currentStart = visibleConversationStart;
569
- const nextStart = Math.max(0, currentStart - CONVERSATION_CHUNK_MESSAGES);
570
- const currentHeight = measureConversationHeight(state, width, currentStart);
571
- const nextHeight = measureConversationHeight(state, width, nextStart);
641
+ let startIndex = findConversationStartIndex(items, prefixHeights, paddedTop);
642
+ let endIndex = findConversationEndIndex(items, prefixHeights, paddedBottom);
643
+ if (endIndex <= startIndex) {
644
+ endIndex = Math.min(items.length, startIndex + 1);
645
+ }
646
+ startIndex = Math.min(startIndex, Math.max(0, endIndex - 1));
572
647
 
573
- visibleConversationStart = nextStart;
574
- scrollOffset += Math.max(0, nextHeight - currentHeight);
648
+ const topSpacerHeight = measureConversationSliceHeight(
649
+ prefixHeights,
650
+ 0,
651
+ startIndex,
652
+ );
653
+ const bottomSpacerHeight = measureConversationSliceHeight(
654
+ prefixHeights,
655
+ endIndex,
656
+ items.length,
657
+ );
658
+
659
+ const nodes: Node[] = [];
660
+ const topSpacer = renderConversationSpacer(topSpacerHeight);
661
+ if (topSpacer) {
662
+ nodes.push(topSpacer);
663
+ }
664
+ for (let index = startIndex; index < endIndex; index++) {
665
+ nodes.push(items[index]!.node);
666
+ }
667
+ const bottomSpacer = renderConversationSpacer(bottomSpacerHeight);
668
+ if (bottomSpacer) {
669
+ nodes.push(bottomSpacer);
670
+ }
671
+ return nodes;
575
672
  }
576
673
 
577
674
  // ---------------------------------------------------------------------------
@@ -610,6 +707,13 @@ function openPathAutocompleteOverlay(state: AppState): void {
610
707
  inputValue = value;
611
708
  dismissOverlay();
612
709
  },
710
+ onKeyPress: (key) => {
711
+ if (key === "escape") {
712
+ dismissOverlay();
713
+ return;
714
+ }
715
+ return false;
716
+ },
613
717
  onBlur: dismissOverlay,
614
718
  });
615
719
 
@@ -663,7 +767,7 @@ export function createInputController(state: AppState): InputController {
663
767
 
664
768
  return {
665
769
  onChange: (value) => {
666
- if (inputValue === value) {
770
+ if (inputReadOnly || inputValue === value) {
667
771
  return;
668
772
  }
669
773
  inputValue = value;
@@ -675,6 +779,13 @@ export function createInputController(state: AppState): InputController {
675
779
  inputFocused = false;
676
780
  },
677
781
  onKeyPress: (key) => {
782
+ if (inputReadOnly) {
783
+ if (READONLY_INPUT_BUBBLE_KEYS.has(key)) {
784
+ return;
785
+ }
786
+ return false;
787
+ }
788
+
678
789
  if (key === "enter") {
679
790
  const raw = inputValue;
680
791
 
@@ -684,8 +795,13 @@ export function createInputController(state: AppState): InputController {
684
795
  return false;
685
796
  }
686
797
 
687
- inputValue = "";
798
+ const queuedInputCount = state.queuedUserMessages.length;
688
799
  handleInput(raw, state);
800
+ if (state.queuedUserMessages.length > queuedInputCount) {
801
+ inputReadOnly = true;
802
+ } else {
803
+ inputValue = "";
804
+ }
689
805
  return false;
690
806
  }
691
807
  if (key === "tab") {
@@ -773,7 +889,7 @@ const commandController = createCommandController({
773
889
  openOverlay,
774
890
  dismissOverlay,
775
891
  setInputValue: (value) => {
776
- if (inputValue === value) {
892
+ if (inputReadOnly || inputValue === value) {
777
893
  return;
778
894
  }
779
895
  inputValue = value;
@@ -797,6 +913,14 @@ const agentController = createUiAgentController({
797
913
  commandController.handleCommand(command, state),
798
914
  requestRender,
799
915
  scrollConversationToBottom,
916
+ clearQueuedInputDraft: () => {
917
+ if (!inputReadOnly) {
918
+ return;
919
+ }
920
+ inputReadOnly = false;
921
+ inputValue = "";
922
+ inputFocused = true;
923
+ },
800
924
  startDividerAnimation,
801
925
  stopDividerAnimation,
802
926
  });
@@ -891,7 +1015,30 @@ export function suspendToBackground(
891
1015
  // Main
892
1016
  // ---------------------------------------------------------------------------
893
1017
 
894
- function renderConversationLog(state: AppState, width: number): Node {
1018
+ function getConversationViewportHeight(
1019
+ cols: number,
1020
+ rows: number,
1021
+ divider: Node,
1022
+ input: Node,
1023
+ statusBar: Node,
1024
+ ): number {
1025
+ if (!Number.isFinite(rows)) {
1026
+ return Number.POSITIVE_INFINITY;
1027
+ }
1028
+
1029
+ const width = Math.max(1, Math.floor(cols));
1030
+ const reservedHeight =
1031
+ measureContentHeight(divider, { width }) +
1032
+ measureContentHeight(input, { width }) +
1033
+ measureContentHeight(statusBar, { width });
1034
+ return Math.max(1, Math.floor(rows) - reservedHeight);
1035
+ }
1036
+
1037
+ function renderConversationLog(
1038
+ state: AppState,
1039
+ width: number,
1040
+ viewportHeight: number,
1041
+ ): Node {
895
1042
  return VStack(
896
1043
  {
897
1044
  flex: 1,
@@ -900,23 +1047,11 @@ function renderConversationLog(state: AppState, width: number): Node {
900
1047
  justifyContent: state.messages.length === 0 ? "center" : undefined,
901
1048
  scrollOffset: stickToBottom ? Infinity : scrollOffset,
902
1049
  onScroll: (offset, maxOffset) => {
903
- const wasStickToBottom = stickToBottom;
904
1050
  scrollOffset = offset;
905
-
906
- if (wasStickToBottom && offset < maxOffset) {
907
- visibleConversationStart = getLatestConversationChunkStart(
908
- state.messages.length,
909
- );
910
- }
911
-
912
1051
  stickToBottom = offset >= maxOffset;
913
-
914
- if (!stickToBottom && offset === 0 && visibleConversationStart > 0) {
915
- prependConversationChunk(state, width);
916
- }
917
1052
  },
918
1053
  },
919
- buildConversationLog(state, width),
1054
+ buildConversationLog(state, width, viewportHeight),
920
1055
  );
921
1056
  }
922
1057
 
@@ -929,6 +1064,8 @@ function renderConversationLog(state: AppState, width: number): Node {
929
1064
  * @param state - Application state.
930
1065
  * @param cols - Current terminal width in columns.
931
1066
  * @param inputController - Stable callbacks for the controlled TextInput.
1067
+ * @param onSuspend - Optional suspend handler for Ctrl+Z.
1068
+ * @param rows - Current terminal height in rows.
932
1069
  * @returns The base layout node.
933
1070
  */
934
1071
  export function renderBaseLayout(
@@ -936,6 +1073,7 @@ export function renderBaseLayout(
936
1073
  cols: number,
937
1074
  inputController: InputController,
938
1075
  onSuspend?: () => void,
1076
+ rows = Number.POSITIVE_INFINITY,
939
1077
  ): Node {
940
1078
  titleState = state;
941
1079
  titleViewportActive = true;
@@ -943,6 +1081,17 @@ export function renderBaseLayout(
943
1081
  syncTerminalTitle(state);
944
1082
  }
945
1083
 
1084
+ const divider = renderDivider(state, cols);
1085
+ const input = renderInputArea(state.theme, inputController);
1086
+ const statusBar = renderStatusBar(state, cols);
1087
+ const conversationViewportHeight = getConversationViewportHeight(
1088
+ cols,
1089
+ rows,
1090
+ divider,
1091
+ input,
1092
+ statusBar,
1093
+ );
1094
+
946
1095
  return VStack(
947
1096
  {
948
1097
  height: "100%",
@@ -973,16 +1122,16 @@ export function renderBaseLayout(
973
1122
  },
974
1123
  [
975
1124
  // ── Conversation log ──
976
- renderConversationLog(state, cols),
1125
+ renderConversationLog(state, cols, conversationViewportHeight),
977
1126
 
978
1127
  // ── Animated divider (pulse when agent is working) ──
979
- renderDivider(state, cols),
1128
+ divider,
980
1129
 
981
1130
  // ── Input area ──
982
- renderInputArea(state.theme, inputController),
1131
+ input,
983
1132
 
984
1133
  // ── Status bar (1 line) ──
985
- renderStatusBar(state, cols),
1134
+ statusBar,
986
1135
  ],
987
1136
  );
988
1137
  }
@@ -1005,16 +1154,22 @@ export function startUI(state: AppState): void {
1005
1154
 
1006
1155
  cel.viewport(() => {
1007
1156
  const cols = terminal.columns;
1008
- const base = renderBaseLayout(state, cols, inputController, () => {
1009
- suspendToBackground(() => {
1010
- resumeTerminalUi();
1011
- cel._getBuffer()?.clear();
1012
- if (state.running) {
1013
- startDividerAnimation();
1014
- }
1015
- requestRender("immediate");
1016
- });
1017
- });
1157
+ const base = renderBaseLayout(
1158
+ state,
1159
+ cols,
1160
+ inputController,
1161
+ () => {
1162
+ suspendToBackground(() => {
1163
+ resumeTerminalUi();
1164
+ cel._getBuffer()?.clear();
1165
+ if (state.running) {
1166
+ startDividerAnimation();
1167
+ }
1168
+ requestRender("immediate");
1169
+ });
1170
+ },
1171
+ terminal.rows,
1172
+ );
1018
1173
  const overlay = renderActiveOverlay(state);
1019
1174
 
1020
1175
  if (overlay) {