mini-coder 0.5.3 → 0.5.4

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/README.md CHANGED
@@ -59,32 +59,34 @@ Plugins can add more tools, but the core stays intentionally small.
59
59
 
60
60
  ## Commands
61
61
 
62
- | Command | Description |
63
- | ------------ | ---------------------------------------------------------------------- |
64
- | `/model` | Switch models. Persists as the global default. |
65
- | `/session` | List and resume sessions scoped to the current working directory. |
66
- | `/new` | Start a fresh session and reset cumulative usage counters. |
67
- | `/fork` | Copy the conversation into a new session and continue independently. |
68
- | `/undo` | Remove the last conversational turn (does not revert file changes). |
69
- | `/reasoning` | Toggle thinking visibility. Persisted and restored on launch. |
70
- | `/verbose` | Toggle verbose shell rendering plus edit previews/errors in the log. |
71
- | `/login` | Interactive OAuth login for supported providers. |
72
- | `/logout` | Clear saved OAuth credentials for a logged-in provider. |
73
- | `/effort` | Set reasoning effort: low, medium, high, or xhigh. |
74
- | `/help` | List commands, loaded AGENTS.md files, discovered skills, and plugins. |
62
+ | Command | Description |
63
+ | ------------ | ------------------------------------------------------------------------------------------------------ |
64
+ | `/model` | Switch models and save the choice as the global default. |
65
+ | `/session` | Open the session picker for the current working directory. |
66
+ | `/new` | Start a fresh session and reset the running token and cost totals. |
67
+ | `/fork` | Fork the current chat into a new session, keep the original, and add a UI-only `Forked session.` note. |
68
+ | `/undo` | Remove the last conversational turn without touching filesystem changes. |
69
+ | `/reasoning` | Show or hide model thinking. The setting is saved and restored on launch. |
70
+ | `/verbose` | Expand shell output plus edit previews and edit errors in the conversation log. |
71
+ | `/login` | Sign in with a supported OAuth provider. |
72
+ | `/logout` | Remove saved OAuth credentials for a logged-in provider. |
73
+ | `/effort` | Choose low, medium, high, or xhigh reasoning effort. |
74
+ | `/help` | Show commands, current toggles, loaded AGENTS.md files, skills, and plugins. |
75
75
 
76
76
  ## Key bindings
77
77
 
78
- | Key | Action |
79
- | ------------- | ------------------------------------------------- |
80
- | `Enter` | Submit message |
81
- | `Shift+Enter` | Insert newline |
82
- | `Escape` | Interrupt current turn, preserve partial response |
83
- | `Tab` | File path autocomplete (or command filter on `/`) |
84
- | `Ctrl+R` | Search global raw input history |
85
- | `Ctrl+C` | Graceful exit |
86
- | `Ctrl+D` | Graceful exit (EOF, when input is empty) |
87
- | `:q` | Graceful exit |
78
+ | Key | Action |
79
+ | ------------- | ---------------------------------------------------------------------------------------------------------- |
80
+ | `Enter` | Submit message |
81
+ | `Shift+Enter` | Insert newline |
82
+ | `Escape` | Dismiss the overlay without changing the draft; otherwise interrupt the running turn; otherwise do nothing |
83
+ | `Tab` | Autocomplete a path, or open the command picker when the draft starts with `/` |
84
+ | `Ctrl+R` | Search global raw input history |
85
+ | `Ctrl+C` | Graceful exit |
86
+ | `Ctrl+D` | Graceful exit when the input is empty |
87
+ | `:q` | Graceful exit |
88
+ | `Ctrl+Z` | Suspend the process |
89
+ | Mouse wheel | Scroll conversation history |
88
90
 
89
91
  ## Headless one-shot mode
90
92
 
@@ -95,10 +97,12 @@ $ mc -p "summarize this repo"
95
97
  $ printf '%s\n' 'fix the failing tests' | mc
