mini-coder 0.5.0 → 0.5.2

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.
@@ -8,9 +8,13 @@
8
8
  * @module
9
9
  */
10
10
 
11
- import { Markdown } from "@cel-tui/components";
11
+ import {
12
+ Markdown,
13
+ SyntaxHighlight,
14
+ type SyntaxHighlightTheme,
15
+ } from "@cel-tui/components";
12
16
  import { HStack, measureContentHeight, Text, VStack } from "@cel-tui/core";
13
- import type { Node } from "@cel-tui/types";
17
+ import type { Color, Node } from "@cel-tui/types";
14
18
  import type {
15
19
  AssistantMessage,
16
20
  TextContent,
@@ -20,6 +24,7 @@ import type {
20
24
  import type { AppState } from "../index.ts";
21
25
  import type { UiMessage } from "../session.ts";
22
26
  import type { Theme } from "../theme.ts";
27
+ import { APP_NAME, DEV_VERSION_LABEL } from "../version.ts";
23
28
 
24
29
  /** Single blank-line gap used between conversation-level blocks. */
25
30
  export const CONVERSATION_GAP = 1;
@@ -33,6 +38,26 @@ const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
33
38
  /** Horizontal columns consumed by tool-block padding and the left border. */
34
39
  const TOOL_BLOCK_CHROME_WIDTH = 4;
35
40
 
41
+ /** ANSI16 fallback hex values for syntax-highlighter theme overrides. */
42
+ const ANSI_COLOR_HEX: Readonly<Record<Color, string>> = {
43
+ color00: "#000000",
44
+ color01: "#cd3131",
45
+ color02: "#0dbc79",
46
+ color03: "#e5e510",
47
+ color04: "#2472c8",
48
+ color05: "#bc3fbc",
49
+ color06: "#11a8cd",
50
+ color07: "#e5e5e5",
51
+ color08: "#666666",
52
+ color09: "#f14c4c",
53
+ color10: "#23d18b",
54
+ color11: "#f5f543",
55
+ color12: "#3b8eea",
56
+ color13: "#d670d6",
57
+ color14: "#29b8db",
58
+ color15: "#ffffff",
59
+ };
60
+
36
61
  /** A pending tool result shown in the streaming tail. */
37
62
  export interface PendingToolResult {
38
63
  /** Tool call id from the assistant message. */
@@ -77,6 +102,13 @@ interface AssistantRenderState {
77
102
 
78
103
  type ConversationMessage = AppState["messages"][number];
79
104
 
105
+ type ConversationLogState = Pick<
106
+ AppState,
107
+ "messages" | "showReasoning" | "verbose" | "theme"
108
+ > & {
109
+ versionLabel?: AppState["versionLabel"];
110
+ };
111
+
80
112
  type ToolCallRenderInfo = {
81
113
  name: string;
82
114
  args: Record<string, unknown>;
@@ -110,6 +142,13 @@ type ToolRenderLineKind =
110
142
  | "summary"
111
143
  | "error";
112
144
 
145
+ interface HighlightedToolBodySpec {
146
+ /** Full raw source content to syntax-highlight. */
147
+ text: string;
148
+ /** Registered lextide language id. */
149
+ language: string;
150
+ }
151
+
113
152
  interface ToolBlockSpec {
114
153
  /** Tool name shown in the header pill. */
115
154
  toolName: string;
@@ -117,6 +156,8 @@ interface ToolBlockSpec {
117
156
  direction: ToolRenderDirection;
118
157
  /** Logical body lines for the tool block. */
119
158
  bodyLines: readonly ToolRenderLine[];
159
+ /** Optional syntax-highlighted body rendered from the full unsplit source. */
160
+ highlightedBody?: HighlightedToolBodySpec;
120
161
  /** Whether `/verbose` preview rules apply to the body. */
121
162
  previewBody: boolean;
122
163
  }
@@ -339,6 +380,159 @@ function getToolHeaderColor(toolName: string, theme: Theme) {
339
380
  }
340
381
  }
341
382
 
383
+ type SyntaxThemeRegistration = Exclude<SyntaxHighlightTheme, string>;
384
+ type SyntaxThemeTokenColor = NonNullable<
385
+ SyntaxThemeRegistration["tokenColors"]
386
+ >[number];
387
+
388
+ const graphemeSegmenter = new Intl.Segmenter(undefined, {
389
+ granularity: "grapheme",
390
+ });
391
+ const shellSyntaxThemeCache = new WeakMap<Theme, SyntaxThemeRegistration>();
392
+
393
+ function colorToHex(color: Color | undefined): string | undefined {
394
+ return color ? ANSI_COLOR_HEX[color] : undefined;
395
+ }
396
+
397
+ function pushSyntaxTokenColor(
398
+ tokenColors: SyntaxThemeTokenColor[],
399
+ scope: string | readonly string[],
400
+ foreground: Color | undefined,
401
+ fontStyle?: string,
402
+ ): void {
403
+ const foregroundHex = colorToHex(foreground);
404
+ if (!foregroundHex && !fontStyle) {
405
+ return;
406
+ }
407
+
408
+ tokenColors.push({
409
+ scope,
410
+ settings: {
411
+ ...(foregroundHex ? { foreground: foregroundHex } : {}),
412
+ ...(fontStyle ? { fontStyle } : {}),
413
+ },
414
+ });
415
+ }
416
+
417
+ function getShellSyntaxTheme(theme: Theme): SyntaxThemeRegistration {
418
+ const cached = shellSyntaxThemeCache.get(theme);
419
+ if (cached) {
420
+ return cached;
421
+ }
422
+
423
+ const tokenColors: SyntaxThemeTokenColor[] = [];
424
+ pushSyntaxTokenColor(tokenColors, ["comment", "quote"], theme.mutedText);
425
+ pushSyntaxTokenColor(
426
+ tokenColors,
427
+ ["keyword", "storage"],
428
+ theme.secondaryAccentText,
429
+ );
430
+ pushSyntaxTokenColor(
431
+ tokenColors,
432
+ [
433
+ "entity.name.function",
434
+ "function",
435
+ "meta.function-call",
436
+ "support.function",
437
+ "title",
438
+ ],
439
+ theme.accentText,
440
+ );
441
+ pushSyntaxTokenColor(
442
+ tokenColors,
443
+ [
444
+ "built_in",
445
+ "class",
446
+ "entity.name.type",
447
+ "entity.other.inherited-class",
448
+ "support.class",
449
+ "support.type",
450
+ "type",
451
+ ],
452
+ theme.accentText,
453
+ );
454
+ pushSyntaxTokenColor(
455
+ tokenColors,
456
+ ["constant.language", "constant.numeric", "literal", "symbol"],
457
+ theme.secondaryAccentText ?? theme.accentText,
458
+ );
459
+ pushSyntaxTokenColor(
460
+ tokenColors,
461
+ ["markup.inline", "string"],
462
+ theme.diffAdded,
463
+ );
464
+ pushSyntaxTokenColor(tokenColors, "regexp", theme.diffRemoved);
465
+ pushSyntaxTokenColor(
466
+ tokenColors,
467
+ ["attr", "attribute", "entity.other.attribute-name", "property"],
468
+ theme.accentText,
469
+ );
470
+ pushSyntaxTokenColor(
471
+ tokenColors,
472
+ ["entity.name.tag", "name", "tag"],
473
+ theme.accentText,
474
+ );
475
+
476
+ const syntaxTheme: SyntaxThemeRegistration = {
477
+ ...(colorToHex(theme.toolText) ? { fg: colorToHex(theme.toolText) } : {}),
478
+ tokenColors,
479
+ };
480
+ shellSyntaxThemeCache.set(theme, syntaxTheme);
481
+ return syntaxTheme;
482
+ }
483
+
484
+ function splitHighlightedTextNode(
485
+ node: Extract<Node, { type: "text" }>,
486
+ ): Node[] {
487
+ if (node.content === "") {
488
+ return [Text("", node.props)];
489
+ }
490
+
491
+ const parts = node.content.match(/\s+|\S+/gu);
492
+ if (!parts) {
493
+ return [Text(node.content, node.props)];
494
+ }
495
+
496
+ const children: Node[] = [];
497
+ for (const part of parts) {
498
+ if (/^\s+$/u.test(part)) {
499
+ children.push(Text(part, node.props));
500
+ continue;
501
+ }
502
+
503
+ for (const { segment } of graphemeSegmenter.segment(part)) {
504
+ children.push(Text(segment, node.props));
505
+ }
506
+ }
507
+
508
+ return children;
509
+ }
510
+
511
+ function normalizeHighlightedToolLine(line: Node): Node {
512
+ if (line.type !== "hstack") {
513
+ return line;
514
+ }
515
+
516
+ const children = line.children.flatMap((child) => {
517
+ return child.type === "text" ? splitHighlightedTextNode(child) : [child];
518
+ });
519
+ return HStack(line.props, children);
520
+ }
521
+
522
+ function getHighlightedToolBodyLines(
523
+ spec: HighlightedToolBodySpec,
524
+ theme: Theme,
525
+ ): Node[] {
526
+ if (spec.text === "") {
527
+ return [];
528
+ }
529
+
530
+ const highlighted = SyntaxHighlight(spec.text, spec.language, {
531
+ theme: getShellSyntaxTheme(theme),
532
+ });
533
+ return highlighted.children.map((line) => normalizeHighlightedToolLine(line));
534
+ }
535
+
342
536
  /** Render a single styled text node for a tool line. */
343
537
  function renderToolLine(line: ToolRenderLine, theme: Theme): Node {
344
538
  switch (line.kind) {
@@ -374,41 +568,28 @@ function renderToolLines(
374
568
  return lines.map((line) => renderToolLine(line, theme));
375
569
  }
376
570
 
377
- function measureToolLinesHeight(
378
- lines: readonly ToolRenderLine[],
379
- width: number,
380
- theme: Theme,
381
- ): number {
571
+ function measureToolNodesHeight(lines: readonly Node[], width: number): number {
382
572
  if (lines.length === 0) {
383
573
  return 0;
384
574
  }
385
575
 
386
- return measureContentHeight(VStack({}, renderToolLines(lines, theme)), {
387
- width,
388
- });
576
+ return measureContentHeight(VStack({}, [...lines]), { width });
389
577
  }
390
578
 
391
- function measureToolLineHeight(
392
- line: ToolRenderLine,
393
- width: number,
394
- theme: Theme,
395
- ): number {
396
- return measureContentHeight(VStack({}, [renderToolLine(line, theme)]), {
397
- width,
398
- });
579
+ function measureToolNodeHeight(line: Node, width: number): number {
580
+ return measureContentHeight(VStack({}, [line]), { width });
399
581
  }
400
582
 
401
- function countVisibleTailLines(
402
- lines: readonly ToolRenderLine[],
583
+ function countVisibleTailNodes(
584
+ lines: readonly Node[],
403
585
  width: number,
404
- theme: Theme,
405
586
  maxRows: number,
406
587
  ): number {
407
588
  let remainingRows = maxRows;
408
589
  let visibleLineCount = 0;
409
590
 
410
591
  for (let index = lines.length - 1; index >= 0; index--) {
411
- const lineHeight = measureToolLineHeight(lines[index]!, width, theme);
592
+ const lineHeight = measureToolNodeHeight(lines[index]!, width);
412
593
  if (lineHeight > remainingRows) {
413
594
  return visibleLineCount > 0 ? visibleLineCount : 1;
414
595
  }
@@ -432,8 +613,8 @@ function renderToolHeaderPill(
432
613
  ]);
433
614
  }
434
615
 
435
- function renderToolBody(
436
- lines: readonly ToolRenderLine[],
616
+ function renderToolBodyFromNodes(
617
+ lines: readonly Node[],
437
618
  previewBody: boolean,
438
619
  opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
439
620
  ): { body: Node | null; summary?: ToolRenderLine } {
@@ -441,21 +622,19 @@ function renderToolBody(
441
622
  return { body: null };
442
623
  }
443
624
 
444
- const renderedLines = renderToolLines(lines, opts.theme);
445
625
  if (opts.verbose || !previewBody) {
446
- return { body: VStack({}, renderedLines) };
626
+ return { body: VStack({}, [...lines]) };
447
627
  }
448
628
 
449
629
  const bodyWidth = getToolBodyWidth(opts.previewWidth);
450
- const totalHeight = measureToolLinesHeight(lines, bodyWidth, opts.theme);
630
+ const totalHeight = measureToolNodesHeight(lines, bodyWidth);
451
631
  if (totalHeight <= UI_TOOL_PREVIEW_ROWS) {
452
- return { body: VStack({}, renderedLines) };
632
+ return { body: VStack({}, [...lines]) };
453
633
  }
454
634
 
455
- const visibleLineCount = countVisibleTailLines(
635
+ const visibleLineCount = countVisibleTailNodes(
456
636
  lines,
457
637
  bodyWidth,
458
- opts.theme,
459
638
  UI_TOOL_PREVIEW_ROWS,
460
639
  );
461
640
  const previewLines = lines.slice(-visibleLineCount);
@@ -466,7 +645,7 @@ function renderToolBody(
466
645
  {
467
646
  height: UI_TOOL_PREVIEW_ROWS,
468
647
  },
469
- renderToolLines(previewLines, opts.theme),
648
+ [...previewLines],
470
649
  ),
471
650
  summary:
472
651
  hiddenLineCount > 0
@@ -478,12 +657,31 @@ function renderToolBody(
478
657
  };
479
658
  }
480
659
 
660
+ function renderToolBody(
661
+ spec: ToolBlockSpec,
662
+ opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
663
+ ): { body: Node | null; summary?: ToolRenderLine } {
664
+ if (spec.highlightedBody) {
665
+ return renderToolBodyFromNodes(
666
+ getHighlightedToolBodyLines(spec.highlightedBody, opts.theme),
667
+ spec.previewBody,
668
+ opts,
669
+ );
670
+ }
671
+
672
+ return renderToolBodyFromNodes(
673
+ renderToolLines(spec.bodyLines, opts.theme),
674
+ spec.previewBody,
675
+ opts,
676
+ );
677
+ }
678
+
481
679
  /** Render a tool block with a left border and compact header pill. */
482
680
  function renderToolBlock(
483
681
  spec: ToolBlockSpec,
484
682
  opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
485
683
  ): Node {
486
- const body = renderToolBody(spec.bodyLines, spec.previewBody, opts);
684
+ const body = renderToolBody(spec, opts);
487
685
  const children: Node[] = [
488
686
  HStack({}, [
489
687
  renderToolHeaderPill(spec.toolName, spec.direction, opts.theme),
@@ -511,10 +709,18 @@ function getToolContentText(content: ToolResultMessage["content"]): string {
511
709
  }
512
710
 
513
711
  function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
712
+ const command = getToolArgString(args, "command");
514
713
  return {
515
714
  toolName: "shell",
516
715
  direction: "->",
517
- bodyLines: splitToolTextLines(getToolArgString(args, "command"), "command"),
716
+ bodyLines: splitToolTextLines(command, "command"),
717
+ highlightedBody:
718
+ command === ""
719
+ ? undefined
720
+ : {
721
+ text: command,
722
+ language: "bash",
723
+ },
518
724
  previewBody: true,
519
725
  };
520
726
  }
@@ -728,6 +934,23 @@ function renderUiMessage(msg: UiMessage, theme: Theme): Node {
728
934
  ]);
729
935
  }
730
936
 
937
+ function renderEmptyConversationBanner(
938
+ theme: Theme,
939
+ versionLabel: string,
940
+ ): Node {
941
+ return HStack({ justifyContent: "center" }, [
942
+ VStack({ padding: { x: 1 } }, [
943
+ Text(APP_NAME, {
944
+ bold: true,
945
+ fgColor: theme.accentText,
946
+ }),
947
+ Text(versionLabel, {
948
+ fgColor: theme.mutedText,
949
+ }),
950
+ ]),
951
+ ]);
952
+ }
953
+
731
954
  function pushConversationNode(nodes: Node[], node: Node | null): void {
732
955
  if (!node) {
733
956
  return;
@@ -808,7 +1031,7 @@ function cacheToolCallArgs(messages: readonly ConversationMessage[]): void {
808
1031
  }
809
1032
 
810
1033
  function canReuseCommittedConversationCache(
811
- state: Pick<AppState, "messages" | "showReasoning" | "verbose" | "theme">,
1034
+ state: ConversationLogState,
812
1035
  startIndex: number,
813
1036
  previewWidth: number,
814
1037
  ): boolean {
@@ -824,7 +1047,7 @@ function canReuseCommittedConversationCache(
824
1047
  }
825
1048
 
826
1049
  function cacheCommittedConversation(
827
- state: Pick<AppState, "messages" | "showReasoning" | "verbose" | "theme">,
1050
+ state: ConversationLogState,
828
1051
  renderOpts: ConversationRenderOpts,
829
1052
  startIndex: number,
830
1053
  ): void {
@@ -879,7 +1102,7 @@ function hasStreamingTail(streaming: StreamingConversationState): boolean {
879
1102
  * @returns The rendered conversation log nodes.
880
1103
  */
881
1104
  export function buildConversationLogNodes(
882
- state: Pick<AppState, "messages" | "showReasoning" | "verbose" | "theme">,
1105
+ state: ConversationLogState,
883
1106
  streaming: StreamingConversationState,
884
1107
  startIndex = 0,
885
1108
  previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH,
@@ -891,6 +1114,15 @@ export function buildConversationLogNodes(
891
1114
  previewWidth,
892
1115
  };
893
1116
 
1117
+ if (state.messages.length === 0 && !hasStreamingTail(streaming)) {
1118
+ return [
1119
+ renderEmptyConversationBanner(
1120
+ state.theme,
1121
+ state.versionLabel ?? DEV_VERSION_LABEL,
1122
+ ),
1123
+ ];
1124
+ }
1125
+
894
1126
  cacheCommittedConversation(state, renderOpts, startIndex);
895
1127
 
896
1128
  if (!hasStreamingTail(streaming)) {
@@ -23,4 +23,22 @@ describe("ui/help", () => {
23
23
  "/verbose Toggle verbose tool rendering (currently off)",
24
24
  );
25
25
  });
26
+
27
+ test("buildHelpText includes the current Escape input-focus note", () => {
28
+ const helpState: HelpRenderState = {
29
+ providers: new Map(),
30
+ model: null,
31
+ agentsMd: [],
32
+ skills: [],
33
+ plugins: [],
34
+ showReasoning: DEFAULT_SHOW_REASONING,
35
+ verbose: false,
36
+ };
37
+
38
+ const text = buildHelpText(helpState);
39
+
40
+ expect(text).toContain("Escape blurs the input first");
41
+ expect(text).toContain("Tab re-focuses the input");
42
+ expect(text).toContain("Escape again interrupts the current turn");
43
+ });
26
44
  });
package/src/ui/help.ts CHANGED
@@ -77,6 +77,12 @@ export function buildHelpText(state: HelpRenderState): string {
77
77
  }
78
78
 
79
79
  const providerNames = Array.from(state.providers.keys());
80
+ lines.push("");
81
+ lines.push("Note:");
82
+ lines.push(" While a turn is running, Escape blurs the input first.");
83
+ lines.push(" Tab re-focuses the input.");
84
+ lines.push(" Escape again interrupts the current turn.");
85
+
80
86
  lines.push("");
81
87
  lines.push(
82
88
  providerNames.length > 0
@@ -53,7 +53,11 @@ describe("ui/input", () => {
53
53
  if (!placeholder || placeholder.type !== "text") {
54
54
  throw new Error("Expected a text placeholder");
55
55
  }
56
- expect(placeholder.content).toBe("message…");
56
+ expect(placeholder.content).toBe(
57
+ "`Ctrl+R` for input history, `/` + `Tab` for interactive menu, or type a message…",
58
+ );
59
+ expect(placeholder.props.fgColor).toBe(DEFAULT_THEME.mutedText);
60
+ expect(placeholder.props.italic).toBe(true);
57
61
  });
58
62
 
59
63
  test("autocompleteInputPath completes the last file path token", () => {
package/src/ui/input.ts CHANGED
@@ -10,6 +10,9 @@ import { Text, TextInput } from "@cel-tui/core";
10
10
  import type { Node } from "@cel-tui/types";
11
11
  import type { Theme } from "../theme.ts";
12
12
 
13
+ const INPUT_PLACEHOLDER =
14
+ "`Ctrl+R` for input history, `/` + `Tab` for interactive menu, or type a message…";
15
+
13
16
  /** Stable callbacks for the controlled TextInput. */
14
17
  export interface InputController {
15
18
  /** Update the controlled input value and re-render. */
@@ -129,7 +132,10 @@ export function renderInputArea(
129
132
  padding: { x: 1 },
130
133
  value,
131
134
  onChange: controller.onChange,
132
- placeholder: Text("message…", { fgColor: theme.mutedText }),
135
+ placeholder: Text(INPUT_PLACEHOLDER, {
136
+ fgColor: theme.mutedText,
137
+ italic: true,
138
+ }),
133
139
  focused,
134
140
  onFocus: controller.onFocus,
135
141
  onBlur: controller.onBlur,
@@ -103,6 +103,9 @@ function createTestState(): AppState {
103
103
  activeTurnPromise: null,
104
104
  showReasoning: DEFAULT_SHOW_REASONING,
105
105
  verbose: DEFAULT_VERBOSE,
106
+ versionLabel: "dev",
107
+ customModels: [],
108
+ startupWarnings: [],
106
109
  };
107
110
  }
108
111
 
@@ -137,6 +140,7 @@ describe("ui/status", () => {
137
140
  state.git = {
138
141
  root: state.cwd,
139
142
  branch: "main",
143
+ upstream: "origin/main",
140
144
  staged: 1,
141
145
  modified: 2,
142
146
  untracked: 3,