codeep 2.14.0 → 2.15.0

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.
Files changed (61) hide show
  1. package/README.md +35 -24
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +2 -2
  6. package/dist/config/providers.js +15 -10
  7. package/dist/renderer/App.d.ts +0 -30
  8. package/dist/renderer/App.js +149 -659
  9. package/dist/renderer/agentExecution.d.ts +1 -0
  10. package/dist/renderer/agentExecution.js +3 -2
  11. package/dist/renderer/commands/helpers.d.ts +63 -0
  12. package/dist/renderer/commands/helpers.js +108 -0
  13. package/dist/renderer/commands/registry.js +5 -0
  14. package/dist/renderer/commands.d.ts +4 -0
  15. package/dist/renderer/commands.js +179 -63
  16. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  17. package/dist/renderer/components/ActionFormatting.js +67 -0
  18. package/dist/renderer/components/Autocomplete.d.ts +33 -0
  19. package/dist/renderer/components/Autocomplete.js +40 -0
  20. package/dist/renderer/components/Intro.d.ts +9 -0
  21. package/dist/renderer/components/Intro.js +5 -15
  22. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  23. package/dist/renderer/components/MessageFormatter.js +375 -0
  24. package/dist/renderer/components/Permission.d.ts +4 -0
  25. package/dist/renderer/components/Permission.js +1 -1
  26. package/dist/renderer/components/Status.d.ts +4 -0
  27. package/dist/renderer/components/Status.js +2 -3
  28. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  29. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  30. package/dist/renderer/components/uiConstants.d.ts +8 -0
  31. package/dist/renderer/components/uiConstants.js +24 -0
  32. package/dist/renderer/inputParsing.d.ts +22 -0
  33. package/dist/renderer/inputParsing.js +28 -0
  34. package/dist/renderer/layout.d.ts +215 -0
  35. package/dist/renderer/layout.js +326 -0
  36. package/dist/renderer/main.d.ts +2 -1
  37. package/dist/renderer/main.js +45 -10
  38. package/dist/renderer/ollamaHint.d.ts +12 -0
  39. package/dist/renderer/ollamaHint.js +29 -0
  40. package/dist/utils/agentChat.js +23 -1
  41. package/dist/utils/codeepCloud.d.ts +54 -0
  42. package/dist/utils/codeepCloud.js +95 -0
  43. package/dist/utils/export.d.ts +12 -0
  44. package/dist/utils/export.js +3 -3
  45. package/dist/utils/hooks.d.ts +26 -0
  46. package/dist/utils/hooks.js +69 -1
  47. package/dist/utils/keychain.js +45 -29
  48. package/dist/utils/logger.d.ts +12 -0
  49. package/dist/utils/logger.js +1 -1
  50. package/dist/utils/mcpConfig.d.ts +26 -0
  51. package/dist/utils/mcpConfig.js +109 -4
  52. package/dist/utils/skillBundles.d.ts +14 -0
  53. package/dist/utils/skillBundles.js +3 -3
  54. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  55. package/dist/utils/skillBundlesCloud.js +1 -1
  56. package/dist/utils/tokenTracker.js +12 -2
  57. package/dist/utils/toolParsing.d.ts +11 -0
  58. package/dist/utils/toolParsing.js +6 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Autocomplete logic for the `/`-command picker.