96
98
  ```
97
99
 
98
- - Enabled when `-p/--prompt` is provided or when stdin/stdout is not a TTY.
99
- - Reuses the same input parsing rules as the interactive UI for plain text, `/skill:name`, and standalone image paths.
100
- - Streams newline-delimited JSON events to stdout.
101
- - Interactive slash commands such as `/model` and `/help` are not available in headless mode.
100
+ - Starts when `-p/--prompt` is provided or when stdin or stdout is not a TTY.
101
+ - If stdout is redirected but stdin is still interactive, pass `-p`; headless mode will not fall back to an interactive prompt.
102
+ - Uses the same parser as the TUI for plain text, `/skill:name`, and standalone image paths.
103
+ - Writes raw NDJSON events to stdout for text deltas, tool activity, final messages, and `done` / `error` / `aborted` outcomes.
104
+ - Headless runs still persist like normal sessions and show up in `/session` history for that working directory.
105
+ - Interactive slash commands such as `/model`, `/session`, and `/help` are not available in headless mode.
102
106
 
103
107
  ## Docs
104
108
 
package/bun.lock CHANGED
@@ -5,9 +5,9 @@
5
5
  "": {
6
6
  "name": "mini-coder",
7
7
  "dependencies": {
8
- "@cel-tui/components": "^0.6.2",
9
- "@cel-tui/core": "^0.6.2",
10
- "@cel-tui/types": "^0.6.2",
8
+ "@cel-tui/components": "0.7.2",
9
+ "@cel-tui/core": "0.7.2",
10
+ "@cel-tui/types": "0.7.2",
11
11
  "@mariozechner/pi-ai": "^0.66.1",
12
12
  },
13
13
  "devDependencies": {
@@ -108,11 +108,11 @@
108
108
 
109
109
  "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="],
110
110
 
111
- "@cel-tui/components": ["@cel-tui/components@0.6.2", "", { "dependencies": { "@cel-tui/core": "0.6.2", "@cel-tui/types": "0.6.2", "lextide": "^0.1.0", "yoctomarkdown": "^0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-FYR7lX3/gPAyEkfMuyVapdxfO0e9OIw4lmdIUF2Tw7QszkpV4MAkEoB3G2oUY6UZyxJCSCKLBPvLzwu5uxaDgw=="],
111
+ "@cel-tui/components": ["@cel-tui/components@0.7.2", "", { "dependencies": { "@cel-tui/core": "0.7.2", "@cel-tui/types": "0.7.2", "lextide": "^0.2.1", "yoctomarkdown": "^0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-RHq7G/lS6A733l534cSStQp+cJECkxZZWrLghbPHwGBsSeDdJ2MkrQm+02roh6EH/N40ZAYjQqplq8acRrnFFQ=="],
112
112
 
113
- "@cel-tui/core": ["@cel-tui/core@0.6.2", "", { "dependencies": { "@cel-tui/types": "0.6.2", "get-east-asian-width": "^1.5.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-KJQdDMPbwLE211Hvn75jUKEvHiKTtQ7iVGJiiOpG+s4DjhnpySBXWO0hReMxe5NR2NPls7wmze1dBZ8/MXqVXA=="],
113
+ "@cel-tui/core": ["@cel-tui/core@0.7.2", "", { "dependencies": { "@cel-tui/types": "0.7.2", "get-east-asian-width": "^1.5.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-Y5qdI9Zoa77Olq/LD+vqf3j0hyCRJYkkTkIdUfQplSDSUJS5NM+VSnxcQ8RCUNmrIsHeLmmAPHYsBsS1WMWk1w=="],
114
114
 
115
- "@cel-tui/types": ["@cel-tui/types@0.6.2", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-JsJdo20KmgtC67QlG/ZrZyGi0xNJBKd3G6kM/yN/6/hk8a86P+1k1jqtSUjcZ4E9x1v9IXqPk6QelU7kLyOV1A=="],
115
+ "@cel-tui/types": ["@cel-tui/types@0.7.2", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-XEmYkGqtBMOXI/DKLopv5OL7d5/8ybkj1OpJO4weWPyx4Y6VY9pYFaj8zYhVaFSQZcB23/yoA+DPYi0kOgoZhQ=="],
116
116
 
117
117
  "@google/genai": ["@google/genai@1.48.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ=="],
118
118
 
@@ -342,7 +342,7 @@
342
342
 
343
343
  "lefthook-windows-x64": ["lefthook-windows-x64@2.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-yiAh8qxml6uqy10jDxOdN9fOQpyLxBFY1fgCEAhn7sVJYmJKRhjqSBwZX6LG5MQjzr29KStrIdw7TR3lf3rT7Q=="],
344
344
 
345
- "lextide": ["lextide@0.1.0", "", {}, "sha512-i1fx9SzultDOIgVtFt/vBOeyFaGYCNLT6OcdqOLRfWL9p6aTQsq8JQ+8LpJ4T7znWNjfjLqUU6dK5g2kXhSq3A=="],
345
+ "lextide": ["lextide@0.2.1", "", {}, "sha512-fP7liS0LoNJz2EF6G7+K21yvB58bJEjaTrsDcPi4Qv7xZynB9NzjY44N6tFK6SJaECHIUqF2kcWbWQ84hPfmlg=="],
346
346
 
347
347
  "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
348
348
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -18,9 +18,9 @@
18
18
  "test": "bun test"
19
19
  },
20
20
  "dependencies": {
21
- "@cel-tui/components": "^0.6.2",
22
- "@cel-tui/core": "^0.6.2",
23
- "@cel-tui/types": "^0.6.2",
21
+ "@cel-tui/components": "^0.7.2",
22
+ "@cel-tui/core": "^0.7.2",
23
+ "@cel-tui/types": "^0.7.2",
24
24
  "@mariozechner/pi-ai": "^0.66.1"
25
25
  },
26
26
  "devDependencies": {
package/src/prompt.ts CHANGED
@@ -225,6 +225,7 @@ Workflow: **inspect with shell → mutate with edit → verify with shell**.
225
225
 
226
226
  - Think first: before any tool call, decide all files and information you need.
227
227
  - Early in the task, look for acceptance criteria in tests, verifier scripts, eval scripts, examples, and expected-output files. Do not rely on the task text alone when machine-checkable criteria are available.
228
+ - Before relying on a binary, interpreter, or CLI helper you have not yet confirmed, probe availability with focused checks such as \`command -v <tool>\`, \`<tool> --version\`, or \`python3 -m pip --version\`, then adapt to what is actually installed.
228
229
  - Batch reads: if you need multiple files, read them together in parallel rather than one at a time.
229
230
  - Only make sequential calls when a later call genuinely depends on an earlier result.
230
231
 
@@ -233,13 +234,15 @@ Workflow: **inspect with shell → mutate with edit → verify with shell**.
233
234
  - Be concise. Friendly coding teammate tone.
234
235
  - After making changes: lead with a quick explanation of what changed and why, then suggest logical next steps if any.
235
236
  - Don't dump large file contents you've written — reference file paths.
236
- - When suggesting multiple options, use numbered lists so the user can reply with a number.
237
+ - When discussing multiple options, use numbered lists so the user can reply with a number without turning it into a questionnaire.
237
238
  - If asked for a review, focus on bugs, risks, regressions, and missing tests. Findings first, ordered by severity.
238
239
 
239
240
  # Persistence
240
241
 
241
242
  - Carry work through to completion within the current turn. Don't stop at analysis or partial fixes.
242
243
  - Once the contract is clear, create the required artifact early, then iterate and improve it. Do not spend most of the turn exploring.
244
+ - Run the narrowest verifier or test as soon as there is a plausible first implementation or artifact. Do not delay the first verification run until the end.
245
+ - When verification is down to a small number of failures, stop broad exploration and focus only on the remaining failing assertions or exact contract gaps until the last detail passes.
243
246
  - If you encounter an error, diagnose and fix it rather than reporting it and stopping.
244
247
  - Before concluding, run the smallest targeted verification that checks the exact contract: required files exist, names and signatures match, outputs are in the required format, and no forbidden extra artifacts were left behind.
245
248
  - Avoid excessive looping: if you're re-reading or re-editing the same files without progress, stop and ask the user.`;
