lonny-agent 0.1.2 → 0.1.9

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 (216) hide show
  1. package/.kilo/plans/1780064105789-mighty-pixel.md +171 -0
  2. package/.lonny/fix-new-command-session-cleanup.md +65 -0
  3. package/.lonny/tui-autocomplete-above.md +62 -0
  4. package/dist/agent/__tests__/compaction.test.js +6 -4
  5. package/dist/agent/__tests__/compaction.test.js.map +1 -1
  6. package/dist/agent/llm.d.ts +1 -1
  7. package/dist/agent/llm.d.ts.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +26 -8
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/anthropic.d.ts +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +2 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.d.ts +1 -1
  16. package/dist/agent/providers/google.d.ts.map +1 -1
  17. package/dist/agent/providers/google.js +2 -1
  18. package/dist/agent/providers/google.js.map +1 -1
  19. package/dist/agent/providers/ollama.d.ts +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts +1 -1
  24. package/dist/agent/providers/openai.d.ts.map +1 -1
  25. package/dist/agent/providers/openai.js +2 -2
  26. package/dist/agent/providers/openai.js.map +1 -1
  27. package/dist/agent/session.d.ts +1 -0
  28. package/dist/agent/session.d.ts.map +1 -1
  29. package/dist/agent/session.js +8 -2
  30. package/dist/agent/session.js.map +1 -1
  31. package/dist/pi-tui/autocomplete.d.ts +54 -0
  32. package/dist/pi-tui/autocomplete.d.ts.map +1 -0
  33. package/dist/pi-tui/autocomplete.js +636 -0
  34. package/dist/pi-tui/autocomplete.js.map +1 -0
  35. package/dist/pi-tui/components/box.d.ts +22 -0
  36. package/dist/pi-tui/components/box.d.ts.map +1 -0
  37. package/dist/pi-tui/components/box.js +104 -0
  38. package/dist/pi-tui/components/box.js.map +1 -0
  39. package/dist/pi-tui/components/cancellable-loader.d.ts +22 -0
  40. package/dist/pi-tui/components/cancellable-loader.d.ts.map +1 -0
  41. package/dist/pi-tui/components/cancellable-loader.js +35 -0
  42. package/dist/pi-tui/components/cancellable-loader.js.map +1 -0
  43. package/dist/pi-tui/components/editor.d.ts +249 -0
  44. package/dist/pi-tui/components/editor.d.ts.map +1 -0
  45. package/dist/pi-tui/components/editor.js +1883 -0
  46. package/dist/pi-tui/components/editor.js.map +1 -0
  47. package/dist/pi-tui/components/image.d.ts +28 -0
  48. package/dist/pi-tui/components/image.d.ts.map +1 -0
  49. package/dist/pi-tui/components/image.js +90 -0
  50. package/dist/pi-tui/components/image.js.map +1 -0
  51. package/dist/pi-tui/components/input.d.ts +37 -0
  52. package/dist/pi-tui/components/input.d.ts.map +1 -0
  53. package/dist/pi-tui/components/input.js +382 -0
  54. package/dist/pi-tui/components/input.js.map +1 -0
  55. package/dist/pi-tui/components/loader.d.ts +31 -0
  56. package/dist/pi-tui/components/loader.d.ts.map +1 -0
  57. package/dist/pi-tui/components/loader.js +70 -0
  58. package/dist/pi-tui/components/loader.js.map +1 -0
  59. package/dist/pi-tui/components/markdown.d.ts +96 -0
  60. package/dist/pi-tui/components/markdown.d.ts.map +1 -0
  61. package/dist/pi-tui/components/markdown.js +647 -0
  62. package/dist/pi-tui/components/markdown.js.map +1 -0
  63. package/dist/pi-tui/components/select-list.d.ts +50 -0
  64. package/dist/pi-tui/components/select-list.d.ts.map +1 -0
  65. package/dist/pi-tui/components/select-list.js +167 -0
  66. package/dist/pi-tui/components/select-list.js.map +1 -0
  67. package/dist/pi-tui/components/settings-list.d.ts +50 -0
  68. package/dist/pi-tui/components/settings-list.d.ts.map +1 -0
  69. package/dist/pi-tui/components/settings-list.js +189 -0
  70. package/dist/pi-tui/components/settings-list.js.map +1 -0
  71. package/dist/pi-tui/components/spacer.d.ts +12 -0
  72. package/dist/pi-tui/components/spacer.d.ts.map +1 -0
  73. package/dist/pi-tui/components/spacer.js +23 -0
  74. package/dist/pi-tui/components/spacer.js.map +1 -0
  75. package/dist/pi-tui/components/text.d.ts +19 -0
  76. package/dist/pi-tui/components/text.d.ts.map +1 -0
  77. package/dist/pi-tui/components/text.js +91 -0
  78. package/dist/pi-tui/components/text.js.map +1 -0
  79. package/dist/pi-tui/components/truncated-text.d.ts +13 -0
  80. package/dist/pi-tui/components/truncated-text.d.ts.map +1 -0
  81. package/dist/pi-tui/components/truncated-text.js +51 -0
  82. package/dist/pi-tui/components/truncated-text.js.map +1 -0
  83. package/dist/pi-tui/editor-component.d.ts +39 -0
  84. package/dist/pi-tui/editor-component.d.ts.map +1 -0
  85. package/dist/pi-tui/editor-component.js +2 -0
  86. package/dist/pi-tui/editor-component.js.map +1 -0
  87. package/dist/pi-tui/fuzzy.d.ts +16 -0
  88. package/dist/pi-tui/fuzzy.d.ts.map +1 -0
  89. package/dist/pi-tui/fuzzy.js +110 -0
  90. package/dist/pi-tui/fuzzy.js.map +1 -0
  91. package/dist/pi-tui/index.d.ts +23 -0
  92. package/dist/pi-tui/index.d.ts.map +1 -0
  93. package/dist/pi-tui/index.js +32 -0
  94. package/dist/pi-tui/index.js.map +1 -0
  95. package/dist/pi-tui/keybindings.d.ts +193 -0
  96. package/dist/pi-tui/keybindings.d.ts.map +1 -0
  97. package/dist/pi-tui/keybindings.js +174 -0
  98. package/dist/pi-tui/keybindings.js.map +1 -0
  99. package/dist/pi-tui/keys.d.ts +184 -0
  100. package/dist/pi-tui/keys.d.ts.map +1 -0
  101. package/dist/pi-tui/keys.js +1182 -0
  102. package/dist/pi-tui/keys.js.map +1 -0
  103. package/dist/pi-tui/kill-ring.d.ts +28 -0
  104. package/dist/pi-tui/kill-ring.d.ts.map +1 -0
  105. package/dist/pi-tui/kill-ring.js +44 -0
  106. package/dist/pi-tui/kill-ring.js.map +1 -0
  107. package/dist/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  108. package/dist/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  109. package/dist/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  110. package/dist/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  111. package/dist/pi-tui/native-modifiers.d.ts +3 -0
  112. package/dist/pi-tui/native-modifiers.d.ts.map +1 -0
  113. package/dist/pi-tui/native-modifiers.js +53 -0
  114. package/dist/pi-tui/native-modifiers.js.map +1 -0
  115. package/dist/pi-tui/stdin-buffer.d.ts +50 -0
  116. package/dist/pi-tui/stdin-buffer.d.ts.map +1 -0
  117. package/dist/pi-tui/stdin-buffer.js +361 -0
  118. package/dist/pi-tui/stdin-buffer.js.map +1 -0
  119. package/dist/pi-tui/terminal-image.d.ts +90 -0
  120. package/dist/pi-tui/terminal-image.d.ts.map +1 -0
  121. package/dist/pi-tui/terminal-image.js +343 -0
  122. package/dist/pi-tui/terminal-image.js.map +1 -0
  123. package/dist/pi-tui/terminal.d.ts +113 -0
  124. package/dist/pi-tui/terminal.d.ts.map +1 -0
  125. package/dist/pi-tui/terminal.js +478 -0
  126. package/dist/pi-tui/terminal.js.map +1 -0
  127. package/dist/pi-tui/tui.d.ts +227 -0
  128. package/dist/pi-tui/tui.d.ts.map +1 -0
  129. package/dist/pi-tui/tui.js +1091 -0
  130. package/dist/pi-tui/tui.js.map +1 -0
  131. package/dist/pi-tui/undo-stack.d.ts +17 -0
  132. package/dist/pi-tui/undo-stack.d.ts.map +1 -0
  133. package/dist/pi-tui/undo-stack.js +25 -0
  134. package/dist/pi-tui/undo-stack.js.map +1 -0
  135. package/dist/pi-tui/utils.d.ts +84 -0
  136. package/dist/pi-tui/utils.d.ts.map +1 -0
  137. package/dist/pi-tui/utils.js +1024 -0
  138. package/dist/pi-tui/utils.js.map +1 -0
  139. package/dist/pi-tui/word-navigation.d.ts +25 -0
  140. package/dist/pi-tui/word-navigation.d.ts.map +1 -0
  141. package/dist/pi-tui/word-navigation.js +98 -0
  142. package/dist/pi-tui/word-navigation.js.map +1 -0
  143. package/dist/tools/__tests__/edit.test.js +85 -0
  144. package/dist/tools/__tests__/edit.test.js.map +1 -1
  145. package/dist/tools/edit.d.ts.map +1 -1
  146. package/dist/tools/edit.js +23 -1
  147. package/dist/tools/edit.js.map +1 -1
  148. package/dist/tui/components.d.ts +1 -1
  149. package/dist/tui/components.d.ts.map +1 -1
  150. package/dist/tui/components.js +1 -1
  151. package/dist/tui/components.js.map +1 -1
  152. package/dist/tui/editor-patch.d.ts +13 -0
  153. package/dist/tui/editor-patch.d.ts.map +1 -0
  154. package/dist/tui/editor-patch.js +41 -0
  155. package/dist/tui/editor-patch.js.map +1 -0
  156. package/dist/tui/index.d.ts.map +1 -1
  157. package/dist/tui/index.js +15 -3
  158. package/dist/tui/index.js.map +1 -1
  159. package/dist/web/index.d.ts.map +1 -1
  160. package/dist/web/index.js +9 -2
  161. package/dist/web/index.js.map +1 -1
  162. package/dist/web/public/app.js +65 -4
  163. package/dist/web/public/style.css +49 -0
  164. package/dist/web/session-bridge.d.ts +0 -4
  165. package/dist/web/session-bridge.d.ts.map +1 -1
  166. package/dist/web/session-bridge.js +16 -2
  167. package/dist/web/session-bridge.js.map +1 -1
  168. package/package.json +5 -3
  169. package/src/agent/__tests__/compaction.test.ts +6 -4
  170. package/src/agent/llm.ts +5 -1
  171. package/src/agent/prompt-builder.ts +27 -8
  172. package/src/agent/providers/anthropic.ts +15 -8
  173. package/src/agent/providers/google.ts +6 -1
  174. package/src/agent/providers/ollama.ts +6 -1
  175. package/src/agent/providers/openai.ts +21 -12
  176. package/src/agent/session.ts +12 -2
  177. package/src/pi-tui/autocomplete.ts +808 -0
  178. package/src/pi-tui/components/box.ts +137 -0
  179. package/src/pi-tui/components/cancellable-loader.ts +40 -0
  180. package/src/pi-tui/components/editor.ts +2321 -0
  181. package/src/pi-tui/components/image.ts +130 -0
  182. package/src/pi-tui/components/input.ts +456 -0
  183. package/src/pi-tui/components/loader.ts +93 -0
  184. package/src/pi-tui/components/markdown.ts +839 -0
  185. package/src/pi-tui/components/select-list.ts +263 -0
  186. package/src/pi-tui/components/settings-list.ts +264 -0
  187. package/src/pi-tui/components/spacer.ts +28 -0
  188. package/src/pi-tui/components/text.ts +113 -0
  189. package/src/pi-tui/components/truncated-text.ts +65 -0
  190. package/src/pi-tui/editor-component.ts +74 -0
  191. package/src/pi-tui/fuzzy.ts +137 -0
  192. package/src/pi-tui/index.ts +115 -0
  193. package/src/pi-tui/keybindings.ts +245 -0
  194. package/src/pi-tui/keys.ts +1440 -0
  195. package/src/pi-tui/kill-ring.ts +46 -0
  196. package/src/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  197. package/src/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  198. package/src/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  199. package/src/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  200. package/src/pi-tui/native-modifiers.ts +65 -0
  201. package/src/pi-tui/stdin-buffer.ts +434 -0
  202. package/src/pi-tui/terminal-image.ts +469 -0
  203. package/src/pi-tui/terminal.ts +594 -0
  204. package/src/pi-tui/tui.ts +1376 -0
  205. package/src/pi-tui/undo-stack.ts +28 -0
  206. package/src/pi-tui/utils.ts +1176 -0
  207. package/src/pi-tui/word-navigation.ts +127 -0
  208. package/src/tools/__tests__/edit.test.ts +91 -0
  209. package/src/tools/edit.ts +27 -1
  210. package/src/tui/components.ts +2 -2
  211. package/src/tui/index.ts +24 -10
  212. package/src/web/index.ts +9 -2
  213. package/src/web/public/app.js +65 -4
  214. package/src/web/public/style.css +49 -0
  215. package/src/web/session-bridge.ts +13 -2
  216. package/tsconfig.json +2 -2