3
+ *
4
+ * Extracted from `App.ts` so the filter rule (prefix match, 8-item cap,
5
+ * only triggered for command-shaped input) can be unit-tested without
6
+ * the editor / render machinery.
7
+ */
8
+ /**
9
+ * Filter `commands` to those that start with the typed prefix and the
10
+ * dropdown should appear.
11
+ *
12
+ * Rules:
13
+ * - Input must start with `/` (a slash command).
14
+ * - Input must not contain a space (the user is still typing the
15
+ * command name, not an argument).
16
+ * - Match case-insensitively on the text after the `/`.
17
+ * - Cap at 8 results so the dropdown never grows past the panel.
18
+ *
19
+ * @param value The raw editor value (e.g. `/h`, `/lo`, `/hel world`).
20
+ * @param commands The full command-name list (from `COMMAND_DESCRIPTIONS`).
21
+ * @returns Match list, or `null` when the dropdown should be hidden.
22
+ */
23
+ export function filterCommands(value, commands) {
24
+ // Only show autocomplete while typing a command name.
25
+ if (!value.startsWith('/') || value.includes(' ')) {
26
+ return null;
27
+ }
28
+ const query = value.slice(1).toLowerCase();
29
+ if (query.length === 0) {
30
+ // Empty query after the slash — the original App.ts code required
31
+ // `query.length > 0`, so an empty `/` keeps the dropdown closed.
32
+ return { items: [], index: 0 };
33
+ }
34
+ const items = commands
35
+ .filter((cmd) => cmd.startsWith(query))
36
+ .slice(0, 8);
37
+ if (items.length === 0)
38
+ return { items: [], index: 0 };
39
+ return { items, index: 0 };
40
+ }
@@ -2,10 +2,19 @@
2
2
  * Intro animation component - matches Ink version style
3
3
  */
4
4
  import { Screen } from '../Screen';
5
+ export declare const GLITCH_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*<>?/;:[]=";
5
6
  /**
6
7
  * Show intro animation with decrypt effect
7
8
  */
8
9
  export declare function showIntro(screen: Screen, duration?: number): Promise<void>;
10
+ /**
11
+ * Generate noise line (random glitch characters)
12
+ */
13
+ export declare function generateNoiseLine(original: string): string;
14
+ /**
15
+ * Get partially decrypted line based on progress
16
+ */
17
+ export declare function getDecryptedLine(original: string, progress: number): string;
9
18
  /**
10
19
  * Quick version without animation (for fast startup)
11
20
  */
@@ -1,20 +1,10 @@
1
1
  /**
2
2
  * Intro animation component - matches Ink version style
3
3
  */
4
- import { fg, style } from '../ansi.js';
5
- // ASCII Logo (same as Ink version)
6
- const LOGO = [
7
- ' ██████╗ ██████╗ ██████╗ ███████╗███████╗██████╗ ',
8
- '██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗',
9
- '██║ ██║ ██║██║ ██║█████╗ █████╗ ██████╔╝',
10
- '██║ ██║ ██║██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ',
11
- '╚██████╗╚██████╔╝██████╔╝███████╗███████╗██║ ',
12
- ' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ',
13
- ];
4
+ import { style } from '../ansi.js';
5
+ import { PRIMARY_COLOR, LOGO_LINES as LOGO } from './uiConstants.js';
14
6
  const TAGLINE = 'Deep into Code.';
15
- const GLITCH_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*<>?/;:[]=';
16
- // Primary color: #f02a30 (Codeep red)
17
- const PRIMARY_COLOR = fg.rgb(240, 42, 48);
7
+ export const GLITCH_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*<>?/;:[]=';
18
8
  /**
19
9
  * Show intro animation with decrypt effect
20
10
  */