package/src/session.ts CHANGED
@@ -160,6 +160,8 @@ const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
160
160
  },
161
161
  };
162
162
 
163
+ const SQLITE_BUSY_TIMEOUT_MS = 1_000;
164
+
163
165
  /** Row shape returned by `SELECT * FROM prompt_history`. */
164
166
  type PromptHistoryRow = {
165
167
  id: number;
@@ -253,6 +255,7 @@ CREATE INDEX IF NOT EXISTS idx_prompt_history_created_at ON prompt_history(creat
253
255
  export function openDatabase(path: string): Database {
254
256
  const db = new Database(path);
255
257
  db.run("PRAGMA journal_mode = WAL");
258
+ db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
256
259
  db.run("PRAGMA foreign_keys = ON");
257
260
  db.exec(SCHEMA);
258
261
  return db;
@@ -709,6 +712,31 @@ export function getAssistantUsage(
709
712
  };
710
713
  }
711
714
 
715
+ function runInImmediateTransaction<T>(db: Database, callback: () => T): T {
716
+ if (db.inTransaction) {
717
+ return callback();
718
+ }
719
+
720
+ db.run("BEGIN IMMEDIATE");
721
+ try {
722
+ const result = callback();
723
+ db.run("COMMIT");
724
+ return result;
725
+ } catch (error) {
726
+ if (db.inTransaction) {
727
+ try {
728
+ db.run("ROLLBACK");
729
+ } catch (rollbackError) {
730
+ throw new AggregateError(
731
+ [error, rollbackError],
732
+ "Failed to roll back SQLite transaction",
733
+ );
734
+ }
735
+ }
736
+ throw error;
737
+ }
738
+ }
739
+
712
740
  /**
713
741
  * Append a UI-only message to a session's history.
714
742
  *
@@ -790,25 +818,27 @@ export function appendMessage(
790
818
  message: PersistedMessage,
791
819
  turn?: number,
792
820
  ): number | null {
793
- const now = Date.now();
794
-
795
- let effectiveTurn: number | null;
796
- if (isUiMessage(message)) {
797
- effectiveTurn = null;
798
- } else if (turn !== undefined) {
799
- effectiveTurn = turn;
800
- } else {
801
- const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
802
- effectiveTurn = (row?.max_turn ?? 0) + 1;
803
- }
821
+ return runInImmediateTransaction(db, () => {
822
+ const now = Date.now();
823
+
824
+ let effectiveTurn: number | null;
825
+ if (isUiMessage(message)) {
826
+ effectiveTurn = null;
827
+ } else if (turn !== undefined) {
828
+ effectiveTurn = turn;
829
+ } else {
830
+ const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
831
+ effectiveTurn = (row?.max_turn ?? 0) + 1;
832
+ }
804
833
 
805
- db.run(
806
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
807
- [sessionId, effectiveTurn, JSON.stringify(message), now],
808
- );
809
- db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, sessionId]);
834
+ db.run(
835
+ "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
836
+ [sessionId, effectiveTurn, JSON.stringify(message), now],
837
+ );
838
+ db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, sessionId]);
810
839
 
811
- return effectiveTurn;
840
+ return effectiveTurn;
841
+ });
812
842
  }
813
843
 
814
844
  /**
@@ -64,7 +64,15 @@ async function renderBufferRows(
64
64
  node: Node | null,
65
65
  cols = PREVIEW_WIDTH,
66
66
  rows = 24,
67
- ): Promise<Array<{ text: string; fgColors: Array<string | null> }>> {
67
+ ): Promise<
68
+ Array<{
69
+ text: string;
70
+ fgColors: Array<string | null>;
71
+ bold: boolean[];
72
+ italic: boolean[];
73
+ underline: boolean[];
74
+ }>
75
+ > {
68
76
  if (!node) {
69
77
  return [];
70
78
  }
@@ -79,16 +87,28 @@ async function renderBufferRows(
79
87
  throw new Error("Expected cel-tui to produce a render buffer");
80
88
  }
81
89
 
82
- const snapshot: Array<{ text: string; fgColors: Array<string | null> }> = [];
90
+ const snapshot: Array<{
91
+ text: string;
92
+ fgColors: Array<string | null>;
93
+ bold: boolean[];
94
+ italic: boolean[];
95
+ underline: boolean[];
96
+ }> = [];
83
97
  for (let y = 0; y < rows; y++) {
84
98
  let text = "";
85
99
  const fgColors: Array<string | null> = [];
100
+ const bold: boolean[] = [];
101
+ const italic: boolean[] = [];
102
+ const underline: boolean[] = [];
86
103
  for (let x = 0; x < cols; x++) {
87
104
  const cell = buffer.get(x, y);
88
105
  text += cell.char;
89
106
  fgColors.push(cell.fgColor);
107
+ bold.push(cell.bold);
108
+ italic.push(cell.italic);
109
+ underline.push(cell.underline);
90
110
  }
91
- snapshot.push({ text, fgColors });
111
+ snapshot.push({ text, fgColors, bold, italic, underline });
92
112
  }
93
113
 
94
114
  cel.stop();
@@ -119,18 +139,20 @@ afterEach(() => {
119
139
  });
120
140
 
121
141
  describe("ui/conversation", () => {
122
- test("renderAssistantMessage preserves visible paragraph order in streamed markdown", () => {
142
+ test("renderAssistantMessage keeps raw markdown markers visible in assistant text", () => {
123
143
  // Arrange
124
- const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
144
+ const message = fauxAssistantMessage(
145
+ "# Heading\n\nUse **bold** and `code`.\n- item",
146
+ );
125
147
 
126
148
  // Act
127
149
  const text = collectText(renderAssistantMessage(message, RENDER_OPTS));
128
- const firstParagraphIndex = text.indexOf("First paragraph");
129
- const secondParagraphIndex = text.indexOf("Second paragraph");
130
150
 
131
151
  // Assert
132
- expect(firstParagraphIndex).toBeGreaterThanOrEqual(0);
133
- expect(secondParagraphIndex).toBeGreaterThan(firstParagraphIndex);
152
+ expect(text).toContain("# Heading");
153
+ expect(text).toContain("Use **bold** and `code`.");
154
+ expect(text).toContain("- item");
155
+ expect(text).not.toContain("Use bold and code.");
134
156
  });
135
157
 
136
158
  test("renderAssistantMessage with reasoning enabled shows thinking blocks", () => {
@@ -196,18 +218,131 @@ describe("ui/conversation", () => {
196
218
  expect(height).toBe(6);
197
219
  });
198
220
 
199
- test("renderAssistantMessage keeps markdown paragraph spacing inside a single text block", () => {
221
+ test("renderAssistantMessage syntax-highlights markdown tokens with theme-derived colors", async () => {
200
222
  // Arrange
201
- const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
223
+ const theme = {
224
+ ...DEFAULT_THEME,
225
+ accentText: "color14",
226
+ secondaryAccentText: "color09",
227
+ diffAdded: "color10",
228
+ mutedText: "color13",
229
+ } satisfies typeof DEFAULT_THEME;
230
+ const message = fauxAssistantMessage("# Heading\n- item\n> quote\n`code`");
202
231
 
203
232
  // Act
204
- const height = measureRenderedHeight(
205
- renderAssistantMessage(message, RENDER_OPTS),
206
- PREVIEW_WIDTH,
233
+ const rows = await renderBufferRows(
234
+ renderAssistantMessage(message, {
235
+ ...RENDER_OPTS,
236
+ theme,
237
+ }),
238
+ 32,
239
+ 12,
207
240
  );
241
+ const headingRow = rows.find((row) => row.text.includes("# Heading"));
242
+ const bulletRow = rows.find((row) => row.text.includes("- item"));
243
+ const quoteRow = rows.find((row) => row.text.includes("> quote"));
244
+ const codeRow = rows.find((row) => row.text.includes("`code`"));
208
245
 
209
246
  // Assert
210
- expect(height).toBe(3);
247
+ expect(headingRow).toBeDefined();
248
+ expect(bulletRow).toBeDefined();
249
+ expect(quoteRow).toBeDefined();
250
+ expect(codeRow).toBeDefined();
251
+ expect(headingRow?.fgColors[headingRow.text.indexOf("#")]).toBe(
252
+ theme.accentText ?? null,
253
+ );
254
+ expect(bulletRow?.fgColors[bulletRow.text.indexOf("-")]).toBe(
255
+ theme.secondaryAccentText ?? null,
256
+ );
257
+ expect(quoteRow?.fgColors[quoteRow.text.indexOf(">")]).toBe(
258
+ theme.mutedText ?? null,
259
+ );
260
+ expect(quoteRow?.italic[quoteRow.text.indexOf(">")]).toBe(true);
261
+ expect(codeRow?.fgColors[codeRow.text.indexOf("`")]).toBe(
262
+ theme.diffAdded ?? null,
263
+ );
264
+ });
265
+
266
+ test("renderAssistantMessage syntax-highlights markdown emphasis and links with style cues", async () => {
267
+ // Arrange
268
+ const theme = {
269
+ ...DEFAULT_THEME,
270
+ accentText: "color14",
271
+ diffAdded: "color10",
272
+ } satisfies typeof DEFAULT_THEME;
273
+ const message = fauxAssistantMessage(
274
+ "*italic* **bold** [label](https://example.com)",
275
+ );
276
+
277
+ // Act
278
+ const rows = await renderBufferRows(
279
+ renderAssistantMessage(message, {
280
+ ...RENDER_OPTS,
281
+ theme,
282
+ }),
283
+ 64,
284
+ 12,
285
+ );
286
+ const contentRow = rows.find((row) =>
287
+ row.text.includes("*italic* **bold** [label](https://example.com)"),
288
+ );
289
+
290
+ // Assert
291
+ expect(contentRow).toBeDefined();
292
+ expect(contentRow?.italic[contentRow.text.indexOf("*italic*")]).toBe(true);
293
+ expect(contentRow?.bold[contentRow.text.indexOf("**bold**")]).toBe(true);
294
+ expect(contentRow?.fgColors[contentRow.text.indexOf("label")]).toBe(
295
+ theme.diffAdded ?? null,
296
+ );
297
+ expect(
298
+ contentRow?.fgColors[contentRow.text.indexOf("https://example.com")],
299
+ ).toBe(theme.accentText ?? null);
300
+ expect(
301
+ contentRow?.underline[contentRow.text.indexOf("https://example.com")],
302
+ ).toBe(true);
303
+ });
304
+
305
+ test("renderAssistantMessage with adjacent text blocks preserves markdown structure across the block boundary", async () => {
306
+ // Arrange
307
+ const singleBlock = {
308
+ content: [fauxText("```js\nconst x = 1;\n```")],
309
+ };
310
+ const splitBlocks = {
311
+ content: [fauxText("```js\n"), fauxText("const x = 1;\n```")],
312
+ };
313
+
314
+ // Act
315
+ const singleRows = await renderBufferRows(
316
+ renderAssistantMessage(singleBlock, RENDER_OPTS),
317
+ 40,
318
+ 12,
319
+ );
320
+ const splitRows = await renderBufferRows(
321
+ renderAssistantMessage(splitBlocks, RENDER_OPTS),
322
+ 40,
323
+ 12,
324
+ );
325
+ const singleHeight = measureRenderedHeight(
326
+ renderAssistantMessage(singleBlock, RENDER_OPTS),
327
+ 40,
328
+ );
329
+ const splitHeight = measureRenderedHeight(
330
+ renderAssistantMessage(splitBlocks, RENDER_OPTS),
331
+ 40,
332
+ );
333
+ const singleRow = singleRows.find((row) =>
334
+ row.text.includes("const x = 1;"),
335
+ );
336
+ const splitRow = splitRows.find((row) => row.text.includes("const x = 1;"));
337
+
338
+ // Assert
339
+ expect(singleRow).toBeDefined();
340
+ expect(splitRow).toBeDefined();
341
+ expect(singleHeight).toBe(splitHeight);
342
+ expect(singleRow?.fgColors[singleRow.text.indexOf("const")]).not.toBe(null);
343
+ expect(splitRow?.fgColors[splitRow.text.indexOf("const")]).toBe(
344
+ singleRow?.fgColors[singleRow.text.indexOf("const")],
345
+ );
211
346
  });
212
347
 
213
348
  test("buildConversationLogNodes with a pending shell result keeps the streamed call and result append-only", () => {
@@ -926,6 +1061,37 @@ describe("ui/conversation", () => {
926
1061
  expect(text).not.toContain("seq 1 25");
927
1062
  });
928
1063
 
1064
+ test("renderToolResult for a wrapped shell preview keeps the summary directly below the visible tail", async () => {
1065
+ // Arrange
1066
+ const output = [
1067
+ "line 1",
1068
+ "line 2",
1069
+ "line 3",
1070
+ "line 4",
1071
+ "this is a very long wrapped line that will take more than six rendered rows in the preview width so it gets dropped entirely",
1072
+ "tail A",
1073
+ "tail B",
1074
+ ].join("\n");
1075
+
1076
+ // Act
1077
+ const rows = await renderBufferRows(
1078
+ renderToolResult("shell", { command: "demo" }, output, false, {
1079
+ ...RENDER_OPTS,
1080
+ previewWidth: 24,
1081
+ }),
1082
+ 24,
1083
+ 20,
1084
+ );
1085
+ const tailRowIndex = rows.findIndex((row) => row.text.includes("tail B"));
1086
+ const summaryRowIndex = rows.findIndex(
1087
+ (row) => row.text.includes("And ") && row.text.includes(" lines more"),
1088
+ );
1089
+
1090
+ // Assert
1091
+ expect(tailRowIndex).toBeGreaterThan(-1);
1092
+ expect(summaryRowIndex).toBe(tailRowIndex + 1);
1093
+ });
1094
+
929
1095
  test("renderToolResult for shell output in verbose mode shows the full stored output", () => {
930
1096
  // Arrange
931
1097
  const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import {
12
- Markdown,
13
12
  SyntaxHighlight,
14
13
  type SyntaxHighlightTheme,
15
14
  } from "@cel-tui/components";
@@ -142,11 +141,15 @@ type ToolRenderLineKind =
142
141
  | "summary"
143
142
  | "error";
144
143
 
145
- interface HighlightedToolBodySpec {
144
+ type SyntaxThemeVariant = "markdown" | "shell";
145
+
146
+ interface HighlightedBodySpec {
146
147
  /** Full raw source content to syntax-highlight. */
147
148
  text: string;
148
149
  /** Registered lextide language id. */
149
150
  language: string;
151
+ /** Semantic theme variant for this highlighted content. */
152
+ themeVariant: SyntaxThemeVariant;
150
153
  }
151
154
 
152
155
  interface ToolBlockSpec {
@@ -157,7 +160,7 @@ interface ToolBlockSpec {
157
160
  /** Logical body lines for the tool block. */
158
161
  bodyLines: readonly ToolRenderLine[];
159
162
  /** Optional syntax-highlighted body rendered from the full unsplit source. */
160
- highlightedBody?: HighlightedToolBodySpec;
163
+ highlightedBody?: HighlightedBodySpec;
161
164
  /** Whether `/verbose` preview rules apply to the body. */
162
165
  previewBody: boolean;
163
166
  }
@@ -222,16 +225,25 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
222
225
  ]);