@@ -0,0 +1,127 @@
1
+ import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from './utils.js'
2
+
3
+ const wordSegmenter = getWordSegmenter()
4
+
5
+ /**
6
+ * Options for word navigation functions.
7
+ * When omitted, uses the default Intl.Segmenter word segmentation.
8
+ */
9
+ export interface WordNavigationOptions {
10
+ /** Custom segmenter returning word segments for the given text. */
11
+ segment?: (text: string) => Iterable<Intl.SegmentData>
12
+ /** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */
13
+ isAtomicSegment?: (segment: string) => boolean
14
+ }
15
+
16
+ /**
17
+ * Find the cursor position after moving one word backward from `cursor` in `text`.
18
+ * Skips trailing whitespace, then stops at the next word/punctuation boundary.
19
+ *
20
+ * Pure function - does not mutate any state.
21
+ */
22
+ export function findWordBackward(
23
+ text: string,
24
+ cursor: number,
25
+ options?: WordNavigationOptions,
26
+ ): number {
27
+ if (cursor <= 0) return 0
28
+
29
+ const textBeforeCursor = text.slice(0, cursor)
30
+ const segmentFn = options?.segment
31
+ const isAtomic = options?.isAtomicSegment
32
+ const segments = segmentFn
33
+ ? [...segmentFn(textBeforeCursor)]
34
+ : [...wordSegmenter.segment(textBeforeCursor)]
35
+ let newCursor = cursor
36
+
37
+ // Skip trailing whitespace
38
+ while (
39
+ segments.length > 0 &&
40
+ !isAtomic?.(segments[segments.length - 1]?.segment || '') &&
41
+ isWhitespaceChar(segments[segments.length - 1]?.segment || '')
42
+ ) {
43
+ newCursor -= segments.pop()?.segment.length || 0
44
+ }
45
+
46
+ if (segments.length === 0) return newCursor
47
+
48
+ const last = segments[segments.length - 1]!
49
+
50
+ if (isAtomic?.(last.segment)) {
51
+ // Skip one atomic segment.
52
+ newCursor -= last.segment.length
53
+ } else if (last.isWordLike) {
54
+ // Skip inside one word-like segment, preserving ASCII punctuation boundaries.
55
+ const segment = last.segment
56
+ const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, 'g'))]
57
+ if (matches.length <= 0) {
58
+ newCursor -= segment.length
59
+ } else {
60
+ const lastMatch = matches[matches.length - 1]!
61
+ newCursor -= segment.length - (lastMatch.index + lastMatch[0].length)
62
+ }
63
+ } else {
64
+ // Skip non-word non-whitespace run (punctuation)
65
+ while (
66
+ segments.length > 0 &&
67
+ !isAtomic?.(segments[segments.length - 1]?.segment || '') &&
68
+ !segments[segments.length - 1]?.isWordLike &&
69
+ !isWhitespaceChar(segments[segments.length - 1]?.segment || '')
70
+ ) {
71
+ newCursor -= segments.pop()?.segment.length || 0
72
+ }
73
+ }
74
+
75
+ return newCursor
76
+ }
77
+
78
+ /**
79
+ * Find the cursor position after moving one word forward from `cursor` in `text`.
80
+ * Skips leading whitespace, then stops at the next word/punctuation boundary.
81
+ *
82
+ * Pure function - does not mutate any state.
83
+ */
84
+ export function findWordForward(
85
+ text: string,
86
+ cursor: number,
87
+ options?: WordNavigationOptions,
88
+ ): number {
89
+ if (cursor >= text.length) return text.length
90
+
91
+ const textAfterCursor = text.slice(cursor)
92
+ const segmentFn = options?.segment
93
+ const isAtomic = options?.isAtomicSegment
94
+ const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor)
95
+ const iterator = segments[Symbol.iterator]()
96
+ let next = iterator.next()
97
+ let newCursor = cursor
98
+
99
+ // Skip leading whitespace
100
+ while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) {
101
+ newCursor += next.value.segment.length
102
+ next = iterator.next()
103
+ }
104
+
105
+ if (next.done) return newCursor
106
+
107
+ if (isAtomic?.(next.value.segment)) {
108
+ // Skip one atomic segment.
109
+ newCursor += next.value.segment.length
110
+ } else if (next.value.isWordLike) {
111
+ // Skip inside one word-like segment, preserving ASCII punctuation boundaries.
112
+ newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length
113
+ } else {
114
+ // Skip non-word non-whitespace run (punctuation)
115
+ while (
116
+ !next.done &&
117
+ !isAtomic?.(next.value.segment) &&
118
+ !next.value.isWordLike &&
119
+ !isWhitespaceChar(next.value.segment)
120
+ ) {
121
+ newCursor += next.value.segment.length
122
+ next = iterator.next()
123
+ }
124
+ }
125
+
126
+ return newCursor
127
+ }
@@ -155,6 +155,97 @@ describe('edit tool', () => {
155
155
  })
