lonny-agent 0.2.3 → 0.2.6

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 (129) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/llm.d.ts +4 -0
  5. package/dist/agent/llm.d.ts.map +1 -1
  6. package/dist/agent/project.d.ts +26 -0
  7. package/dist/agent/project.d.ts.map +1 -0
  8. package/dist/agent/project.js +303 -0
  9. package/dist/agent/project.js.map +1 -0
  10. package/dist/agent/prompt-builder.d.ts +1 -1
  11. package/dist/agent/prompt-builder.d.ts.map +1 -1
  12. package/dist/agent/prompt-builder.js +22 -13
  13. package/dist/agent/prompt-builder.js.map +1 -1
  14. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  15. package/dist/agent/providers/anthropic.js +20 -2
  16. package/dist/agent/providers/anthropic.js.map +1 -1
  17. package/dist/agent/providers/google.d.ts.map +1 -1
  18. package/dist/agent/providers/google.js +1 -1
  19. package/dist/agent/providers/google.js.map +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.map +1 -1
  24. package/dist/agent/providers/openai.js +63 -3
  25. package/dist/agent/providers/openai.js.map +1 -1
  26. package/dist/agent/session.d.ts +8 -2
  27. package/dist/agent/session.d.ts.map +1 -1
  28. package/dist/agent/session.js +250 -153
  29. package/dist/agent/session.js.map +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/index.js +7 -1
  32. package/dist/config/index.js.map +1 -1
  33. package/dist/tools/__tests__/bash.test.js +1 -1
  34. package/dist/tools/__tests__/bash.test.js.map +1 -1
  35. package/dist/tools/__tests__/edit.test.js +875 -371
  36. package/dist/tools/__tests__/edit.test.js.map +1 -1
  37. package/dist/tools/__tests__/grep.test.js +32 -0
  38. package/dist/tools/__tests__/grep.test.js.map +1 -1
  39. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  40. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  41. package/dist/tools/__tests__/sed.test.js +228 -0
  42. package/dist/tools/__tests__/sed.test.js.map +1 -0
  43. package/dist/tools/bash.d.ts.map +1 -1
  44. package/dist/tools/bash.js +83 -10
  45. package/dist/tools/bash.js.map +1 -1
  46. package/dist/tools/edit.d.ts +58 -0
  47. package/dist/tools/edit.d.ts.map +1 -1
  48. package/dist/tools/edit.js +366 -149
  49. package/dist/tools/edit.js.map +1 -1
  50. package/dist/tools/grep.d.ts.map +1 -1
  51. package/dist/tools/grep.js +54 -30
  52. package/dist/tools/grep.js.map +1 -1
  53. package/dist/tools/read.d.ts +6 -0
  54. package/dist/tools/read.d.ts.map +1 -1
  55. package/dist/tools/read.js +42 -15
  56. package/dist/tools/read.js.map +1 -1
  57. package/dist/tools/registry.d.ts +0 -6
  58. package/dist/tools/registry.d.ts.map +1 -1
  59. package/dist/tools/registry.js +2 -33
  60. package/dist/tools/registry.js.map +1 -1
  61. package/dist/tools/sed.d.ts +4 -0
  62. package/dist/tools/sed.d.ts.map +1 -0
  63. package/dist/tools/sed.js +121 -0
  64. package/dist/tools/sed.js.map +1 -0
  65. package/dist/tools/write_plan.d.ts +1 -1
  66. package/dist/tools/write_plan.d.ts.map +1 -1
  67. package/dist/tools/write_plan.js +14 -4
  68. package/dist/tools/write_plan.js.map +1 -1
  69. package/dist/tui/index.d.ts.map +1 -1
  70. package/dist/tui/index.js +5 -4
  71. package/dist/tui/index.js.map +1 -1
  72. package/dist/web/index.d.ts.map +1 -1
  73. package/dist/web/index.js +30 -7
  74. package/dist/web/index.js.map +1 -1
  75. package/dist/web/public/app.js +5 -855
  76. package/dist/web/public/confirm.js +70 -0
  77. package/dist/web/public/index.html +121 -120
  78. package/dist/web/public/input.js +107 -0
  79. package/dist/web/public/messages.js +395 -0
  80. package/dist/web/public/sidebar.js +82 -0
  81. package/dist/web/public/state.js +72 -0
  82. package/dist/web/public/style.css +996 -949
  83. package/dist/web/public/utils.js +37 -0
  84. package/dist/web/public/websocket.js +267 -0
  85. package/dist/web/public/ws.js +19 -0
  86. package/dist/web/session-bridge.d.ts.map +1 -1
  87. package/dist/web/session-bridge.js +3 -7
  88. package/dist/web/session-bridge.js.map +1 -1
  89. package/package.json +55 -54
  90. package/scripts/copy-native.mjs +24 -0
  91. package/scripts/copy-web.mjs +15 -0
  92. package/src/agent/index.ts +25 -25
  93. package/src/agent/llm.ts +4 -0
  94. package/src/agent/project.ts +366 -0
  95. package/src/agent/prompt-builder.ts +23 -13
  96. package/src/agent/providers/anthropic.ts +179 -172
  97. package/src/agent/providers/google.ts +210 -211
  98. package/src/agent/providers/ollama.ts +186 -182
  99. package/src/agent/providers/openai.ts +73 -5
  100. package/src/agent/session.ts +275 -153
  101. package/src/config/index.ts +342 -335
  102. package/src/tools/__tests__/bash.test.ts +1 -1
  103. package/src/tools/__tests__/edit.test.ts +981 -385
  104. package/src/tools/__tests__/grep.test.ts +35 -0
  105. package/src/tools/bash.ts +97 -11
  106. package/src/tools/edit.ts +414 -154
  107. package/src/tools/git.ts +76 -76
  108. package/src/tools/grep.ts +57 -30
  109. package/src/tools/read.ts +61 -25
  110. package/src/tools/registry.ts +231 -265
  111. package/src/tools/write_plan.ts +21 -5
  112. package/src/tui/index.ts +786 -785
  113. package/src/web/index.ts +29 -7
  114. package/src/web/public/app.js +5 -855
  115. package/src/web/public/confirm.js +70 -0
  116. package/src/web/public/index.html +121 -120
  117. package/src/web/public/input.js +107 -0
  118. package/src/web/public/messages.js +395 -0
  119. package/src/web/public/sidebar.js +82 -0
  120. package/src/web/public/state.js +72 -0
  121. package/src/web/public/style.css +996 -949
  122. package/src/web/public/utils.js +37 -0
  123. package/src/web/public/websocket.js +267 -0
  124. package/src/web/public/ws.js +19 -0
  125. package/src/web/session-bridge.ts +193 -194
  126. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  127. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  128. package/.lonny/plan-web-cwd-status.md +0 -38
  129. package/src/tools/exec.ts +0 -348