223
226
  }
224
227
 
225
- /** Render a markdown block with stable internal paragraph spacing. */
226
- function renderMarkdownBlock(content: string): Node | null {
227
- const children = Markdown(content).map((node) => {
228
- if (node.type === "text" && node.content === "") {
229
- return node;
230
- }
231
- return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, [node])]);
232
- });
228
+ /** Render a syntax-highlighted raw markdown block. */
229
+ function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
230
+ if (content === "") {
231
+ return null;
232
+ }
233
233
 
234
- return children.length > 0 ? VStack({ gap: 0 }, children) : null;
234
+ const children = getHighlightedBodyLines(
235
+ {
236
+ text: content,
237
+ language: "markdown",
238
+ themeVariant: "markdown",
239
+ },
240
+ theme,
241
+ );
242
+ if (children.length === 0) {
243
+ return null;
244
+ }
245
+
246
+ return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, children)]);
235
247
  }
236
248
 
237
249
  function getAssistantErrorMessage(
@@ -270,12 +282,38 @@ function renderThinkingBlock(
270
282
  ]);
271
283
  }
272
284
 
285
+ function coalesceAdjacentTextBlocks(
286
+ content: AssistantMessage["content"],
287
+ ): AssistantMessage["content"] {
288
+ const coalesced: AssistantMessage["content"] = [];
289
+
290
+ for (const block of content) {
291
+ if (block.type !== "text") {
292
+ coalesced.push(block);
293
+ continue;
294
+ }
295
+
296
+ const previous = coalesced.at(-1);
297
+ if (previous?.type === "text") {
298
+ coalesced[coalesced.length - 1] = {
299
+ ...previous,
300
+ text: previous.text + block.text,
301
+ };
302
+ continue;
303
+ }
304
+
305
+ coalesced.push({ ...block });
306
+ }
307
+
308
+ return coalesced;
309
+ }
310
+
273
311
  function renderAssistantContentBlock(
274
312
  block: AssistantMessage["content"][number],
275
313
  opts: ConversationRenderOpts,
276
314
  ): Node | null {
277
315
  if (block.type === "text" && block.text) {
278
- return renderMarkdownBlock(block.text);
316
+ return renderMarkdownTextBlock(block.text, opts.theme);
279
317
  }
280
318
  if (block.type === "thinking" && block.thinking) {
281
319
  return renderThinkingBlock(block.thinking, opts);
@@ -297,7 +335,7 @@ export function renderAssistantMessage(
297
335
  assistant: AssistantMessage | AssistantRenderState,
298
336
  opts: ConversationRenderOpts,
299
337
  ): Node | null {
300
- const children = assistant.content
338
+ const children = coalesceAdjacentTextBlocks(assistant.content)
301
339
  .map((block) => {
302
340
  return renderAssistantContentBlock(block, opts);
303
341
  })
@@ -388,7 +426,13 @@ type SyntaxThemeTokenColor = NonNullable<
388
426
  const graphemeSegmenter = new Intl.Segmenter(undefined, {
389
427
  granularity: "grapheme",
390
428
  });
391
- const shellSyntaxThemeCache = new WeakMap<Theme, SyntaxThemeRegistration>();
429
+ const syntaxThemeCache: Record<
430
+ SyntaxThemeVariant,
431
+ WeakMap<Theme, SyntaxThemeRegistration>
432
+ > = {
433
+ markdown: new WeakMap(),
434
+ shell: new WeakMap(),
435
+ };
392
436
 
393
437
  function colorToHex(color: Color | undefined): string | undefined {
394
438
  return color ? ANSI_COLOR_HEX[color] : undefined;
@@ -414,14 +458,11 @@ function pushSyntaxTokenColor(
414
458
  });
415
459
  }
416
460
 
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);
461
+ function pushShellSyntaxTokenColors(
462
+ tokenColors: SyntaxThemeTokenColor[],
463
+ theme: Theme,
464
+ ): void {
465
+ pushSyntaxTokenColor(tokenColors, "comment", theme.mutedText);
425
466
  pushSyntaxTokenColor(
426
467
  tokenColors,
427
468
  ["keyword", "storage"],
@@ -472,12 +513,47 @@ function getShellSyntaxTheme(theme: Theme): SyntaxThemeRegistration {
472
513
  ["entity.name.tag", "name", "tag"],
473
514
  theme.accentText,
474
515
  );
516
+ }
517
+
518
+ function pushMarkdownSyntaxTokenColors(
519
+ tokenColors: SyntaxThemeTokenColor[],
520
+ theme: Theme,
521
+ ): void {
522
+ pushSyntaxTokenColor(tokenColors, "quote", theme.mutedText, "italic");
523
+ pushSyntaxTokenColor(tokenColors, "section", theme.accentText, "bold");
524
+ pushSyntaxTokenColor(
525
+ tokenColors,
526
+ "bullet",
527
+ theme.secondaryAccentText,
528
+ "bold",
529
+ );
530
+ pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
531
+ pushSyntaxTokenColor(tokenColors, "link", theme.accentText, "underline");
532
+ pushSyntaxTokenColor(tokenColors, "strong", undefined, "bold");
533
+ pushSyntaxTokenColor(tokenColors, "emphasis", undefined, "italic");
534
+ }
535
+
536
+ function getSyntaxTheme(
537
+ theme: Theme,
538
+ variant: SyntaxThemeVariant,
539
+ ): SyntaxThemeRegistration {
540
+ const cache = syntaxThemeCache[variant];
541
+ const cached = cache.get(theme);
542
+ if (cached) {
543
+ return cached;
544
+ }
545
+
546
+ const tokenColors: SyntaxThemeTokenColor[] = [];
547
+ if (variant === "shell") {
548
+ pushShellSyntaxTokenColors(tokenColors, theme);
549
+ } else {
550
+ pushMarkdownSyntaxTokenColors(tokenColors, theme);
551
+ }
475
552
 
476
553
  const syntaxTheme: SyntaxThemeRegistration = {
477
- ...(colorToHex(theme.toolText) ? { fg: colorToHex(theme.toolText) } : {}),
478
554
  tokenColors,
479
555
  };
480
- shellSyntaxThemeCache.set(theme, syntaxTheme);
556
+ cache.set(theme, syntaxTheme);
481
557
  return syntaxTheme;
482
558
  }
483
559
 
@@ -508,7 +584,7 @@ function splitHighlightedTextNode(
508
584
  return children;
509
585
  }
510
586
 
511
- function normalizeHighlightedToolLine(line: Node): Node {
587
+ function normalizeHighlightedLine(line: Node): Node {
512
588
  if (line.type !== "hstack") {
513
589
  return line;
514
590
  }
@@ -519,8 +595,8 @@ function normalizeHighlightedToolLine(line: Node): Node {
519
595
  return HStack(line.props, children);
520
596
  }
521
597
 
522
- function getHighlightedToolBodyLines(
523
- spec: HighlightedToolBodySpec,
598
+ function getHighlightedBodyLines(
599
+ spec: HighlightedBodySpec,
524
600
  theme: Theme,
525
601
  ): Node[] {
526
602
  if (spec.text === "") {
@@ -528,9 +604,9 @@ function getHighlightedToolBodyLines(
528
604
  }
529
605
 
530
606
  const highlighted = SyntaxHighlight(spec.text, spec.language, {
531
- theme: getShellSyntaxTheme(theme),
607
+ theme: getSyntaxTheme(theme, spec.themeVariant),
532
608
  });
533
- return highlighted.children.map((line) => normalizeHighlightedToolLine(line));
609
+ return highlighted.children.map((line) => normalizeHighlightedLine(line));
534
610
  }
535
611
 
536
612
  /** Render a single styled text node for a tool line. */
@@ -644,6 +720,7 @@ function renderToolBodyFromNodes(
644
720
  body: VStack(
645
721
  {
646
722
  height: UI_TOOL_PREVIEW_ROWS,
723
+ justifyContent: "end",
647
724
  },
648
725
  [...previewLines],
649
726
  ),
@@ -663,7 +740,7 @@ function renderToolBody(
663
740
  ): { body: Node | null; summary?: ToolRenderLine } {
664
741
  if (spec.highlightedBody) {
665
742
  return renderToolBodyFromNodes(
666
- getHighlightedToolBodyLines(spec.highlightedBody, opts.theme),
743
+ getHighlightedBodyLines(spec.highlightedBody, opts.theme),
667
744
  spec.previewBody,
668
745
  opts,
669
746
  );
@@ -720,6 +797,7 @@ function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
720
797
  : {
721
798
  text: command,
722
799
  language: "bash",
800
+ themeVariant: "shell",
723
801
  },
724
802
  previewBody: true,
725
803
  };
@@ -24,7 +24,7 @@ describe("ui/help", () => {
24
24
  );
25
25
  });
26
26
 
27
- test("buildHelpText includes the current Escape input-focus note", () => {
27
+ test("buildHelpText describes the current Escape behavior", () => {
28
28
  const helpState: HelpRenderState = {
29
29
  providers: new Map(),
30
30
  model: null,
@@ -37,8 +37,13 @@ describe("ui/help", () => {
37
37
 
38
38
  const text = buildHelpText(helpState);
39
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");
40
+ expect(text).toContain(
41
+ "Escape closes the current overlay and returns focus to the input",
42
+ );
43
+ expect(text).toContain(
44
+ "With no overlay open, Escape interrupts the current turn",
45
+ );
46
+ expect(text).toContain("Otherwise Escape does nothing");
47
+ expect(text).not.toContain("Escape blurs the input first");
43
48
  });
44
49
  });
package/src/ui/help.ts CHANGED
@@ -79,9 +79,11 @@ export function buildHelpText(state: HelpRenderState): string {
79
79
  const providerNames = Array.from(state.providers.keys());
80
80
  lines.push("");
81
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.");
82
+ lines.push(
83
+ " Escape closes the current overlay and returns focus to the input.",
84
+ );
85
+ lines.push(" With no overlay open, Escape interrupts the current turn.");
86
+ lines.push(" Otherwise Escape does nothing.");
85
87
 
86
88
  lines.push("");
87
89
  lines.push(
package/src/ui.ts CHANGED
@@ -572,7 +572,6 @@ function renderConversationLog(state: AppState, width: number): Node {
572
572
  flex: 1,
573
573
  gap: CONVERSATION_GAP,
574
574
  overflow: "scroll",
575
- scrollbar: true,
576
575
  justifyContent: state.messages.length === 0 ? "center" : undefined,
577
576
  scrollOffset: stickToBottom ? Infinity : scrollOffset,
578
577
  onScroll: (offset, maxOffset) => {
@@ -631,8 +630,13 @@ export function renderBaseLayout(
631
630
  onSuspend?.();
632
631
  return;
633
632
  }
634
- if (key === "escape" && state.running) {
635
- if (state.abortController) state.abortController.abort();
633
+ if (key === "escape") {
634
+ if (state.running) {
635
+ inputFocused = true;
636
+ if (state.abortController) {
637
+ state.abortController.abort();
638
+ }
639
+ }
636
640
  return;
637
641
  }
638
642
  return false;