@@ -68,7 +58,7 @@ export async function showIntro(screen, duration = 1500) {
68
58
  /**
69
59
  * Generate noise line (random glitch characters)
70
60
  */
71
- function generateNoiseLine(original) {
61
+ export function generateNoiseLine(original) {
72
62
  let result = '';
73
63
  for (const char of original) {
74
64
  if (char === ' ' && Math.random() > 0.1) {
@@ -83,7 +73,7 @@ function generateNoiseLine(original) {
83
73
  /**
84
74
  * Get partially decrypted line based on progress
85
75
  */
86
- function getDecryptedLine(original, progress) {
76
+ export function getDecryptedLine(original, progress) {
87
77
  let result = '';
88
78
  for (let i = 0; i < original.length; i++) {
89
79
  const char = original[i];
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Message formatting utilities — extracted from `App.ts`.
3
+ *
4
+ * These are pure (or near-pure) functions that turn a chat message
5
+ * (`role` + raw `content`) into the array of styled lines the custom
6
+ * screen renderer paints. Extracting them:
7
+ *
8
+ * - shrinks `App.ts` (the 3.2k-LoC render-loop host) by ~600 LoC,
9
+ * - makes the formatter unit-testable in isolation (no App state),
10
+ * - lets the quick-chat / ACP surfaces reuse the same rendering if
11
+ * they ever need to paint formatted assistant output.
12
+ *
13
+ * Convention: a `RenderedLine` carries the already-ANSI-styled `text`
14
+ * plus a `raw` flag — when true, the screen writer must NOT apply the
15
+ * per-message default style (the line has its own colours baked in).
16
+ * This is how code blocks and headings keep their syntax-highlight
17
+ * palette instead of being washed by the body colour.
18
+ */
19
+ export interface RenderedLine {
20
+ text: string;
21
+ style: string;
22
+ raw?: boolean;
23
+ }
24
+ /**
25
+ * Mutable counter passed by reference into `formatMessage` so the caller
26
+ * (App, during a render pass) can keep a running code-block index across
27
+ * messages — that index is what `/copy [n]` refers to.
28
+ *
29
+ * Modelled as an object (not a return value) because `formatMessage`
30
+ * appends to an output array AND increments the counter; returning both
31
+ * would force every caller to thread a tuple through, and the render
32
+ * loop already mutates `this.codeBlockCounter` in place.
33
+ */
34
+ export interface BlockCounter {
35
+ current: number;
36
+ }
37
+ /**
38
+ * Apply inline markdown formatting (bold, italic, inline code, strikethrough)
39
+ * to a single line. Returns the styled string plus a flag telling the caller
40
+ * whether any formatting was applied — the caller uses that to decide whether
41
+ * to mark the line `raw` (so the renderer doesn't double-apply the body colour).
42
+ */
43
+ export declare function applyInlineMarkdown(text: string): {
44
+ formatted: string;
45
+ hasFormatting: boolean;
46
+ };
47
+ /**
48
+ * Word-wrap `text` to `maxWidth` columns. Words wider than `maxWidth`
49
+ * (typically long file paths with no spaces) are hard-broken across lines.
50
+ *
51
+ * Note: this is NOT the `wordWrap` exported from `ansi.ts` — that one
52
+ * lets over-width words overflow. This chat-flavoured variant slices
53
+ * them so a 200-char path doesn't blow out the right margin.
54
+ */
55
+ export declare function wordWrap(text: string, maxWidth: number): string[];
56
+ /**
57
+ * Format a chunk of prose (text between code fences) into styled lines.
58
+ *
59
+ * Recognises, per line:
60
+ * - ATX headings (`#` … `######`)
61
+ * - horizontal rules (`---`, `***`, `___`)
62
+ * - blockquotes (`> …`, nestable)
63
+ * - bullet/numbered list items (`-`, `*`, `1.`)
64
+ * - plain text, with inline markdown + word-wrap
65
+ *
66
+ * `firstPrefix` / `firstStyle` apply only to the first output line — the
67
+ * caller uses that to attach the role indicator (▌ for user, ▸ for system);
68
+ * continuation lines get a blank/generic prefix so wrapped paragraphs stay
69
+ * visually grouped under the same marker.
70
+ */
71
+ export declare function formatTextLines(text: string, maxWidth: number, firstPrefix: string, firstStyle: string, rawPrefix?: boolean): RenderedLine[];
72
+ /**
73
+ * Format a fenced code block: language label + block number on the first
74
+ * line, then each source line indented and syntax-highlighted via
75
+ * `highlightCode`. A trailing blank line separates the block from the
76
+ * next paragraph.
77
+ *
78
+ * `blockNum` is the 1-based index used by `/copy [n]`; omitted when the
79
+ * caller is rendering a non-chat context (e.g. a paste preview).
80
+ */
81
+ export declare function formatCodeBlock(code: string, lang: string, maxWidth: number, blockNum?: number): RenderedLine[];
82
+ /**
83
+ * Format a full chat message (role + content) into styled lines.
84
+ *
85
+ * Walks the content looking for `` ``` ``-fenced code blocks; anything
86
+ * outside a fence goes through `formatTextLines` (headings, lists, inline
87
+ * markdown), fenced regions go through `formatCodeBlock`. The fenced-block
88
+ * regex is non-greedy and stops at the first closing fence, so streaming
89
+ * input with an as-yet-unclosed fence still renders correctly (the open
90
+ * tail is treated as plain text until the closer arrives).
91
+ *
92
+ * `counter` is incremented once per fenced block encountered — callers
93
+ * thread the same `BlockCounter` across consecutive `formatMessage`
94
+ * calls so block numbers are stable across a whole render pass.
95
+ */
96
+ export declare function formatMessage(role: 'user' | 'assistant' | 'system', content: string, maxWidth: number, counter: BlockCounter): RenderedLine[];
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Message formatting utilities — extracted from `App.ts`.
3
+ *
4
+ * These are pure (or near-pure) functions that turn a chat message
5
+ * (`role` + raw `content`) into the array of styled lines the custom
6
+ * screen renderer paints. Extracting them:
7
+ *
8
+ * - shrinks `App.ts` (the 3.2k-LoC render-loop host) by ~600 LoC,
9
+ * - makes the formatter unit-testable in isolation (no App state),
10
+ * - lets the quick-chat / ACP surfaces reuse the same rendering if
11
+ * they ever need to paint formatted assistant output.
12
+ *
13
+ * Convention: a `RenderedLine` carries the already-ANSI-styled `text`
14
+ * plus a `raw` flag — when true, the screen writer must NOT apply the
15
+ * per-message default style (the line has its own colours baked in).
16
+ * This is how code blocks and headings keep their syntax-highlight
17
+ * palette instead of being washed by the body colour.
18
+ */
19
+ import { fg, style, stringWidth } from '../ansi.js';
20
+ import { SYNTAX, highlightCode } from '../highlight.js';
21
+ // Shared primary colour — same value as the one defined in App.ts. Kept
22
+ // here so the formatter is self-contained; the App re-exports it from
23
+ // the same literal to avoid drift.
24
+ const PRIMARY_COLOR = fg.rgb(240, 42, 48);
25
+ // ─── Inline markdown ──────────────────────────────────────────────────────────
26
+ /**
27
+ * Apply inline markdown formatting (bold, italic, inline code, strikethrough)
28
+ * to a single line. Returns the styled string plus a flag telling the caller
29
+ * whether any formatting was applied — the caller uses that to decide whether
30
+ * to mark the line `raw` (so the renderer doesn't double-apply the body colour).
31
+ */
32
+ export function applyInlineMarkdown(text) {
33
+ let result = '';
34
+ let hasFormatting = false;
35
+ let i = 0;
36
+ while (i < text.length) {
37
+ // Inline code: `code`
38
+ if (text[i] === '`' && text[i + 1] !== '`') {
39
+ const end = text.indexOf('`', i + 1);
40
+ if (end !== -1) {
41
+ const code = text.slice(i + 1, end);
42
+ result += fg.rgb(209, 154, 102) + code + '\x1b[0m';
43
+ hasFormatting = true;
44
+ i = end + 1;
45
+ continue;
46
+ }
47
+ }
48
+ // Bold + italic: ***text***
49
+ if (text.slice(i, i + 3) === '***') {
50
+ const end = text.indexOf('***', i + 3);
51
+ if (end !== -1) {
52
+ const inner = text.slice(i + 3, end);
53
+ result += style.bold + style.italic + PRIMARY_COLOR + inner + '\x1b[0m';
54
+ hasFormatting = true;
55
+ i = end + 3;
56
+ continue;
57
+ }
58
+ }
59
+ // Bold: **text**
60
+ if (text.slice(i, i + 2) === '**') {
61
+ const end = text.indexOf('**', i + 2);
62
+ if (end !== -1) {
63
+ const inner = text.slice(i + 2, end);
64
+ result += style.bold + PRIMARY_COLOR + inner + '\x1b[0m';
65
+ hasFormatting = true;
66
+ i = end + 2;
67
+ continue;
68
+ }
69
+ }
70
+ // Italic: *text*
71
+ if (text[i] === '*' && text[i + 1] !== '*') {
72
+ const end = text.indexOf('*', i + 1);
73
+ if (end !== -1 && end > i + 1) {
74
+ const inner = text.slice(i + 1, end);
75
+ result += style.italic + inner + '\x1b[0m';
76
+ hasFormatting = true;
77
+ i = end + 1;
78
+ continue;
79
+ }
80
+ }
81
+ // Strikethrough: ~~text~~ — using the SGR strikethrough escape (\x1b[9m).
82
+ // Widely supported in modern terminals (iTerm2, Kitty, WezTerm, Alacritty,
83
+ // gnome-terminal, Windows Terminal). Falls back gracefully to the dim
84
+ // text colour on terminals that don't render the SGR.
85
+ if (text.slice(i, i + 2) === '~~') {
86
+ const end = text.indexOf('~~', i + 2);
87
+ if (end !== -1) {
88
+ const inner = text.slice(i + 2, end);
89
+ result += '\x1b[9m' + fg.rgb(140, 140, 140) + inner + '\x1b[0m';
90
+ hasFormatting = true;
91
+ i = end + 2;
92
+ continue;
93
+ }
94
+ }
95
+ result += text[i];
96
+ i++;
97
+ }
98
+ return { formatted: result, hasFormatting };
99
+ }
100
+ // ─── Word wrap ────────────────────────────────────────────────────────────────
101
+ /**
102
+ * Word-wrap `text` to `maxWidth` columns. Words wider than `maxWidth`
103
+ * (typically long file paths with no spaces) are hard-broken across lines.
104
+ *
105
+ * Note: this is NOT the `wordWrap` exported from `ansi.ts` — that one
106
+ * lets over-width words overflow. This chat-flavoured variant slices
107
+ * them so a 200-char path doesn't blow out the right margin.
108
+ */
109
+ export function wordWrap(text, maxWidth) {
110
+ const words = text.split(' ');
111
+ const lines = [];
112
+ let currentLine = '';
113
+ for (const word of words) {
114
+ const wordW = stringWidth(word);
115
+ // Hard-break words wider than maxWidth (e.g. long file paths with no spaces)
116
+ if (wordW > maxWidth) {
117
+ if (currentLine) {
118
+ lines.push(currentLine);
119
+ currentLine = '';
120
+ }
121
+ // Slice the word into maxWidth chunks
122
+ let remaining = word;
123
+ while (stringWidth(remaining) > maxWidth) {
124
+ lines.push(remaining.slice(0, maxWidth));
125
+ remaining = remaining.slice(maxWidth);
126
+ }
127
+ currentLine = remaining;
128
+ continue;
129
+ }
130
+ if (stringWidth(currentLine) + wordW + 1 > maxWidth && currentLine) {
131
+ lines.push(currentLine);
132
+ currentLine = word;
133
+ }
134
+ else {
135
+ currentLine += (currentLine ? ' ' : '') + word;
136
+ }
137
+ }
138
+ if (currentLine) {
139
+ lines.push(currentLine);
140
+ }
141
+ return lines.length > 0 ? lines : [''];
142
+ }
143
+ // ─── Text lines ───────────────────────────────────────────────────────────────
144
+ /**
145
+ * Format a chunk of prose (text between code fences) into styled lines.
146
+ *
147
+ * Recognises, per line:
148
+ * - ATX headings (`#` … `######`)
149
+ * - horizontal rules (`---`, `***`, `___`)
150
+ * - blockquotes (`> …`, nestable)
151
+ * - bullet/numbered list items (`-`, `*`, `1.`)
152
+ * - plain text, with inline markdown + word-wrap
153
+ *
154
+ * `firstPrefix` / `firstStyle` apply only to the first output line — the
155
+ * caller uses that to attach the role indicator (▌ for user, ▸ for system);
156
+ * continuation lines get a blank/generic prefix so wrapped paragraphs stay
157
+ * visually grouped under the same marker.
158
+ */
159
+ export function formatTextLines(text, maxWidth, firstPrefix, firstStyle, rawPrefix = false) {
160
+ const lines = [];
161
+ const contentLines = text.split('\n');
162
+ for (let i = 0; i < contentLines.length; i++) {
163
+ const line = contentLines[i];
164
+ const prefix = i === 0 ? firstPrefix : ' ';
165
+ const prefixStyle = i === 0 ? firstStyle : '';
166
+ const isRaw = i === 0 ? rawPrefix : false;
167
+ // Heading: ## or ### etc.
168
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
169
+ if (headingMatch) {
170
+ const level = headingMatch[1].length;
171
+ const headingText = headingMatch[2];
172
+ const headingColor = level <= 2 ? fg.rgb(97, 175, 239) : fg.rgb(198, 120, 221);
173
+ lines.push({
174
+ text: prefix + headingColor + style.bold + headingText + '\x1b[0m',
175
+ style: prefixStyle,
176
+ raw: true,
177
+ });
178
+ continue;
179
+ }
180
+ // Horizontal rule: --- or *** or ___
181
+ if (/^[-*_]{3,}\s*$/.test(line)) {
182
+ const ruleWidth = Math.min(maxWidth - 4, 40);
183
+ lines.push({
184
+ text: prefix + fg.gray + '─'.repeat(ruleWidth) + '\x1b[0m',
185
+ style: prefixStyle,
186
+ raw: true,
187
+ });
188
+ continue;
189
+ }
190
+ // Blockquote: `> text` — render with a left accent bar (PRIMARY_COLOR)
191
+ // and the body in a dimmer grey so it visually sits behind regular text.
192
+ // Strips one level of `> ` so nested quotes render with their own bar.
193
+ const quoteMatch = line.match(/^(\s*)>\s?(.*)$/);
194
+ if (quoteMatch) {
195
+ const indent = quoteMatch[1];
196
+ const quoteText = quoteMatch[2];
197
+ const { formatted, hasFormatting } = applyInlineMarkdown(quoteText);
198
+ const body = hasFormatting ? formatted : quoteText;
199
+ // Vertical bar + space, then dim grey body. Skip if the body is
200
+ // empty so an isolated `>` renders cleanly.
201
+ const barred = PRIMARY_COLOR + '│' + '\x1b[0m' + (body ? ' ' + fg.rgb(160, 160, 160) + body + '\x1b[0m' : '');
202
+ lines.push({
203
+ text: prefix + indent + barred,
204
+ style: prefixStyle,
205
+ raw: true,
206
+ });
207
+ continue;
208
+ }
209
+ // List items: - item or * item or numbered 1. item
210
+ const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s+(.+)$/);
211
+ if (listMatch) {
212
+ const indent = listMatch[1];
213
+ const bullet = listMatch[2];
214
+ const content = listMatch[3];
215
+ const { formatted, hasFormatting } = applyInlineMarkdown(content);
216
+ const bulletChar = bullet === '-' || bullet === '*' ? '\u25b8' : bullet;
217
+ if (hasFormatting) {
218
+ lines.push({
219
+ text: prefix + indent + fg.gray + bulletChar + '\x1b[0m' + ' ' + formatted,
220
+ style: prefixStyle,
221
+ raw: true,
222
+ });
223
+ }
224
+ else {
225
+ lines.push({
226
+ text: prefix + indent + bulletChar + ' ' + content,
227
+ style: prefixStyle,
228
+ });
229
+ }
230
+ continue;
231
+ }
232
+ // Regular text with possible inline markdown
233
+ const { formatted, hasFormatting } = applyInlineMarkdown(line);
234
+ if (hasFormatting) {
235
+ // Use original (no-ANSI) line to measure and wrap, then apply markdown per segment
236
+ if (stringWidth(line) > maxWidth - prefix.length) {
237
+ const wrapped = wordWrap(line, maxWidth - prefix.length);
238
+ for (let j = 0; j < wrapped.length; j++) {
239
+ const { formatted: segFormatted } = applyInlineMarkdown(wrapped[j]);
240
+ lines.push({
241
+ text: (j === 0 ? prefix : ' ') + segFormatted,
242
+ style: j === 0 ? prefixStyle : '',
243
+ raw: true,
244
+ });
245
+ }
246
+ }
247
+ else {
248
+ lines.push({
249
+ text: prefix + formatted,
250
+ style: prefixStyle,
251
+ raw: true,
252
+ });
253
+ }
254
+ }
255
+ else {
256
+ // Plain text - word wrap as before
257
+ if (stringWidth(line) > maxWidth - prefix.length) {
258
+ const wrapped = wordWrap(line, maxWidth - prefix.length);
259
+ for (let j = 0; j < wrapped.length; j++) {
260
+ const lineIsRaw = j === 0 ? isRaw : false;
261
+ lines.push({
262
+ text: (j === 0 ? prefix : ' ') + wrapped[j],
263
+ style: j === 0 ? prefixStyle : '',
264
+ ...(lineIsRaw ? { raw: true } : {}),
265
+ });
266
+ }
267
+ }
268
+ else {
269
+ lines.push({
270
+ text: prefix + line,
271
+ style: prefixStyle,
272
+ ...(isRaw ? { raw: true } : {}),
273
+ });
274
+ }
275
+ }
276
+ }
277
+ return lines;
278
+ }
279
+ // ─── Code block ───────────────────────────────────────────────────────────────
280
+ /**
281
+ * Format a fenced code block: language label + block number on the first
282
+ * line, then each source line indented and syntax-highlighted via
283
+ * `highlightCode`. A trailing blank line separates the block from the
284
+ * next paragraph.
285
+ *
286
+ * `blockNum` is the 1-based index used by `/copy [n]`; omitted when the
287
+ * caller is rendering a non-chat context (e.g. a paste preview).
288
+ */
289
+ export function formatCodeBlock(code, lang, maxWidth, blockNum) {
290
+ const lines = [];
291
+ const codeLines = code.split('\n');
292
+ // Remove trailing empty line if exists
293
+ if (codeLines.length > 0 && codeLines[codeLines.length - 1] === '') {
294
+ codeLines.pop();
295
+ }
296
+ // Language label with block number for /copy
297
+ const label = blockNum ? (lang ? ` ${lang} [${blockNum}]` : ` [${blockNum}]`) : (lang ? ' ' + lang : '');
298
+ if (label) {
299
+ lines.push({ text: label, style: SYNTAX.codeLang, raw: false });
300
+ }
301
+ // Code lines with highlighting and indent
302
+ for (const codeLine of codeLines) {
303
+ const highlighted = highlightCode(codeLine, lang);
304
+ lines.push({
305
+ text: ' ' + highlighted,
306
+ style: '',
307
+ raw: true, // Don't apply additional styling, code is pre-highlighted
308
+ });
309
+ }
310
+ // Empty line after code block
311
+ lines.push({ text: '', style: '', raw: false });
312
+ return lines;
313
+ }
314
+ // ─── Message ──────────────────────────────────────────────────────────────────
315
+ /**
316
+ * Format a full chat message (role + content) into styled lines.
317
+ *
318
+ * Walks the content looking for `` ``` ``-fenced code blocks; anything
319
+ * outside a fence goes through `formatTextLines` (headings, lists, inline
320
+ * markdown), fenced regions go through `formatCodeBlock`. The fenced-block
321
+ * regex is non-greedy and stops at the first closing fence, so streaming
322
+ * input with an as-yet-unclosed fence still renders correctly (the open
323
+ * tail is treated as plain text until the closer arrives).
324
+ *
325
+ * `counter` is incremented once per fenced block encountered — callers
326
+ * thread the same `BlockCounter` across consecutive `formatMessage`
327
+ * calls so block numbers are stable across a whole render pass.
328
+ */
329
+ export function formatMessage(role, content, maxWidth, counter) {
330
+ const lines = [];
331
+ // Role-specific prefix — user gets primary color bar, assistant gets dim header, system gets diamond
332
+ const contIndent = ' ';
333
+ let firstPrefix;
334
+ const firstStyle = '';
335
+ if (role === 'user') {
336
+ firstPrefix = PRIMARY_COLOR + '\u258c ' + style.reset;
337
+ }
338
+ else if (role === 'assistant') {
339
+ lines.push({ text: PRIMARY_COLOR + '\u254c\u254c' + style.reset + fg.rgb(120, 120, 120) + ' codeep' + style.reset, style: '', raw: true });
340
+ firstPrefix = ' ';
341
+ }
342
+ else {
343
+ firstPrefix = PRIMARY_COLOR + '\u25b8 ' + style.reset;
344
+ }
345
+ const codeBlockRegex = /```([^\n]*)\n([\s\S]*?)```/g;
346
+ let lastIndex = 0;
347
+ let match;
348
+ let isFirstLine = true;
349
+ while ((match = codeBlockRegex.exec(content)) !== null) {
350
+ const textBefore = content.slice(lastIndex, match.index);
351
+ if (textBefore) {
352
+ const prefix = isFirstLine ? firstPrefix : (role === 'user' ? contIndent : ' ');
353
+ const textLines = formatTextLines(textBefore, maxWidth, prefix, firstStyle, role === 'user' && isFirstLine);
354
+ lines.push(...textLines);
355
+ isFirstLine = false;
356
+ }
357
+ counter.current++;
358
+ const rawLang = (match[1] || 'text').trim();
359
+ let lang = rawLang;
360
+ if (rawLang.includes(':') || rawLang.includes('.')) {
361
+ lang = rawLang.split('.').pop() || rawLang;
362
+ }
363
+ lines.push(...formatCodeBlock(match[2], lang, maxWidth, counter.current));
364
+ lastIndex = match.index + match[0].length;
365
+ isFirstLine = false;
366
+ }
367
+ const textAfter = content.slice(lastIndex);
368
+ if (textAfter) {
369
+ const prefix = isFirstLine ? firstPrefix : (role === 'user' ? contIndent : ' ');
370
+ const textLines = formatTextLines(textAfter, maxWidth, prefix, firstStyle, role === 'user' && isFirstLine);
371
+ lines.push(...textLines);
372
+ }
373
+ lines.push({ text: '', style: '' });
374
+ return lines;
375
+ }
@@ -18,3 +18,7 @@ export declare function renderPermissionScreen(screen: Screen, options: Permissi
18
18
  * Get permission options array for easy indexing
19
19
  */
20
20
  export declare function getPermissionOptions(): PermissionLevel[];
21
+ /**
22
+ * Truncate path for display
23
+ */
24
+ export declare function truncatePath(path: string, maxLen: number): string;
@@ -97,7 +97,7 @@ export function getPermissionOptions() {
97
97
  /**
98
98
  * Truncate path for display
99
99
  */
100
- function truncatePath(path, maxLen) {
100
+ export function truncatePath(path, maxLen) {
101
101
  if (path.length <= maxLen)
102
102
  return path;
103
103
  const parts = path.split('/');
@@ -25,3 +25,7 @@ export interface StatusInfo {
25
25
  * Render status screen
26
26
  */
27
27
  export declare function renderStatusScreen(screen: Screen, status: StatusInfo): void;
28
+ /**
29
+ * Truncate path for display
30
+ */
31
+ export declare function truncatePath(path: string, maxLen: number): string;
@@ -2,8 +2,7 @@
2
2
  * Status screen component
3
3
  */
4
4
  import { fg, style } from '../ansi.js';
5
- // Primary color: #f02a30 (Codeep red)
6
- const PRIMARY_COLOR = fg.rgb(240, 42, 48);
5
+ import { PRIMARY_COLOR } from './uiConstants.js';
7
6
  /**
8
7
  * Render status screen
9
8
  */
@@ -80,7 +79,7 @@ export function renderStatusScreen(screen, status) {
80
79
  /**
81
80
  * Truncate path for display
82
81
  */
83
- function truncatePath(path, maxLen) {
82
+ export function truncatePath(path, maxLen) {
84
83
  if (path.length <= maxLen)
85
84
  return path;
86
85
  // Try to keep the end of the path
@@ -0,0 +1,19 @@
1
+ export interface FormattedLine {
2
+ text: string;
3
+ style: string;
4
+ raw?: boolean;
5
+ }
6
+ /**
7
+ * Format the welcome message body into coloured terminal lines.
8
+ *
9
+ * The body is a small DSL of line shapes:
10
+ * - `Codeep vX.X.X · Provider · Model` — version header
11
+ * - ` Project <path>` — project label
12
+ * - ` Access <read · write>` — access label
13
+ * - ` Mode <mode>` — mode label
14
+ * - lines containing `⚠` — amber warning
15
+ * - lines containing `/help` — shortcuts hint
16
+ *
17
+ * Anything else is pushed verbatim.
18
+ */
19
+ export declare function formatWelcomeMessage(content: string): FormattedLine[];