156
156
  })
157
157
 
158
+ describe('diff output', () => {
159
+ beforeAll(() => {
160
+ fs.writeFileSync(path.join(tmpDir, 'diff-test.txt'), 'line one\nline two\nline three\n')
161
+ })
162
+
163
+ it('shows green + for new file creation (no red - lines)', async () => {
164
+ const r = await tool().execute({
165
+ edits: [{ file_path: 'diff-new.txt', old_string: '', new_string: 'hello\nworld' }],
166
+ })
167
+ expect(r.success).toBe(true)
168
+ // Should contain green ANSI code for added lines
169
+ expect(r.output).toContain('\x1b[38;2;0;200;100m') // green
170
+ expect(r.output).toContain('+ hello')
171
+ expect(r.output).toContain('+ world')
172
+ // Should NOT contain red ANSI code (no removed lines)
173
+ expect(r.output).not.toContain('\x1b[38;2;255;80;80m')
174
+ expect(r.output).toContain('Created')
175
+ })
176
+
177
+ it('shows red - for removed lines and green + for added lines on replacement', async () => {
178
+ const r = await tool().execute({
179
+ edits: [
180
+ {
181
+ file_path: 'diff-test.txt',
182
+ old_string: 'line two',
183
+ new_string: 'line TWO',
184
+ },
185
+ ],
186
+ })
187
+ expect(r.success).toBe(true)
188
+ // Should contain red ANSI code for removed content
189
+ expect(r.output).toContain('\x1b[38;2;255;80;80m')
190
+ expect(r.output).toContain('- line two')
191
+ // Should contain green ANSI code for added content
192
+ expect(r.output).toContain('\x1b[38;2;0;200;100m')
193
+ expect(r.output).toContain('+ line TWO')
194
+ expect(r.output).toContain('Edited')
195
+ })
196
+
197
+ it('shows multi-line diff correctly', async () => {
198
+ const r = await tool().execute({
199
+ edits: [
200
+ {
201
+ file_path: 'diff-test.txt',
202
+ old_string: 'line one\nline TWO',
203
+ new_string: 'line 1\nline 2',
204
+ },
205
+ ],
206
+ })
207
+ expect(r.success).toBe(true)
208
+ // Removed lines
209
+ expect(r.output).toContain('- line one')
210
+ expect(r.output).toContain('- line TWO')
211
+ // Added lines
212
+ expect(r.output).toContain('+ line 1')
213
+ expect(r.output).toContain('+ line 2')
214
+ })
215
+
216
+ it('handles CRLF in old_string (Windows compatibility)', async () => {
217
+ fs.writeFileSync(path.join(tmpDir, 'crlf-test.txt'), 'a\\r\\nb\\r\\nc\\r\\n')
218
+ const r = await tool().execute({
219
+ edits: [
220
+ {
221
+ file_path: 'crlf-test.txt',
222
+ // Simulate AI sending old_string with \\r\\n (CRLF) on Windows
223
+ old_string: 'b\\r\\n',
224
+ new_string: 'B\\r\\n',
225
+ },
226
+ ],
227
+ })
228
+ expect(r.success).toBe(true)
229
+ const content = fs.readFileSync(path.join(tmpDir, 'crlf-test.txt'), 'utf8')
230
+ expect(content).toContain('B')
231
+ // Should show the normalized diff (with \\n, not \\r\\n)
232
+ expect(r.output).toContain('- b')
233
+ expect(r.output).toContain('+ B')
234
+ })
235
+
236
+ it('does not show empty red line when old_string is empty', async () => {
237
+ const r = await tool().execute({
238
+ edits: [{ file_path: 'empty-old.txt', old_string: '', new_string: 'only added' }],
239
+ })
240
+ expect(r.success).toBe(true)
241
+ // Should have green + lines
242
+ expect(r.output).toContain('+ only added')
243
+ // Count red-start markers — should be 0 (no removed content)
244
+ const redCount = (r.output.match(/\x1b\[38;2;255;80;80m/g) || []).length
245
+ expect(redCount).toBe(0)
246
+ })
247
+ })
248
+
158
249
  describe('create mode', () => {
159
250
  const createdDir = () => path.join(tmpDir, 'created-test')
160
251
 
package/src/tools/edit.ts CHANGED
@@ -4,6 +4,24 @@ import type { FileReadTracker } from '../diff/apply.js'
4
4
  import { fmtErr } from './errors.js'
5
5
  import type { Tool, ToolResult } from './types.js'
6
6
 
7
+ // ── ANSI colors for diff output ─────────────────────────────────────────
8
+ const DIFF_RED = '\x1b[38;2;255;80;80m'
9
+ const DIFF_GREEN = '\x1b[38;2;0;200;100m'
10
+ const DIFF_RESET = '\x1b[0m'
11
+
12
+ function generateDiff(oldStr: string, newStr: string): string {
13
+ const oldLines = oldStr === '' ? [] : oldStr.split('\n')
14
+ const newLines = newStr.split('\n')
15
+ const lines: string[] = []
16
+ for (const line of oldLines) {
17
+ lines.push(` ${DIFF_RED}- ${line}${DIFF_RESET}`)
18
+ }
19
+ for (const line of newLines) {
20
+ lines.push(` ${DIFF_GREEN}+ ${line}${DIFF_RESET}`)
21
+ }
22
+ return lines.join('\n')
23
+ }
24
+
7
25
  interface SingleEdit {
8
26
  file_path: string
9
27
  old_string: string
@@ -155,6 +173,9 @@ EXAMPLES:
155
173
 
156
174
  for (let i = group.edits.length - 1; i >= 0; i--) {
157
175
  const e = group.edits[i]
176
+ // Normalize CRLF → LF in AI-provided strings (critical on Windows)
177
+ e.old_string = e.old_string.replace(/\r\n/g, '\n')
178
+ e.new_string = e.new_string.replace(/\r\n/g, '\n')
158
179
 
159
180
  if (e.old_string === '') {
160
181
  if (content !== null) {
@@ -170,8 +191,11 @@ EXAMPLES:
170
191
  }
171
192
  break
172
193
  }
194
+ const newLines = e.new_string.split('\n').length
173
195
  content = e.new_string
174
- results.push(` Created ${relPath} (${e.new_string.split('\n').length} lines)`)
196
+ results.push(
197
+ ` Created ${relPath} (${newLines} lines):\n${generateDiff('', e.new_string)}`,
198
+ )
175
199
  continue
176
200
  }
177
201
 
@@ -218,6 +242,8 @@ EXAMPLES:
218
242
  break
219
243
  }
220
244
 
245
+ const diff = generateDiff(e.old_string, e.new_string)
246
+ results.push(` Edited ${relPath}:\n${diff}`)
221
247
  content = content.slice(0, idx) + e.new_string + content.slice(idx + e.old_string.length)
222
248
  }
223
249
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs'
2
2
  import * as path from 'node:path'
3
- import type { Component, SelectItem, SelectListTheme } from '@earendil-works/pi-tui'
4
- import { SelectList } from '@earendil-works/pi-tui'
3
+ import type { Component, SelectItem, SelectListTheme } from '../pi-tui/index.js'
4
+ import { SelectList } from '../pi-tui/index.js'
5
5
  import { PLAN_DIR } from '../tools/write_plan.js'
6
6
  import { visibleLen } from './utils.js'
7
7
 
package/src/tui/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import * as fs from 'node:fs'
2
2
  import * as path from 'node:path'
3
+ import { resetGlobalEventBus } from '../agent/event-bus.js'
4
+ import { ensurePromptsDir, loadPromptTemplates } from '../agent/prompt-templates.js'
5
+ import { formatToolInput, Session, type SessionOutput } from '../agent/session.js'
6
+ import { ensureSkillsDir, loadSkills } from '../agent/skills.js'
7
+ import type { Config } from '../config/index.js'
8
+ import { loadTokenUsage, resetTokenUsage } from '../config/tokens.js'
3
9
  import type {
4
10
  EditorTheme,
5
11
  MarkdownTheme,
@@ -7,7 +13,7 @@ import type {
7
13
  SelectItem,
8
14
  SelectListTheme,
9
15
  SlashCommand,
10
- } from '@earendil-works/pi-tui'
16
+ } from '../pi-tui/index.js'
11
17
  import {
12
18
  Box,
13
19
  CombinedAutocompleteProvider,
@@ -18,13 +24,7 @@ import {
18
24
  ProcessTerminal,
19
25
  Text,
20
26
  TUI,
21
- } from '@earendil-works/pi-tui'
22
- import { resetGlobalEventBus } from '../agent/event-bus.js'
23
- import { ensurePromptsDir, loadPromptTemplates } from '../agent/prompt-templates.js'
24
- import { formatToolInput, Session, type SessionOutput } from '../agent/session.js'
25
- import { ensureSkillsDir, loadSkills } from '../agent/skills.js'
26
- import type { Config } from '../config/index.js'
27
- import { loadTokenUsage, resetTokenUsage } from '../config/tokens.js'
27
+ } from '../pi-tui/index.js'
28
28
  import { fmtErr } from '../tools/errors.js'
29
29
  import { fetchDeepSeekBalance, isDeepSeekOfficial } from './balance.js'
30
30
  import {
@@ -38,6 +38,7 @@ import {
38
38
  TodoPanel,
39
39
  } from './components.js'
40
40
  import { highlightLine } from './highlight.js'
41
+
41
42
  export async function startTui(config: Config): Promise<void> {
42
43
  let chatContent = ''
43
44
  let isRunning = false
@@ -293,7 +294,7 @@ export async function startTui(config: Config): Promise<void> {
293
294
  anchor: 'bottom-left',
294
295
  width: terminal.columns ?? process.stdout.columns ?? 120,
295
296
  offsetY: -1,
296
- maxHeight: 6,
297
+ maxHeight: 12,
297
298
  nonCapturing: false,
298
299
  })
299
300
  showTodoPanel()
@@ -482,6 +483,15 @@ export async function startTui(config: Config): Promise<void> {
482
483
  }
483
484
 
484
485
  if (cmd === 'new') {
486
+ // If the agent is running, stop the old session gracefully first.
487
+ // Without this, the pending chat() promise would continue consuming
488
+ // tokens and write stale output into the freshly cleared chat display.
489
+ if (isRunning) {
490
+ session.stop()
491
+ isRunning = false
492
+ loader.setMessage('')
493
+ tui.setShowHardwareCursor(true)
494
+ }
485
495
  Session.clearSavedSession(config.cwd)
486
496
  resetTokenUsage(config.cwd)
487
497
  resetGlobalEventBus()
@@ -490,6 +500,10 @@ export async function startTui(config: Config): Promise<void> {
490
500
  chatContent = ''
491
501
  chatMarkdown.setText('')
492
502
  plansList.clearFilter()
503
+ // Reset editor internal state that setText('') doesn't clear
504
+ ;(editor as any).undoStack.clear()
505
+ ;(editor as any).history = []
506
+ ;(editor as any).killRing.ring = []
493
507
  updateFooter()
494
508
  return
495
509
  }
@@ -652,7 +666,7 @@ export async function startTui(config: Config): Promise<void> {
652
666
  anchor: 'bottom-left',
653
667
  width: terminal.columns ?? process.stdout.columns ?? 120,
654
668
  offsetY: -1,
655
- maxHeight: 6,
669
+ maxHeight: 12,
656
670
  nonCapturing: false,
657
671
  })
658
672
 
package/src/web/index.ts CHANGED
@@ -357,8 +357,15 @@ export async function startWebUi(config: Config, port: number): Promise<void> {
357
357
  sendPlanData()
358
358
  })()
359
359
 
360
- // Send full session history (exclude system prompt)
361
- const historyMessages = sessionWithOutput.messages.filter(m => m.role !== 'system')
360
+ // Send full session history (exclude system prompt, strip ANSI from tool results)
361
+ const historyMessages = sessionWithOutput.messages
362
+ .filter(m => m.role !== 'system')
363
+ .map(m => {
364
+ if (m.role === 'tool' && typeof m.content === 'string') {
365
+ return { ...m, content: stripAnsi(m.content) }
366
+ }
367
+ return m
368
+ })
362
369
  if (historyMessages.length > 0) {
363
370
  ws.send(
364
371
  JSON.stringify({
@@ -257,8 +257,21 @@
257
257
  const div = document.createElement('div')
258
258
  div.className = id ? 'tool-call executing' : 'tool-call'
259
259
  div.dataset.toolId = id || ''
260
- const inputStr = typeof input === 'object' ? JSON.stringify(input).slice(0, 120) : String(input)
261
- div.innerHTML = `<span class="tool-name">${escapeHtml(name)}</span> <span class="tool-input">${escapeHtml(inputStr)}</span>`
260
+
261
+ if (name === 'edit') {
262
+ // Special display for edit tool: show file paths being edited
263
+ const edits = input && input.edits
264
+ const paths = Array.isArray(edits) ? edits.map(e => e.file_path).filter(Boolean) : []
265
+ const uniquePaths = [...new Set(paths)]
266
+ const pathsStr =
267
+ uniquePaths.length > 0 ? uniquePaths.map(p => escapeHtml(p)).join(', ') : '(no files)'
268
+ div.innerHTML = `<span class="tool-name tool-name-edit">✏ edit</span> <span class="tool-input tool-input-edit">${pathsStr}</span>`
269
+ } else {
270
+ const inputStr =
271
+ typeof input === 'object' ? JSON.stringify(input).slice(0, 120) : String(input)
272
+ div.innerHTML = `<span class="tool-name">${escapeHtml(name)}</span> <span class="tool-input">${escapeHtml(inputStr)}</span>`
273
+ }
274
+
262
275
  if (container && container.matches('.message')) {
263
276
  container.appendChild(div)
264
277
  } else {
@@ -268,6 +281,21 @@
268
281
  scrollToBottom()
269
282
  }
270
283
 
284
+ function renderDiffContent(text) {
285
+ const lines = text.split('\n')
286
+ return lines
287
+ .map(line => {
288
+ const trimmed = line.trim()
289
+ if (trimmed.startsWith('+ ')) {
290
+ return `<span class="diff-added">${escapeHtml(line)}\n</span>`
291
+ } else if (trimmed.startsWith('- ')) {
292
+ return `<span class="diff-removed">${escapeHtml(line)}\n</span>`
293
+ }
294
+ return escapeHtml(line) + '\n'
295
+ })
296
+ .join('')
297
+ }
298
+
271
299
  function addToolResult(name, success, outputOrError, id) {
272
300
  // Remove from pending tracking and clear executing state
273
301
  if (id) {
@@ -275,6 +303,10 @@
275
303
  if (pendingEl) {
276
304
  pendingEl.classList.remove('executing')
277
305
  pendingToolCalls.delete(id)
306
+ // For edit tool, update the tool-call element to show completion summary
307
+ if (name === 'edit' && success) {
308
+ pendingEl.classList.add('completed')
309
+ }
278
310
  }
279
311
  }
280
312
  const container = streamingMsgEl || messagesEl.querySelector('.message:last-child')
@@ -283,8 +315,37 @@
283
315
  if (success) {
284
316
  let display = outputOrError
285
317
  if (display === '(no output)') display = ''
286
- const summary = typeof display === 'string' ? display.slice(0, 80) : ''
287
- div.innerHTML = `<span class="tool-result-success">✔ ${escapeHtml(name)}</span>${summary ? ' ' + escapeHtml(summary) : ''}`
318
+ // Special display for edit tool success show rich diff content
319
+ if (name === 'edit' && display) {
320
+ const lines = display.split('\n')
321
+ const summaryLines = []
322
+ const diffLines = []
323
+ for (const line of lines) {
324
+ const trimmed = line.trim()
325
+ if (
326
+ trimmed.startsWith('Edited ') ||
327
+ trimmed.startsWith('Created ') ||
328
+ trimmed.startsWith('Deleted ')
329
+ ) {
330
+ summaryLines.push(trimmed)
331
+ } else if (trimmed.startsWith('- ') || trimmed.startsWith('+ ')) {
332
+ diffLines.push(line)
333
+ }
334
+ }
335
+ let html = `<span class="tool-result-success">✔ ${escapeHtml(name)}</span>`
336
+ if (summaryLines.length > 0) {
337
+ html += `<span class="tool-result-summary">${summaryLines.map(s => escapeHtml(s)).join('<br>')}</span>`
338
+ }
339
+ if (diffLines.length > 0) {
340
+ html += `<span class="tool-result-diff"><pre><code>${renderDiffContent(diffLines.join('\n'))}</code></pre></span>`
341
+ } else if (display.includes(' FAIL ')) {
342
+ html += `<span class="tool-result-diff"><pre>${escapeHtml(display)}</pre></span>`
343
+ }
344
+ div.innerHTML = html
345
+ } else {
346
+ const summary = typeof display === 'string' ? display.slice(0, 80) : ''
347
+ div.innerHTML = `<span class="tool-result-success">✔ ${escapeHtml(name)}</span>${summary ? ' ' + escapeHtml(summary) : ''}`
348
+ }
288
349
  } else {
289
350
  div.innerHTML = `<span class="tool-result-error">✖ ${escapeHtml(name)}</span> ${escapeHtml(outputOrError)}`
290
351
  }
@@ -347,10 +347,59 @@ body {
347
347
  }
348
348
 
349
349
  .tool-call .tool-name { color: var(--accent-dim); }
350
+ .tool-call .tool-name.tool-name-edit { color: var(--warning); }
350
351
  .tool-call .tool-input { color: var(--text-dim); }
352
+ .tool-call .tool-input.tool-input-edit { font-family: var(--font-mono); font-size: 0.85em; }
353
+ .tool-call.completed { opacity: 0.6; }
354
+ .tool-call.completed .tool-name::before { display: none; }
351
355
 
352
356
  .tool-result .tool-result-success { color: var(--success); }
353
357
  .tool-result .tool-result-error { color: var(--error); }
358
+ .tool-result .tool-result-summary {
359
+ display: block;
360
+ color: var(--text-secondary);
361
+ font-size: 0.85em;
362
+ margin-top: 2px;
363
+ line-height: 1.3;
364
+ }
365
+
366
+ /* ── Edit Diff Display ─────────────────────────────────────────── */
367
+ .tool-result .tool-result-diff {
368
+ display: block;
369
+ margin-top: 2px;
370
+ }
371
+
372
+ .tool-result .tool-result-diff pre {
373
+ max-height: 300px;
374
+ max-width: 100%;
375
+ overflow-y: auto;
376
+ overflow-x: auto;
377
+ background: var(--input-bg);
378
+ border: 1px solid var(--border);
379
+ border-radius: 6px;
380
+ padding: 8px 12px;
381
+ margin: 4px 0;
382
+ font-size: 12px;
383
+ line-height: 1.4;
384
+ white-space: pre;
385
+ word-break: break-all;
386
+ }
387
+
388
+ .tool-result .tool-result-diff code {
389
+ font-family: var(--font-mono);
390
+ font-size: inherit;
391
+ color: inherit;
392
+ background: none;
393
+ padding: 0;
394
+ }
395
+
396
+ .tool-result .diff-added {
397
+ color: var(--success);
398
+ }
399
+
400
+ .tool-result .diff-removed {
401
+ color: var(--error);
402
+ }
354
403
 
355
404
  /* ── Thinking Block ──────────────────────────────────────────── */
356
405
  .thinking-block {
@@ -7,6 +7,11 @@ import type { Config } from '../config/index.js'
7
7
  * Listens to EventBus events and forwards them as JSON messages.
8
8
  */
9
9
 
10
+ /** Strip ANSI escape codes from a string (terminal colors are meaningless in the browser) */
11
+ function stripAnsi(text: string): string {
12
+ return text.replace(/\x1b\[[0-9;]*m/g, '')
13
+ }
14
+
10
15
  export interface WsMessage {
11
16
  type: string
12
17
  [key: string]: unknown
@@ -49,12 +54,18 @@ export function startSessionBridge(
49
54
 
50
55
  const unsubToolResult = bus.on(EventChannels.TOOL_RESULT, data => {
51
56
  const d = data as { name: string; id: string; output: string }
52
- send({ type: 'tool_result', name: d.name, id: d.id, success: true, output: d.output })
57
+ send({
58
+ type: 'tool_result',
59
+ name: d.name,
60
+ id: d.id,
61
+ success: true,
62
+ output: stripAnsi(d.output),
63
+ })
53
64
  })
54
65
 
55
66
  const unsubToolError = bus.on(EventChannels.TOOL_ERROR, data => {
56
67
  const d = data as { name: string; id: string; error: string }
57
- send({ type: 'tool_result', name: d.name, id: d.id, success: false, error: d.error })
68
+ send({ type: 'tool_result', name: d.name, id: d.id, success: false, error: stripAnsi(d.error) })
58
69
  })
59
70
 
60
71
  const unsubTurnEnd = bus.on(EventChannels.TURN_END, data => {
package/tsconfig.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "target": "ES2022",
3
+ "target": "ES2024",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
- "lib": ["ES2022"],
6
+ "lib": ["ES2024"],
7
7
  "outDir": "dist",
8
8
  "rootDir": "src",
9
9
  "strict": true,