package/src/tui/index.ts CHANGED
@@ -1,785 +1,786 @@
1
- import * as fs from 'node:fs'
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'
9
- import type {
10
- EditorTheme,
11
- MarkdownTheme,
12
- OverlayHandle,
13
- SelectItem,
14
- SelectListTheme,
15
- SlashCommand,
16
- } from '../pi-tui/index.js'
17
- import {
18
- Box,
19
- CombinedAutocompleteProvider,
20
- Container,
21
- Editor,
22
- Loader,
23
- Markdown,
24
- ProcessTerminal,
25
- Text,
26
- TUI,
27
- } from '../pi-tui/index.js'
28
- import { fmtErr } from '../tools/errors.js'
29
- import { fetchDeepSeekBalance, isDeepSeekOfficial } from './balance.js'
30
- import {
31
- colors,
32
- LandingScreen,
33
- listPlans,
34
- loadTodos,
35
- PlansList,
36
- plansToItems,
37
- RichFooter,
38
- TodoPanel,
39
- } from './components.js'
40
- import { highlightLine } from './highlight.js'
41
-
42
- /**
43
- * Invisible spacer that renders N empty lines.
44
- * Used to reserve space at the bottom of the chat area so the editor
45
- * overlay doesn't cover the last lines of command output.
46
- */
47
- class Spacer {
48
- constructor(private height: number) {}
49
- render(_width: number): string[] {
50
- return Array.from({ length: this.height }, () => '')
51
- }
52
- invalidate(): void {}
53
- }
54
-
55
- export async function startTui(config: Config): Promise<void> {
56
- let chatContent = ''
57
- let isRunning = false
58
- let session: Session
59
-
60
- // ── Create markdown theme (OpenCode-style, clean colors, with syntax highlighting) ──
61
- const markdownTheme: MarkdownTheme = {
62
- heading: t => `\x1b[38;2;0;170;255m\x1b[1m${t}\x1b[0m`,
63
- link: t => `\x1b[38;2;0;170;255m\x1b[4m${t}\x1b[0m`,
64
- linkUrl: t => `\x1b[38;2;90;90;90m\x1b[4m${t}\x1b[0m`,
65
- code: t => `\x1b[38;2;255;180;50m${t}\x1b[0m`,
66
- codeBlock: t => `\x1b[38;2;200;200;200m${t}\x1b[0m`,
67
- codeBlockBorder: t => `\x1b[38;2;60;60;60m${t}\x1b[0m`,
68
- highlightCode: (code: string, lang?: string) => {
69
- if (lang && lang.trim()) {
70
- const lines = code.split('\n')
71
- return lines.map(line => highlightLine(line, lang))
72
- }
73
- return code.split('\n')
74
- },
75
- codeBlockIndent: ' ',
76
- quote: t => `\x1b[38;2;130;130;130m${t}\x1b[0m`,
77
- quoteBorder: t => `\x1b[38;2;60;60;60m${t}\x1b[0m`,
78
- hr: t => `\x1b[38;2;60;60;60m${t}\x1b[0m`,
79
- listBullet: t => `\x1b[38;2;130;130;130m${t}\x1b[0m`,
80
- bold: t => `\x1b[1m${t}\x1b[0m`,
81
- italic: t => `\x1b[3m${t}\x1b[0m`,
82
- strikethrough: t => `\x1b[9m${t}\x1b[0m`,
83
- underline: t => `\x1b[4m${t}\x1b[0m`,
84
- }
85
-
86
- // ── Create select list theme ──
87
- const selectTheme: SelectListTheme = {
88
- selectedPrefix: t => `\x1b[38;2;255;255;255m\x1b[48;2;0;128;255m ${t}\x1b[0m`,
89
- selectedText: t => `\x1b[38;2;255;255;255m\x1b[48;2;0;128;255m${t}\x1b[0m`,
90
- description: t => `\x1b[90m${t}\x1b[0m`,
91
- scrollInfo: t => `\x1b[90m${t}\x1b[0m`,
92
- noMatch: t => `\x1b[38;2;255;100;100m${t}\x1b[0m`,
93
- }
94
-
95
- // ── Create editor theme (used by Editor component) ────────────────────
96
- const editorTheme: EditorTheme = {
97
- borderColor: (str: string) => colors.accent(str),
98
- selectList: selectTheme,
99
- }
100
-
101
- // ── Create terminal and TUI ────────────────────────────────────────────
102
- const terminal = new ProcessTerminal()
103
- // Patch terminal.write to suppress alternate screen buffer sequences.
104
- // Without this, pi-tui disables the terminal's native scrollback/scrollbar,
105
- // making it impossible to scroll through past conversation history.
106
- const origWrite = terminal.write.bind(terminal)
107
- terminal.write = (data: string) => {
108
- const filtered = data.replace(/\x1b\[\?1049[hl]/g, '')
109
- if (filtered) origWrite(filtered)
110
- }
111
- // Show hardware cursor by default so IME (Chinese input method) can
112
- // position its candidate window at the correct cursor location.
113
- // The cursor is hidden during agent execution via setShowHardwareCursor(false).
114
- // Note: on some terminals (Windows Terminal), showing the hardware cursor
115
- // can interfere with editor rendering layout. If you see editor border gap,
116
- // set this to false and use PI_HARDWARE_CURSOR=1 env var to enable.
117
- const tui = new TUI(terminal, true)
118
- tui.setClearOnShrink(true)
119
- terminal.setTitle(`lonny ${config.model} ${config.provider}`)
120
-
121
- // ── Create components ──────────────────────────────────────────────────
122
-
123
- // Chat area (full width, no side panel) — created upfront but only added
124
- // to the TUI after the landing screen transitions to chat mode.
125
- const chatMarkdown = new Markdown('', 1, 0, markdownTheme)
126
- const chatBox = new Box(1, 0)
127
- chatBox.addChild(chatMarkdown)
128
- // Reserve space at the bottom so the editor overlay doesn't cover
129
- // the last lines of command output. 13 = maxHeight(12) + offsetY(1).
130
- chatBox.addChild(new Spacer(13))
131
-
132
- // Chat input — Editor with multi-line support, history, and autocomplete
133
- const slashCommands: SlashCommand[] = [
134
- { name: 'mode', description: 'Switch mode (code|plan|ask)', argumentHint: 'code|plan|ask' },
135
- { name: 'model', description: 'Switch model', argumentHint: '<name>' },
136
- { name: 'plans', description: 'Show plans overlay' },
137
- { name: 'prompts', description: 'List prompt templates' },
138
- { name: 'skills', description: 'List active skills' },
139
- { name: 'new', description: 'Start a new session' },
140
- { name: 'init', description: 'Create .lonny/skills/ & prompts/' },
141
- { name: 'help', description: 'Show help' },
142
- { name: 'stop', description: 'Stop the running agent' },
143
- { name: 'exit', description: 'Exit' },
144
- { name: 'filter', description: 'Filter plans', argumentHint: '<query>' },
145
- ]
146
- const editor = new Editor(tui, editorTheme)
147
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommands, config.cwd))
148
-
149
- // Loader (thinking indicator)
150
- const loader = new Loader(tui, colors.running, colors.idle, 'thinking...', { intervalMs: 80 })
151
-
152
- // Input area — the editor is shown as a bottom-anchored overlay so the
153
- // input always stays at the bottom of the terminal, regardless of how
154
- // much chat content has accumulated.
155
- let inputOverlayHandle: OverlayHandle | null = null
156
-
157
- // Rich footer (cwd | mode | tokens | model | version + command hints)
158
- const footer = new RichFooter(config.cwd, config.model, config.provider)
159
-
160
- // ── Build layout (landing phase) ──
161
- // In the landing phase, only the footer is shown. The chatBox, editor,
162
- // and loader are added after the first message (see landingScreen.onSubmit).
163
- //
164
- // The editor and loader are shown as a bottom-anchored overlay so the
165
- // input area always stays at the bottom of the terminal, regardless of
166
- // how much chat content has accumulated.
167
-
168
- // ── Plan written callback (defined early since it's used by session restore) ──
169
- const planCb = () => {
170
- refreshPlans()
171
- todoPanel.refresh()
172
- if (plansOverlayHandle?.isHidden() === false) {
173
- showPlansOverlay()
174
- }
175
- }
176
-
177
- // ── Session output ────────────────────────────────────────────────────
178
- // Tool call/result text flows through output.write naturally, interspersed
179
- // with assistant text in the correct order (just like non-TUI mode).
180
- // ── Tool confirmation state ──
181
- let pendingConfirmResolve: ((approved: boolean) => void) | null = null
182
-
183
- function handleConfirmInput(data: string): boolean {
184
- if (!pendingConfirmResolve) return false
185
- const key = data.trim().toLowerCase()
186
- if (key === 'y' || key === 'yes') {
187
- pendingConfirmResolve(true)
188
- pendingConfirmResolve = null
189
- chatContent += 'y\n'
190
- chatMarkdown.setText(chatContent)
191
- tui.requestRender(true)
192
- return true
193
- } else if (key === 'n' || key === 'no' || key === '\r' || key === '') {
194
- pendingConfirmResolve(false)
195
- pendingConfirmResolve = null
196
- chatContent += 'N\n'
197
- chatMarkdown.setText(chatContent)
198
- tui.requestRender(true)
199
- return true
200
- }
201
- return false
202
- }
203
-
204
- const output: SessionOutput = {
205
- write: (text: string) => {
206
- chatContent += text
207
- chatMarkdown.setText(chatContent)
208
- },
209
- suppressToolOutput: false,
210
- confirmTool: async toolCalls => {
211
- chatContent += `\n ${colors.warn('Allow these tool calls?')}\n`
212
- for (const tc of toolCalls) {
213
- const detail = formatToolInput(tc)
214
- chatContent += ` ${colors.dim('\u2022')} ${colors.accent(tc.name)}${detail ? ` ${colors.dim(detail)}` : ''}\n`
215
- }
216
- chatContent += ` ${colors.inputPrompt('(y/N)')} `
217
- chatMarkdown.setText(chatContent)
218
- tui.requestRender(true)
219
-
220
- return new Promise(resolve => {
221
- pendingConfirmResolve = resolve
222
- })
223
- },
224
- }
225
-
226
- // Try to restore a saved session for this directory (MUST be before landing screen setup)
227
- let restored = false
228
- const restoredSession = Session.load(config, output)
229
- if (restoredSession) {
230
- restored = true
231
- session = restoredSession
232
- session.onPlanWritten = planCb
233
- // Find the last user message from the previous session
234
- const lastUserMsg = [...session.messages].reverse().find(m => m.role === 'user')
235
- const lastQuestion =
236
- lastUserMsg && typeof lastUserMsg.content === 'string' ? lastUserMsg.content : null
237
- chatContent = `\n${colors.dim('\u21BA Resumed previous session')}`
238
- if (lastQuestion) {
239
- const preview = lastQuestion.length > 80 ? `${lastQuestion.slice(0, 80)}\u2026` : lastQuestion
240
- chatContent += ` \u2014 ${colors.userLabel(preview)}`
241
- }
242
- chatContent += '\n\n'
243
- chatMarkdown.setText(chatContent)
244
- } else {
245
- session = new Session(config, output)
246
- session.onPlanWritten = planCb
247
- }
248
- // ── Fetch DeepSeek balance at startup (non-blocking)
249
- ;(async () => {
250
- try {
251
- if (isDeepSeekOfficial(config.baseUrl) && config.apiKey) {
252
- const balance = await fetchDeepSeekBalance(config.apiKey)
253
- if (balance.isAvailable && balance.display) {
254
- footer.setBalance(balance.display)
255
- footer.setWebBalance(balance.webDisplay)
256
- tui.requestRender(true)
257
- } else if (balance.error) {
258
- chatContent += `\n${colors.warn('\u26A0')} Balance fetch failed: ${balance.error}\n`
259
- chatMarkdown.setText(chatContent)
260
- }
261
- }
262
- } catch {
263
- // Silently ignore balance fetch errors
264
- }
265
- })()
266
-
267
- // ── Landing screen (centered overlay with pixel logo) ────────────────
268
- const landingScreen = new LandingScreen(config.model, config.provider)
269
- let landingOverlayHandle: OverlayHandle | null = null
270
- // Only show the landing screen if no session was restored
271
- if (!restored) {
272
- landingOverlayHandle = tui.showOverlay(landingScreen, {
273
- anchor: 'center',
274
- width: 70,
275
- maxHeight: 14,
276
- })
277
- tui.setFocus(landingScreen)
278
- }
279
-
280
- // ── Rich footer bar ──────────────────────────────────
281
- // NOTE: must be added AFTER the landing screen overlay so it renders on
282
- // top and is not covered by the centered overlay.
283
- const footerWidth = terminal.columns ?? process.stdout.columns ?? 120
284
- const footerHandle = tui.showOverlay(footer, {
285
- anchor: 'bottom-left',
286
- width: footerWidth,
287
- nonCapturing: true,
288
- })
289
-
290
- // ── Persistent Todo Side Panel ────────────────────────────────────────
291
- const todoPanel = new TodoPanel(config.cwd)
292
- let todoPanelHandle: OverlayHandle | null = null
293
-
294
- function showTodoPanel(): void {
295
- todoPanel.refresh()
296
- const box = new Box(0, 0, colors.bgDark)
297
- box.addChild(todoPanel)
298
- todoPanelHandle = tui.showOverlay(box, {
299
- anchor: 'top-right',
300
- offsetY: 2,
301
- width: 36,
302
- maxHeight: '70%',
303
- offsetX: -1,
304
- nonCapturing: true,
305
- visible: (w: number) => w >= 110,
306
- })
307
- }
308
-
309
- // If a session was restored, immediately transition to chat layout
310
- // (skip the landing screen)
311
- if (restored) {
312
- footer.setPhase('chat')
313
- tui.addChild(chatBox)
314
- tui.addChild(loader)
315
- inputOverlayHandle = tui.showOverlay(editor, {
316
- anchor: 'bottom-left',
317
- width: terminal.columns ?? process.stdout.columns ?? 120,
318
- offsetY: -1,
319
- maxHeight: 12,
320
- nonCapturing: false,
321
- })
322
- showTodoPanel()
323
- tui.setFocus(editor)
324
- }
325
-
326
- // ── Plans overlay components ──────────────────────────────────────────
327
- const plansList = new PlansList([], 15, selectTheme)
328
- let plansOverlayHandle: OverlayHandle | null = null
329
- let plansDetailMode = false
330
-
331
- function showPlansOverlay(): void {
332
- if (plansOverlayHandle?.isHidden() === false) {
333
- plansOverlayHandle.hide()
334
- plansOverlayHandle = null
335
- plansDetailMode = false
336
- return
337
- }
338
- plansDetailMode = false
339
- const plans = listPlans(config.cwd)
340
- plansList.refresh(plansToItems(plans))
341
-
342
- const headerText = new Text(
343
- ` ${colors.accent('\u25B6')} Plans (${plans.length}) ${colors.dim('Enter=view')}`,
344
- 1,
345
- 0,
346
- colors.headerBg,
347
- )
348
- const container = new Container()
349
- container.addChild(headerText)
350
- if (plans.length > 0) {
351
- container.addChild(plansList)
352
- } else {
353
- container.addChild(new Text(' (no plans yet)', 1, 0, colors.dim))
354
- }
355
-
356
- const box = new Box(1, 1, colors.bgDark)
357
- box.addChild(container)
358
-
359
- plansOverlayHandle = tui.showOverlay(box, {
360
- anchor: 'right-center',
361
- width: 45,
362
- maxHeight: '70%',
363
- offsetX: -1,
364
- })
365
- }
366
-
367
- function showPlanDetail(): void {
368
- if (!plansOverlayHandle || plansOverlayHandle.isHidden()) return
369
- const sel = plansList.getSelectedItem()
370
- if (!sel) return
371
-
372
- plansDetailMode = true
373
- const plans = listPlans(config.cwd)
374
- const plan = plans.find(p => p.name === sel.value)
375
- if (!plan) return
376
-
377
- const todos = loadTodos(plan.fullPath)
378
- const headerText = new Text(
379
- ` ${colors.accent('\u25B6')} ${colors.warn(plan.name)} ${colors.dim('Esc=back')}`,
380
- 1,
381
- 0,
382
- colors.headerBg,
383
- )
384
- const todosText = new Text(`\n ${todos}\n`, 1, 0)
385
- const container = new Container()
386
- container.addChild(headerText)
387
- container.addChild(todosText)
388
-
389
- const box = new Box(1, 1, colors.bgDark)
390
- box.addChild(container)
391
-
392
- // Hide current and show detail
393
- plansOverlayHandle.hide()
394
- plansOverlayHandle = tui.showOverlay(box, {
395
- anchor: 'right-center',
396
- width: 50,
397
- maxHeight: '80%',
398
- offsetX: -1,
399
- })
400
- }
401
-
402
- // ── Help overlay ──────────────────────────────────────────────────────────
403
- let helpOverlayHandle: OverlayHandle | null = null
404
-
405
- function showHelpOverlay(): void {
406
- if (helpOverlayHandle?.isHidden() === false) {
407
- helpOverlayHandle.hide()
408
- helpOverlayHandle = null
409
- return
410
- }
411
- const helpContent =
412
- colors.accent('\u2501').repeat(20) +
413
- '\n' +
414
- ` ${colors.accent('lonny')} ${colors.dim('TUI Help')}\n` +
415
- colors.accent('\u2501').repeat(20) +
416
- '\n\n' +
417
- ` ${colors.dim('Commands:')}\n` +
418
- ` ${colors.inputPrompt('/mode')} code|plan|ask ${colors.dim('Switch mode')}\n` +
419
- ` ${colors.inputPrompt('/model')} <name> ${colors.dim('Switch model')}\n` +
420
- ` ${colors.inputPrompt('/plans')} ${colors.dim('Show plans overlay')}\n` +
421
- ` ${colors.inputPrompt('/new')} ${colors.dim('Start a new session')}\n` +
422
- ` ${colors.inputPrompt('/prompts')} ${colors.dim('List prompt templates')}\n` +
423
- ` ${colors.inputPrompt('/skills')} ${colors.dim('List active skills')}\n` +
424
- ` ${colors.inputPrompt('/init')} ${colors.dim('Create .lonny/skills/ & prompts/')}\n` +
425
- ` ${colors.inputPrompt('/stop')} ${colors.dim('Stop the running agent')}\n` +
426
- ` ${colors.inputPrompt('/exit')} ${colors.dim('Exit')}\n` +
427
- ` ${colors.inputPrompt('/help')} ${colors.dim('This help')}\n\n` +
428
- ` ${colors.dim('Keyboard:')}\n` +
429
- ` ${colors.dim('Enter')} ${colors.dim('Send message')}\n` +
430
- ` ${colors.dim('↑/↓')} ${colors.dim('Navigate history')}\n` +
431
- ` ${colors.dim('Tab')} ${colors.dim('Autocomplete')}\n` +
432
- ` ${colors.dim('?')} ${colors.dim('Toggle this help')}\n\n` +
433
- colors.accent('\u2501').repeat(20)
434
- const helpText = new Text(helpContent, 1, 0)
435
- const helpBox = new Box(1, 1, colors.bgDark)
436
- helpBox.addChild(helpText)
437
- helpOverlayHandle = tui.showOverlay(helpBox, {
438
- anchor: 'center',
439
- width: 46,
440
- maxHeight: 22,
441
- })
442
- }
443
-
444
- // ── Update helpers ──────────────────────────────────────────────────────
445
- function updateFooter(): void {
446
- const plans = listPlans(config.cwd)
447
- footer.setAgentStatus(isRunning ? 'running' : 'idle')
448
- footer.setMode(
449
- session?.config.mode === 'plan' ? 'plan' : session?.config.mode === 'ask' ? 'ask' : 'code',
450
- )
451
- footer.setModel(config.model, config.provider)
452
- const tokenStats = loadTokenUsage(config.cwd)
453
- footer.setTokenUsage(
454
- tokenStats.totalInputTokens,
455
- tokenStats.totalOutputTokens,
456
- tokenStats.totalApiCalls,
457
- )
458
- tui.requestRender(true)
459
- }
460
-
461
- function refreshPlans(): void {
462
- const plans = listPlans(config.cwd)
463
- plansList.refresh(plansToItems(plans))
464
- todoPanel.refresh()
465
- updateFooter()
466
- }
467
-
468
- async function refreshBalance(): Promise<void> {
469
- try {
470
- if (isDeepSeekOfficial(config.baseUrl) && config.apiKey) {
471
- const balance = await fetchDeepSeekBalance(config.apiKey)
472
- if (balance.isAvailable && balance.display) {
473
- footer.setBalance(balance.display)
474
- footer.setWebBalance(balance.webDisplay)
475
- } else {
476
- footer.setBalance('')
477
- footer.setWebBalance('')
478
- }
479
- tui.requestRender(true)
480
- }
481
- } catch {
482
- // Silently ignore
483
- }
484
- }
485
-
486
- // ── Input handling ──────────────────────────────────────────────────────
487
- function sendMessage(text: string): void {
488
- const trimmed = text.trim()
489
- if (!trimmed) return
490
- editor.setText('')
491
- editor.addToHistory(trimmed)
492
-
493
- // Allow slash commands even when agent is running (critical for /stop)
494
- if (trimmed.startsWith('/')) {
495
- const parts = trimmed.slice(1).split(/\s+/)
496
- const cmd = parts[0]
497
- const arg = parts.slice(1).join(' ')
498
-
499
- if (cmd === 'exit' || cmd === 'quit') {
500
- chatContent += `\n${colors.dim('Goodbye!')}\n`
501
- chatMarkdown.setText(chatContent)
502
- tui.stop()
503
- process.exit(0)
504
- return
505
- }
506
-
507
- if (cmd === 'new') {
508
- // If the agent is running, stop the old session gracefully first.
509
- // Without this, the pending chat() promise would continue consuming
510
- // tokens and write stale output into the freshly cleared chat display.
511
- if (isRunning) {
512
- session.stop()
513
- isRunning = false
514
- loader.setMessage('')
515
- tui.setShowHardwareCursor(true)
516
- }
517
- Session.clearSavedSession(config.cwd)
518
- resetTokenUsage(config.cwd)
519
- resetGlobalEventBus()
520
- session = new Session(config, output)
521
- session.onPlanWritten = planCb
522
- chatContent = ''
523
- chatMarkdown.setText('')
524
- plansList.clearFilter()
525
- // Reset editor internal state that setText('') doesn't clear
526
- ;(editor as any).undoStack.clear()
527
- ;(editor as any).history = []
528
- ;(editor as any).killRing.ring = []
529
- updateFooter()
530
- return
531
- }
532
-
533
- if (cmd === 'mode') {
534
- if (arg === 'code' || arg === 'plan' || arg === 'ask') {
535
- session.setMode(arg)
536
- chatContent += `\n${colors.warn('\u21E8')} Switched to ${arg === 'ask' ? colors.success(arg) : colors.warn(arg)} mode\n`
537
- chatMarkdown.setText(chatContent)
538
- updateFooter()
539
- } else {
540
- chatContent += `\n${colors.error('\u2716')} Usage: ${colors.inputPrompt('/mode code|plan|ask')} (current: ${session.config.mode})\n`
541
- chatMarkdown.setText(chatContent)
542
- }
543
- return
544
- }
545
-
546
- if (cmd === 'model') {
547
- if (arg) {
548
- session.config.model = arg
549
- // Rebuild system prompt with new model context
550
- session.setMode(session.config.mode) // triggers rebuild
551
- chatContent += `\n${colors.warn('\u21E8')} Model switched to ${colors.warn(arg)}\n`
552
- chatMarkdown.setText(chatContent)
553
- updateFooter()
554
- } else {
555
- chatContent += `\n${colors.inputPrompt('Current model:')} ${colors.dim(session.config.model)}\n`
556
- chatMarkdown.setText(chatContent)
557
- }
558
- return
559
- }
560
-
561
- if (cmd === 'prompts') {
562
- const templates = loadPromptTemplates(config.cwd)
563
- if (templates.length === 0) {
564
- chatContent += `\n${colors.warn('No prompt templates found.')} ${colors.dim('Create .md files in .lonny/prompts/')}\n`
565
- } else {
566
- chatContent += `\n${colors.accent('\u25B6')} ${colors.warn(`Prompt Templates (${templates.length})`)}\n`
567
- for (const t of templates) {
568
- chatContent += ` ${colors.dim('\u2022')} ${colors.inputPrompt(t.name)}`
569
- if (t.description) chatContent += ` ${colors.dim(`\u2014 ${t.description}`)}`
570
- chatContent += '\n'
571
- }
572
- }
573
- chatMarkdown.setText(chatContent)
574
- return
575
- }
576
-
577
- if (cmd === 'skills') {
578
- const skills = loadSkills(config.cwd)
579
- if (skills.length === 0) {
580
- chatContent += `\n${colors.warn('No skills loaded.')} ${colors.dim('Create .md files in .lonny/skills/')}\n`
581
- } else {
582
- chatContent += `\n${colors.accent('\u25B6')} ${colors.warn(`Active Skills (${skills.length})`)}\n`
583
- for (const s of skills) {
584
- chatContent += ` ${colors.dim('\u2022')} ${colors.inputPrompt(s.name)}`
585
- if (s.description) chatContent += ` ${colors.dim(`\u2014 ${s.description}`)}`
586
- chatContent += '\n'
587
- }
588
- }
589
- chatMarkdown.setText(chatContent)
590
- return
591
- }
592
-
593
- if (cmd === 'plans') {
594
- showPlansOverlay()
595
- return
596
- }
597
-
598
- if (cmd === 'filter') {
599
- plansList.setFilter(arg)
600
- tui.requestRender(true)
601
- return
602
- }
603
-
604
- if (cmd === 'help' || cmd === '?') {
605
- showHelpOverlay()
606
- return
607
- }
608
-
609
- if (cmd === 'init') {
610
- ensureSkillsDir(config.cwd)
611
- ensurePromptsDir(config.cwd)
612
- chatContent += `\n${colors.success('\u2714')} Initialized .lonny/skills/ and .lonny/prompts/\n`
613
- chatMarkdown.setText(chatContent)
614
- return
615
- }
616
-
617
- if (cmd === 'stop') {
618
- if (!isRunning) {
619
- chatContent += `\n${colors.dim('Agent is not running.')}\n`
620
- chatMarkdown.setText(chatContent)
621
- return
622
- }
623
- // Tell the session to stop gracefully
624
- session.stop()
625
- chatContent += `\n${colors.warn('\u23F9')} Stopping agent...\n`
626
- chatMarkdown.setText(chatContent)
627
- isRunning = false
628
- loader.setMessage('')
629
- tui.setShowHardwareCursor(true)
630
- updateFooter()
631
- return
632
- }
633
-
634
- chatContent += `\n${colors.error('\u2716')} Unknown command: /${cmd}. ${colors.dim('Type /help for available commands.')}\n`
635
- chatMarkdown.setText(chatContent)
636
- return
637
- }
638
-
639
- // Block regular messages when agent is already running
640
- if (isRunning) return
641
-
642
- isRunning = true
643
- loader.setMessage('thinking...')
644
- tui.setShowHardwareCursor(false)
645
- updateFooter()
646
-
647
- session
648
- .chat(trimmed)
649
- .then(() => {
650
- isRunning = false
651
- loader.setMessage('')
652
- refreshPlans()
653
- tui.setShowHardwareCursor(true)
654
- updateFooter()
655
- refreshBalance()
656
- })
657
- .catch((err: unknown) => {
658
- isRunning = false
659
- loader.setMessage('')
660
- const errMsg = fmtErr(err)
661
- chatContent += `\n${colors.error('\u2716 Error:')} ${errMsg}\n`
662
- chatMarkdown.setText(chatContent)
663
- tui.setShowHardwareCursor(true)
664
- updateFooter()
665
- })
666
- }
667
-
668
- // Wire up submit on editor (after landing transition)
669
- editor.onSubmit = (value: string) => {
670
- sendMessage(value)
671
- }
672
-
673
- // ── Landing screen transition ────────────────────────────────────────────
674
- // When the user presses any key on the landing screen, transition to the
675
- // full chat layout (editor + chat area).
676
- landingScreen.onSubmit = () => {
677
- if (isRunning) return
678
-
679
- // Hide the landing overlay
680
- if (landingOverlayHandle) landingOverlayHandle.hide()
681
- footer.setPhase('chat')
682
-
683
- // Add chat components to the main TUI
684
- // (footer is already an overlay anchored to bottom-left, no need to addChild)
685
- tui.addChild(chatBox)
686
- tui.addChild(loader)
687
- inputOverlayHandle = tui.showOverlay(editor, {
688
- anchor: 'bottom-left',
689
- width: terminal.columns ?? process.stdout.columns ?? 120,
690
- offsetY: -1,
691
- maxHeight: 12,
692
- nonCapturing: false,
693
- })
694
-
695
- showTodoPanel()
696
-
697
- // Focus the chat editor
698
- tui.setFocus(editor)
699
- tui.requestRender(true)
700
- }
701
-
702
- // ── Input listener ──────────────────────────────────────────────────────
703
- tui.addInputListener(data => {
704
- // Check if tool confirmation is pending (consume all input until resolved)
705
- if (pendingConfirmResolve) {
706
- return { consume: handleConfirmInput(data) }
707
- }
708
-
709
- // Check if help overlay is active
710
- if (helpOverlayHandle?.isHidden() === false) {
711
- if (data === '\x1b' || data === '\x1b[' || data === '?') {
712
- helpOverlayHandle.hide()
713
- helpOverlayHandle = null
714
- }
715
- return { consume: true }
716
- }
717
-
718
- // Check if plans overlay is active
719
- if (plansOverlayHandle?.isHidden() === false) {
720
- if (data === '\x1b' || data === '\x1b[') {
721
- if (plansDetailMode) {
722
- // Go back to plan list
723
- plansOverlayHandle.hide()
724
- plansOverlayHandle = null
725
- plansDetailMode = false
726
- showPlansOverlay()
727
- } else {
728
- plansOverlayHandle.hide()
729
- plansOverlayHandle = null
730
- }
731
- return { consume: true }
732
- }
733
- if (data === '\r' && !plansDetailMode) {
734
- // Enter: view plan detail
735
- showPlanDetail()
736
- return { consume: true }
737
- }
738
- if (data === '\x1b[A') {
739
- const plans = listPlans(config.cwd)
740
- if (plans.length > 0) {
741
- const sel = plansList.getSelectedItem()
742
- const idx = sel ? plans.findIndex(p => p.name === sel.value) : -1
743
- const nextIdx = idx <= 0 ? plans.length - 1 : idx - 1
744
- plansList.setSelectedIndex(nextIdx)
745
- updateFooter()
746
- }
747
- return { consume: true }
748
- }
749
- if (data === '\x1b[B') {
750
- const plans = listPlans(config.cwd)
751
- if (plans.length > 0) {
752
- const sel = plansList.getSelectedItem()
753
- const idx = sel ? plans.findIndex(p => p.name === sel.value) : -1
754
- const nextIdx = idx === -1 ? 0 : (idx + 1) % plans.length
755
- plansList.setSelectedIndex(nextIdx)
756
- updateFooter()
757
- }
758
- return { consume: true }
759
- }
760
- return { consume: true }
761
- }
762
-
763
- if (data === '?') {
764
- showHelpOverlay()
765
- return { consume: true }
766
- }
767
-
768
- return undefined
769
- })
770
-
771
- // ── Initial render ────────────────────────────────────────────────────
772
- loader.setMessage('')
773
- refreshPlans()
774
-
775
- // If no session was restored, keep the landing screen and clear chat.
776
- // If a session was restored, chatContent already has the resume message.
777
- if (!restored) {
778
- chatMarkdown.setText('')
779
- }
780
-
781
- tui.start()
782
-
783
- // Keep alive
784
- await new Promise<void>(() => {})
785
- }
1
+ import * as fs from 'node:fs'
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'
9
+ import type {
10
+ EditorTheme,
11
+ MarkdownTheme,
12
+ OverlayHandle,
13
+ SelectItem,
14
+ SelectListTheme,
15
+ SlashCommand,
16
+ } from '../pi-tui/index.js'
17
+ import {
18
+ Box,
19
+ CombinedAutocompleteProvider,
20
+ Container,
21
+ Editor,
22
+ Loader,
23
+ Markdown,
24
+ ProcessTerminal,
25
+ Text,
26
+ TUI,
27
+ } from '../pi-tui/index.js'
28
+ import { fmtErr } from '../tools/errors.js'
29
+ import { fetchDeepSeekBalance, isDeepSeekOfficial } from './balance.js'
30
+ import {
31
+ colors,
32
+ LandingScreen,
33
+ listPlans,
34
+ loadTodos,
35
+ PlansList,
36
+ plansToItems,
37
+ RichFooter,
38
+ TodoPanel,
39
+ } from './components.js'
40
+ import { highlightLine } from './highlight.js'
41
+
42
+ /**
43
+ * Invisible spacer that renders N empty lines.
44
+ * Used to reserve space at the bottom of the chat area so the editor
45
+ * overlay doesn't cover the last lines of command output.
46
+ */
47
+ class Spacer {
48
+ constructor(private height: number) {}
49
+ render(_width: number): string[] {
50
+ return Array.from({ length: this.height }, () => '')
51
+ }
52
+ invalidate(): void {}
53
+ }
54
+
55
+ export async function startTui(config: Config): Promise<void> {
56
+ let chatContent = ''
57
+ let isRunning = false
58
+ let session: Session
59
+
60
+ // ── Create markdown theme (OpenCode-style, clean colors, with syntax highlighting) ──
61
+ const markdownTheme: MarkdownTheme = {
62
+ heading: t => `\x1b[38;2;0;170;255m\x1b[1m${t}\x1b[0m`,
63
+ link: t => `\x1b[38;2;0;170;255m\x1b[4m${t}\x1b[0m`,
64
+ linkUrl: t => `\x1b[38;2;90;90;90m\x1b[4m${t}\x1b[0m`,
65
+ code: t => `\x1b[38;2;255;180;50m${t}\x1b[0m`,
66
+ codeBlock: t => `\x1b[38;2;200;200;200m${t}\x1b[0m`,
67
+ codeBlockBorder: t => `\x1b[38;2;60;60;60m${t}\x1b[0m`,
68
+ highlightCode: (code: string, lang?: string) => {
69
+ if (lang && lang.trim()) {
70
+ const lines = code.split('\n')
71
+ return lines.map(line => highlightLine(line, lang))
72
+ }
73
+ return code.split('\n')
74
+ },
75
+ codeBlockIndent: ' ',
76
+ quote: t => `\x1b[38;2;130;130;130m${t}\x1b[0m`,
77
+ quoteBorder: t => `\x1b[38;2;60;60;60m${t}\x1b[0m`,
78
+ hr: t => `\x1b[38;2;60;60;60m${t}\x1b[0m`,
79
+ listBullet: t => `\x1b[38;2;130;130;130m${t}\x1b[0m`,
80
+ bold: t => `\x1b[1m${t}\x1b[0m`,
81
+ italic: t => `\x1b[3m${t}\x1b[0m`,
82
+ strikethrough: t => `\x1b[9m${t}\x1b[0m`,
83
+ underline: t => `\x1b[4m${t}\x1b[0m`,
84
+ }
85
+
86
+ // ── Create select list theme ──
87
+ const selectTheme: SelectListTheme = {
88
+ selectedPrefix: t => `\x1b[38;2;255;255;255m\x1b[48;2;0;128;255m ${t}\x1b[0m`,
89
+ selectedText: t => `\x1b[38;2;255;255;255m\x1b[48;2;0;128;255m${t}\x1b[0m`,
90
+ description: t => `\x1b[90m${t}\x1b[0m`,
91
+ scrollInfo: t => `\x1b[90m${t}\x1b[0m`,
92
+ noMatch: t => `\x1b[38;2;255;100;100m${t}\x1b[0m`,
93
+ }
94
+
95
+ // ── Create editor theme (used by Editor component) ────────────────────
96
+ const editorTheme: EditorTheme = {
97
+ borderColor: (str: string) => colors.accent(str),
98
+ selectList: selectTheme,
99
+ }
100
+
101
+ // ── Create terminal and TUI ────────────────────────────────────────────
102
+ const terminal = new ProcessTerminal()
103
+ // Patch terminal.write to suppress alternate screen buffer sequences.
104
+ // Without this, pi-tui disables the terminal's native scrollback/scrollbar,
105
+ // making it impossible to scroll through past conversation history.
106
+ const origWrite = terminal.write.bind(terminal)
107
+ terminal.write = (data: string) => {
108
+ const filtered = data.replace(/\x1b\[\?1049[hl]/g, '')
109
+ if (filtered) origWrite(filtered)
110
+ }
111
+ // Show hardware cursor by default so IME (Chinese input method) can
112
+ // position its candidate window at the correct cursor location.
113
+ // The cursor is hidden during agent execution via setShowHardwareCursor(false).
114
+ // Note: on some terminals (Windows Terminal), showing the hardware cursor
115
+ // can interfere with editor rendering layout. If you see editor border gap,
116
+ // set this to false and use PI_HARDWARE_CURSOR=1 env var to enable.
117
+ const tui = new TUI(terminal, true)
118
+ tui.setClearOnShrink(true)
119
+ terminal.setTitle(`lonny ${config.model} ${config.provider}`)
120
+
121
+ // ── Create components ──────────────────────────────────────────────────
122
+
123
+ // Chat area (full width, no side panel) — created upfront but only added
124
+ // to the TUI after the landing screen transitions to chat mode.
125
+ const chatMarkdown = new Markdown('', 1, 0, markdownTheme)
126
+ const chatBox = new Box(1, 0)
127
+ chatBox.addChild(chatMarkdown)
128
+ // Reserve space at the bottom so the editor overlay doesn't cover
129
+ // the last lines of command output. 13 = maxHeight(12) + offsetY(1).
130
+ chatBox.addChild(new Spacer(13))
131
+
132
+ // Chat input — Editor with multi-line support, history, and autocomplete
133
+ const slashCommands: SlashCommand[] = [
134
+ { name: 'mode', description: 'Switch mode (code|plan|ask)', argumentHint: 'code|plan|ask' },
135
+ { name: 'model', description: 'Switch model', argumentHint: '<name>' },
136
+ { name: 'plans', description: 'Show plans overlay' },
137
+ { name: 'prompts', description: 'List prompt templates' },
138
+ { name: 'skills', description: 'List active skills' },
139
+ { name: 'new', description: 'Start a new session' },
140
+ { name: 'init', description: 'Create .lonny/skills/ & prompts/' },
141
+ { name: 'help', description: 'Show help' },
142
+ { name: 'stop', description: 'Stop the running agent' },
143
+ { name: 'exit', description: 'Exit' },
144
+ { name: 'filter', description: 'Filter plans', argumentHint: '<query>' },
145
+ ]
146
+ const editor = new Editor(tui, editorTheme)
147
+ editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommands, config.cwd))
148
+
149
+ // Loader (thinking indicator)
150
+ const loader = new Loader(tui, colors.running, colors.idle, 'thinking...', { intervalMs: 80 })
151
+
152
+ // Input area — the editor is shown as a bottom-anchored overlay so the
153
+ // input always stays at the bottom of the terminal, regardless of how
154
+ // much chat content has accumulated.
155
+ let inputOverlayHandle: OverlayHandle | null = null
156
+
157
+ // Rich footer (cwd | mode | tokens | model | version + command hints)
158
+ const footer = new RichFooter(config.cwd, config.model, config.provider)
159
+
160
+ // ── Build layout (landing phase) ──
161
+ // In the landing phase, only the footer is shown. The chatBox, editor,
162
+ // and loader are added after the first message (see landingScreen.onSubmit).
163
+ //
164
+ // The editor and loader are shown as a bottom-anchored overlay so the
165
+ // input area always stays at the bottom of the terminal, regardless of
166
+ // how much chat content has accumulated.
167
+
168
+ // ── Plan written callback (defined early since it's used by session restore) ──
169
+ const planCb = () => {
170
+ refreshPlans()
171
+ todoPanel.refresh()
172
+ if (plansOverlayHandle?.isHidden() === false) {
173
+ showPlansOverlay()
174
+ }
175
+ }
176
+
177
+ // ── Session output ────────────────────────────────────────────────────
178
+ // Tool call/result text flows through output.write naturally, interspersed
179
+ // with assistant text in the correct order (just like non-TUI mode).
180
+ // ── Tool confirmation state ──
181
+ let pendingConfirmResolve: ((approved: boolean) => void) | null = null
182
+
183
+ function handleConfirmInput(data: string): boolean {
184
+ if (!pendingConfirmResolve) return false
185
+ const key = data.trim().toLowerCase()
186
+ if (key === 'y' || key === 'yes') {
187
+ pendingConfirmResolve(true)
188
+ pendingConfirmResolve = null
189
+ chatContent += 'y\n'
190
+ chatMarkdown.setText(chatContent)
191
+ tui.requestRender(true)
192
+ return true
193
+ } else if (key === 'n' || key === 'no' || key === '\r' || key === '') {
194
+ pendingConfirmResolve(false)
195
+ pendingConfirmResolve = null
196
+ chatContent += 'N\n'
197
+ chatMarkdown.setText(chatContent)
198
+ tui.requestRender(true)
199
+ return true
200
+ }
201
+ return false
202
+ }
203
+
204
+ const output: SessionOutput = {
205
+ write: (text: string) => {
206
+ chatContent += text
207
+ chatMarkdown.setText(chatContent)
208
+ },
209
+ suppressToolOutput: false,
210
+ confirmTool: async toolCalls => {
211
+ chatContent += `\n ${colors.warn('Allow these tool calls?')}\n`
212
+ for (const tc of toolCalls) {
213
+ const detail = formatToolInput(tc)
214
+ chatContent += ` ${colors.dim('\u2022')} ${colors.accent(tc.name)}${detail ? ` ${colors.dim(detail)}` : ''}\n`
215
+ }
216
+ chatContent += ` ${colors.inputPrompt('(y/N)')} `
217
+ chatMarkdown.setText(chatContent)
218
+ tui.requestRender(true)
219
+
220
+ return new Promise(resolve => {
221
+ pendingConfirmResolve = resolve
222
+ })
223
+ },
224
+ }
225
+
226
+ // Try to restore a saved session for this directory (MUST be before landing screen setup)
227
+ let restored = false
228
+ const restoredSession = await Session.load(config, output)
229
+ if (restoredSession) {
230
+ restored = true
231
+ session = restoredSession
232
+ session.onPlanWritten = planCb
233
+ // Find the last user message from the previous session
234
+ const lastUserMsg = [...session.messages].reverse().find(m => m.role === 'user')
235
+ const lastQuestion =
236
+ lastUserMsg && typeof lastUserMsg.content === 'string' ? lastUserMsg.content : null
237
+ chatContent = `\n${colors.dim('\u21BA Resumed previous session')}`
238
+ if (lastQuestion) {
239
+ const preview = lastQuestion.length > 80 ? `${lastQuestion.slice(0, 80)}\u2026` : lastQuestion
240
+ chatContent += ` \u2014 ${colors.userLabel(preview)}`
241
+ }
242
+ chatContent += '\n\n'
243
+ chatMarkdown.setText(chatContent)
244
+ } else {
245
+ session = new Session(config, output)
246
+ session.onPlanWritten = planCb
247
+ }
248
+ // ── Fetch DeepSeek balance at startup (non-blocking)
249
+ ;(async () => {
250
+ try {
251
+ if (isDeepSeekOfficial(config.baseUrl) && config.apiKey) {
252
+ const balance = await fetchDeepSeekBalance(config.apiKey)
253
+ if (balance.isAvailable && balance.display) {
254
+ footer.setBalance(balance.display)
255
+ footer.setWebBalance(balance.webDisplay)
256
+ tui.requestRender(true)
257
+ } else if (balance.error) {
258
+ chatContent += `\n${colors.warn('\u26A0')} Balance fetch failed: ${balance.error}\n`
259
+ chatMarkdown.setText(chatContent)
260
+ }
261
+ }
262
+ } catch {
263
+ // Silently ignore balance fetch errors
264
+ }
265
+ })()
266
+
267
+ // ── Landing screen (centered overlay with pixel logo) ────────────────
268
+ const landingScreen = new LandingScreen(config.model, config.provider)
269
+ let landingOverlayHandle: OverlayHandle | null = null
270
+ // Only show the landing screen if no session was restored
271
+ if (!restored) {
272
+ landingOverlayHandle = tui.showOverlay(landingScreen, {
273
+ anchor: 'center',
274
+ width: 70,
275
+ maxHeight: 14,
276
+ })
277
+ tui.setFocus(landingScreen)
278
+ }
279
+
280
+ // ── Rich footer bar ──────────────────────────────────
281
+ // NOTE: must be added AFTER the landing screen overlay so it renders on
282
+ // top and is not covered by the centered overlay.
283
+ const footerWidth = terminal.columns ?? process.stdout.columns ?? 120
284
+ const footerHandle = tui.showOverlay(footer, {
285
+ anchor: 'bottom-left',
286
+ width: footerWidth,
287
+ nonCapturing: true,
288
+ })
289
+
290
+ // ── Persistent Todo Side Panel ────────────────────────────────────────
291
+ const todoPanel = new TodoPanel(config.cwd)
292
+ let todoPanelHandle: OverlayHandle | null = null
293
+
294
+ function showTodoPanel(): void {
295
+ todoPanel.refresh()
296
+ const box = new Box(0, 0, colors.bgDark)
297
+ box.addChild(todoPanel)
298
+ todoPanelHandle = tui.showOverlay(box, {
299
+ anchor: 'top-right',
300
+ offsetY: 2,
301
+ width: 36,
302
+ maxHeight: '70%',
303
+ offsetX: -1,
304
+ nonCapturing: true,
305
+ visible: (w: number) => w >= 110,
306
+ })
307
+ }
308
+
309
+ // If a session was restored, immediately transition to chat layout
310
+ // (skip the landing screen)
311
+ if (restored) {
312
+ footer.setPhase('chat')
313
+ tui.addChild(chatBox)
314
+ tui.addChild(loader)
315
+ inputOverlayHandle = tui.showOverlay(editor, {
316
+ anchor: 'bottom-left',
317
+ width: terminal.columns ?? process.stdout.columns ?? 120,
318
+ offsetY: -1,
319
+ maxHeight: 12,
320
+ nonCapturing: false,
321
+ })
322
+ showTodoPanel()
323
+ tui.setFocus(editor)
324
+ }
325
+
326
+ // ── Plans overlay components ──────────────────────────────────────────
327
+ const plansList = new PlansList([], 15, selectTheme)
328
+ let plansOverlayHandle: OverlayHandle | null = null
329
+ let plansDetailMode = false
330
+
331
+ function showPlansOverlay(): void {
332
+ if (plansOverlayHandle?.isHidden() === false) {
333
+ plansOverlayHandle.hide()
334
+ plansOverlayHandle = null
335
+ plansDetailMode = false
336
+ return
337
+ }
338
+ plansDetailMode = false
339
+ const plans = listPlans(config.cwd)
340
+ plansList.refresh(plansToItems(plans))
341
+
342
+ const headerText = new Text(
343
+ ` ${colors.accent('\u25B6')} Plans (${plans.length}) ${colors.dim('Enter=view')}`,
344
+ 1,
345
+ 0,
346
+ colors.headerBg,
347
+ )
348
+ const container = new Container()
349
+ container.addChild(headerText)
350
+ if (plans.length > 0) {
351
+ container.addChild(plansList)
352
+ } else {
353
+ container.addChild(new Text(' (no plans yet)', 1, 0, colors.dim))
354
+ }
355
+
356
+ const box = new Box(1, 1, colors.bgDark)
357
+ box.addChild(container)
358
+
359
+ plansOverlayHandle = tui.showOverlay(box, {
360
+ anchor: 'right-center',
361
+ width: 45,
362
+ maxHeight: '70%',
363
+ offsetX: -1,
364
+ })
365
+ }
366
+
367
+ function showPlanDetail(): void {
368
+ if (!plansOverlayHandle || plansOverlayHandle.isHidden()) return
369
+ const sel = plansList.getSelectedItem()
370
+ if (!sel) return
371
+
372
+ plansDetailMode = true
373
+ const plans = listPlans(config.cwd)
374
+ const plan = plans.find(p => p.name === sel.value)
375
+ if (!plan) return
376
+
377
+ const todos = loadTodos(plan.fullPath)
378
+ const headerText = new Text(
379
+ ` ${colors.accent('\u25B6')} ${colors.warn(plan.name)} ${colors.dim('Esc=back')}`,
380
+ 1,
381
+ 0,
382
+ colors.headerBg,
383
+ )
384
+ const todosText = new Text(`\n ${todos}\n`, 1, 0)
385
+ const container = new Container()
386
+ container.addChild(headerText)
387
+ container.addChild(todosText)
388
+
389
+ const box = new Box(1, 1, colors.bgDark)
390
+ box.addChild(container)
391
+
392
+ // Hide current and show detail
393
+ plansOverlayHandle.hide()
394
+ plansOverlayHandle = tui.showOverlay(box, {
395
+ anchor: 'right-center',
396
+ width: 50,
397
+ maxHeight: '80%',
398
+ offsetX: -1,
399
+ })
400
+ }
401
+
402
+ // ── Help overlay ──────────────────────────────────────────────────────────
403
+ let helpOverlayHandle: OverlayHandle | null = null
404
+
405
+ function showHelpOverlay(): void {
406
+ if (helpOverlayHandle?.isHidden() === false) {
407
+ helpOverlayHandle.hide()
408
+ helpOverlayHandle = null
409
+ return
410
+ }
411
+ const helpContent =
412
+ colors.accent('\u2501').repeat(20) +
413
+ '\n' +
414
+ ` ${colors.accent('lonny')} ${colors.dim('TUI Help')}\n` +
415
+ colors.accent('\u2501').repeat(20) +
416
+ '\n\n' +
417
+ ` ${colors.dim('Commands:')}\n` +
418
+ ` ${colors.inputPrompt('/mode')} code|plan|ask ${colors.dim('Switch mode')}\n` +
419
+ ` ${colors.inputPrompt('/model')} <name> ${colors.dim('Switch model')}\n` +
420
+ ` ${colors.inputPrompt('/plans')} ${colors.dim('Show plans overlay')}\n` +
421
+ ` ${colors.inputPrompt('/new')} ${colors.dim('Start a new session')}\n` +
422
+ ` ${colors.inputPrompt('/prompts')} ${colors.dim('List prompt templates')}\n` +
423
+ ` ${colors.inputPrompt('/skills')} ${colors.dim('List active skills')}\n` +
424
+ ` ${colors.inputPrompt('/init')} ${colors.dim('Create .lonny/skills/ & prompts/')}\n` +
425
+ ` ${colors.inputPrompt('/stop')} ${colors.dim('Stop the running agent')}\n` +
426
+ ` ${colors.inputPrompt('/exit')} ${colors.dim('Exit')}\n` +
427
+ ` ${colors.inputPrompt('/help')} ${colors.dim('This help')}\n\n` +
428
+ ` ${colors.dim('Keyboard:')}\n` +
429
+ ` ${colors.dim('Enter')} ${colors.dim('Send message')}\n` +
430
+ ` ${colors.dim('↑/↓')} ${colors.dim('Navigate history')}\n` +
431
+ ` ${colors.dim('Tab')} ${colors.dim('Autocomplete')}\n` +
432
+ ` ${colors.dim('?')} ${colors.dim('Toggle this help')}\n\n` +
433
+ colors.accent('\u2501').repeat(20)
434
+ const helpText = new Text(helpContent, 1, 0)
435
+ const helpBox = new Box(1, 1, colors.bgDark)
436
+ helpBox.addChild(helpText)
437
+ helpOverlayHandle = tui.showOverlay(helpBox, {
438
+ anchor: 'center',
439
+ width: 46,
440
+ maxHeight: 22,
441
+ })
442
+ }
443
+
444
+ // ── Update helpers ──────────────────────────────────────────────────────
445
+ function updateFooter(): void {
446
+ const plans = listPlans(config.cwd)
447
+ footer.setAgentStatus(isRunning ? 'running' : 'idle')
448
+ footer.setMode(
449
+ session?.config.mode === 'plan' ? 'plan' : session?.config.mode === 'ask' ? 'ask' : 'code',
450
+ )
451
+ footer.setModel(config.model, config.provider)
452
+ const tokenStats = loadTokenUsage(config.cwd)
453
+ footer.setTokenUsage(
454
+ tokenStats.totalInputTokens,
455
+ tokenStats.totalOutputTokens,
456
+ tokenStats.totalApiCalls,
457
+ )
458
+ tui.requestRender(true)
459
+ }
460
+
461
+ function refreshPlans(): void {
462
+ const plans = listPlans(config.cwd)
463
+ plansList.refresh(plansToItems(plans))
464
+ todoPanel.refresh()
465
+ updateFooter()
466
+ }
467
+
468
+ async function refreshBalance(): Promise<void> {
469
+ try {
470
+ if (isDeepSeekOfficial(config.baseUrl) && config.apiKey) {
471
+ const balance = await fetchDeepSeekBalance(config.apiKey)
472
+ if (balance.isAvailable && balance.display) {
473
+ footer.setBalance(balance.display)
474
+ footer.setWebBalance(balance.webDisplay)
475
+ } else {
476
+ footer.setBalance('')
477
+ footer.setWebBalance('')
478
+ }
479
+ tui.requestRender(true)
480
+ }
481
+ } catch {
482
+ // Silently ignore
483
+ }
484
+ }
485
+
486
+ // ── Input handling ──────────────────────────────────────────────────────
487
+ async function sendMessage(text: string): Promise<void> {
488
+ const trimmed = text.trim()
489
+ if (!trimmed) return
490
+ editor.setText('')
491
+ editor.addToHistory(trimmed)
492
+
493
+ // Allow slash commands even when agent is running (critical for /stop)
494
+ if (trimmed.startsWith('/')) {
495
+ const parts = trimmed.slice(1).split(/\s+/)
496
+ const cmd = parts[0]
497
+ const arg = parts.slice(1).join(' ')
498
+
499
+ if (cmd === 'exit' || cmd === 'quit') {
500
+ chatContent += `\n${colors.dim('Goodbye!')}\n`
501
+ chatMarkdown.setText(chatContent)
502
+ tui.stop()
503
+ process.exit(0)
504
+ return
505
+ }
506
+
507
+ if (cmd === 'new') {
508
+ // If the agent is running, stop the old session gracefully first.
509
+ // Without this, the pending chat() promise would continue consuming
510
+ // tokens and write stale output into the freshly cleared chat display.
511
+ if (isRunning) {
512
+ session.stop()
513
+ isRunning = false
514
+ loader.setMessage('')
515
+ tui.setShowHardwareCursor(true)
516
+ }
517
+ Session.clearSavedSession(config.cwd)
518
+ resetTokenUsage(config.cwd)
519
+ resetGlobalEventBus()
520
+ session = new Session(config, output)
521
+ session.onPlanWritten = planCb
522
+ chatContent = ''
523
+ chatMarkdown.setText('')
524
+ plansList.clearFilter()
525
+ // Reset editor internal state that setText('') doesn't clear
526
+ ;(editor as any).undoStack.clear()
527
+ ;(editor as any).history = []
528
+ ;(editor as any).killRing.ring = []
529
+ updateFooter()
530
+ return
531
+ }
532
+
533
+ if (cmd === 'mode') {
534
+ if (arg === 'code' || arg === 'plan' || arg === 'ask') {
535
+ await session.setMode(arg)
536
+ chatContent += `\n${colors.warn('\u21E8')} Switched to ${arg === 'ask' ? colors.success(arg) : colors.warn(arg)} mode\n`
537
+ chatMarkdown.setText(chatContent)
538
+ updateFooter()
539
+ } else {
540
+ chatContent += `\n${colors.error('\u2716')} Usage: ${colors.inputPrompt('/mode code|plan|ask')} (current: ${session.config.mode})\n`
541
+ chatMarkdown.setText(chatContent)
542
+ }
543
+ return
544
+ }
545
+
546
+ if (cmd === 'model') {
547
+ if (arg) {
548
+ session.config.model = arg
549
+ // Rebuild system prompt with new model context
550
+ await session.setMode(session.config.mode) // triggers rebuild
551
+ chatContent += `\n${colors.warn('\u21E8')} Model switched to ${colors.warn(arg)}\n`
552
+ chatMarkdown.setText(chatContent)
553
+ updateFooter()
554
+ } else {
555
+ chatContent += `\n${colors.inputPrompt('Current model:')} ${colors.dim(session.config.model)}\n`
556
+ chatMarkdown.setText(chatContent)
557
+ }
558
+ return
559
+ }
560
+
561
+ if (cmd === 'prompts') {
562
+ const templates = loadPromptTemplates(config.cwd)
563
+ if (templates.length === 0) {
564
+ chatContent += `\n${colors.warn('No prompt templates found.')} ${colors.dim('Create .md files in .lonny/prompts/')}\n`
565
+ } else {
566
+ chatContent += `\n${colors.accent('\u25B6')} ${colors.warn(`Prompt Templates (${templates.length})`)}\n`
567
+ for (const t of templates) {
568
+ chatContent += ` ${colors.dim('\u2022')} ${colors.inputPrompt(t.name)}`
569
+ if (t.description) chatContent += ` ${colors.dim(`\u2014 ${t.description}`)}`
570
+ chatContent += '\n'
571
+ }
572
+ }
573
+ chatMarkdown.setText(chatContent)
574
+ return
575
+ }
576
+
577
+ if (cmd === 'skills') {
578
+ const skills = loadSkills(config.cwd)
579
+ if (skills.length === 0) {
580
+ chatContent += `\n${colors.warn('No skills loaded.')} ${colors.dim('Create .md files in .lonny/skills/')}\n`
581
+ } else {
582
+ chatContent += `\n${colors.accent('\u25B6')} ${colors.warn(`Active Skills (${skills.length})`)}\n`
583
+ for (const s of skills) {
584
+ chatContent += ` ${colors.dim('\u2022')} ${colors.inputPrompt(s.name)}`
585
+ if (s.description) chatContent += ` ${colors.dim(`\u2014 ${s.description}`)}`
586
+ chatContent += '\n'
587
+ }
588
+ }
589
+ chatMarkdown.setText(chatContent)
590
+ return
591
+ }
592
+
593
+ if (cmd === 'plans') {
594
+ showPlansOverlay()
595
+ return
596
+ }
597
+
598
+ if (cmd === 'filter') {
599
+ plansList.setFilter(arg)
600
+ tui.requestRender(true)
601
+ return
602
+ }
603
+
604
+ if (cmd === 'help' || cmd === '?') {
605
+ showHelpOverlay()
606
+ return
607
+ }
608
+
609
+ if (cmd === 'init') {
610
+ ensureSkillsDir(config.cwd)
611
+ ensurePromptsDir(config.cwd)
612
+ chatContent += `\n${colors.success('\u2714')} Initialized .lonny/skills/ and .lonny/prompts/\n`
613
+ chatMarkdown.setText(chatContent)
614
+ return
615
+ }
616
+
617
+ if (cmd === 'stop') {
618
+ if (!isRunning) {
619
+ chatContent += `\n${colors.dim('Agent is not running.')}\n`
620
+ chatMarkdown.setText(chatContent)
621
+ return
622
+ }
623
+ // Tell the session to stop gracefully
624
+ session.stop()
625
+ chatContent += `\n${colors.warn('\u23F9')} Stopping agent...\n`
626
+ chatMarkdown.setText(chatContent)
627
+ isRunning = false
628
+ loader.stop()
629
+ loader.setMessage('')
630
+ tui.setShowHardwareCursor(true)
631
+ updateFooter()
632
+ return
633
+ }
634
+
635
+ chatContent += `\n${colors.error('\u2716')} Unknown command: /${cmd}. ${colors.dim('Type /help for available commands.')}\n`
636
+ chatMarkdown.setText(chatContent)
637
+ return
638
+ }
639
+
640
+ // Block regular messages when agent is already running
641
+ if (isRunning) return
642
+
643
+ isRunning = true
644
+ loader.setMessage('thinking...')
645
+ tui.setShowHardwareCursor(false)
646
+ updateFooter()
647
+
648
+ session
649
+ .chat(trimmed)
650
+ .then(() => {
651
+ isRunning = false
652
+ loader.setMessage('')
653
+ refreshPlans()
654
+ tui.setShowHardwareCursor(true)
655
+ updateFooter()
656
+ refreshBalance()
657
+ })
658
+ .catch((err: unknown) => {
659
+ isRunning = false
660
+ loader.setMessage('')
661
+ const errMsg = fmtErr(err)
662
+ chatContent += `\n${colors.error('\u2716 Error:')} ${errMsg}\n`
663
+ chatMarkdown.setText(chatContent)
664
+ tui.setShowHardwareCursor(true)
665
+ updateFooter()
666
+ })
667
+ }
668
+
669
+ // Wire up submit on editor (after landing transition)
670
+ editor.onSubmit = (value: string) => {
671
+ sendMessage(value)
672
+ }
673
+
674
+ // ── Landing screen transition ────────────────────────────────────────────
675
+ // When the user presses any key on the landing screen, transition to the
676
+ // full chat layout (editor + chat area).
677
+ landingScreen.onSubmit = () => {
678
+ if (isRunning) return
679
+
680
+ // Hide the landing overlay
681
+ if (landingOverlayHandle) landingOverlayHandle.hide()
682
+ footer.setPhase('chat')
683
+
684
+ // Add chat components to the main TUI
685
+ // (footer is already an overlay anchored to bottom-left, no need to addChild)
686
+ tui.addChild(chatBox)
687
+ tui.addChild(loader)
688
+ inputOverlayHandle = tui.showOverlay(editor, {
689
+ anchor: 'bottom-left',
690
+ width: terminal.columns ?? process.stdout.columns ?? 120,
691
+ offsetY: -1,
692
+ maxHeight: 12,
693
+ nonCapturing: false,
694
+ })
695
+
696
+ showTodoPanel()
697
+
698
+ // Focus the chat editor
699
+ tui.setFocus(editor)
700
+ tui.requestRender(true)
701
+ }
702
+
703
+ // ── Input listener ──────────────────────────────────────────────────────
704
+ tui.addInputListener(data => {
705
+ // Check if tool confirmation is pending (consume all input until resolved)
706
+ if (pendingConfirmResolve) {
707
+ return { consume: handleConfirmInput(data) }
708
+ }
709
+
710
+ // Check if help overlay is active
711
+ if (helpOverlayHandle?.isHidden() === false) {
712
+ if (data === '\x1b' || data === '\x1b[' || data === '?') {
713
+ helpOverlayHandle.hide()
714
+ helpOverlayHandle = null
715
+ }
716
+ return { consume: true }
717
+ }
718
+
719
+ // Check if plans overlay is active
720
+ if (plansOverlayHandle?.isHidden() === false) {
721
+ if (data === '\x1b' || data === '\x1b[') {
722
+ if (plansDetailMode) {
723
+ // Go back to plan list
724
+ plansOverlayHandle.hide()
725
+ plansOverlayHandle = null
726
+ plansDetailMode = false
727
+ showPlansOverlay()
728
+ } else {
729
+ plansOverlayHandle.hide()
730
+ plansOverlayHandle = null
731
+ }
732
+ return { consume: true }
733
+ }
734
+ if (data === '\r' && !plansDetailMode) {
735
+ // Enter: view plan detail
736
+ showPlanDetail()
737
+ return { consume: true }
738
+ }
739
+ if (data === '\x1b[A') {
740
+ const plans = listPlans(config.cwd)
741
+ if (plans.length > 0) {
742
+ const sel = plansList.getSelectedItem()
743
+ const idx = sel ? plans.findIndex(p => p.name === sel.value) : -1
744
+ const nextIdx = idx <= 0 ? plans.length - 1 : idx - 1
745
+ plansList.setSelectedIndex(nextIdx)
746
+ updateFooter()
747
+ }
748
+ return { consume: true }
749
+ }
750
+ if (data === '\x1b[B') {
751
+ const plans = listPlans(config.cwd)
752
+ if (plans.length > 0) {
753
+ const sel = plansList.getSelectedItem()
754
+ const idx = sel ? plans.findIndex(p => p.name === sel.value) : -1
755
+ const nextIdx = idx === -1 ? 0 : (idx + 1) % plans.length
756
+ plansList.setSelectedIndex(nextIdx)
757
+ updateFooter()
758
+ }
759
+ return { consume: true }
760
+ }
761
+ return { consume: true }
762
+ }
763
+
764
+ if (data === '?') {
765
+ showHelpOverlay()
766
+ return { consume: true }
767
+ }
768
+
769
+ return undefined
770
+ })
771
+
772
+ // ── Initial render ────────────────────────────────────────────────────
773
+ loader.setMessage('')
774
+ refreshPlans()
775
+
776
+ // If no session was restored, keep the landing screen and clear chat.
777
+ // If a session was restored, chatContent already has the resume message.
778
+ if (!restored) {
779
+ chatMarkdown.setText('')
780
+ }
781
+
782
+ tui.start()
783
+
784
+ // Keep alive
785
+ await new Promise<void>(() => {})
786
+ }