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.
- package/README.md +326 -326
- package/dist/agent/index.js +1 -1
- package/dist/agent/index.js.map +1 -1
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/project.d.ts +26 -0
- package/dist/agent/project.d.ts.map +1 -0
- package/dist/agent/project.js +303 -0
- package/dist/agent/project.js.map +1 -0
- package/dist/agent/prompt-builder.d.ts +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +22 -13
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/anthropic.d.ts.map +1 -1
- package/dist/agent/providers/anthropic.js +20 -2
- package/dist/agent/providers/anthropic.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/ollama.d.ts.map +1 -1
- package/dist/agent/providers/ollama.js +2 -1
- package/dist/agent/providers/ollama.js.map +1 -1
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +63 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +8 -2
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +250 -153
- package/dist/agent/session.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +1 -1
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +875 -371
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/grep.test.js +32 -0
- package/dist/tools/__tests__/grep.test.js.map +1 -1
- package/dist/tools/__tests__/sed.test.d.ts +2 -0
- package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
- package/dist/tools/__tests__/sed.test.js +228 -0
- package/dist/tools/__tests__/sed.test.js.map +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +83 -10
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +58 -0
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +366 -149
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +54 -30
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/read.d.ts +6 -0
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +42 -15
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/registry.d.ts +0 -6
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +2 -33
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/sed.d.ts +4 -0
- package/dist/tools/sed.d.ts.map +1 -0
- package/dist/tools/sed.js +121 -0
- package/dist/tools/sed.js.map +1 -0
- package/dist/tools/write_plan.d.ts +1 -1
- package/dist/tools/write_plan.d.ts.map +1 -1
- package/dist/tools/write_plan.js +14 -4
- package/dist/tools/write_plan.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +5 -4
- package/dist/tui/index.js.map +1 -1
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +30 -7
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +5 -855
- package/dist/web/public/confirm.js +70 -0
- package/dist/web/public/index.html +121 -120
- package/dist/web/public/input.js +107 -0
- package/dist/web/public/messages.js +395 -0
- package/dist/web/public/sidebar.js +82 -0
- package/dist/web/public/state.js +72 -0
- package/dist/web/public/style.css +996 -949
- package/dist/web/public/utils.js +37 -0
- package/dist/web/public/websocket.js +267 -0
- package/dist/web/public/ws.js +19 -0
- package/dist/web/session-bridge.d.ts.map +1 -1
- package/dist/web/session-bridge.js +3 -7
- package/dist/web/session-bridge.js.map +1 -1
- package/package.json +55 -54
- package/scripts/copy-native.mjs +24 -0
- package/scripts/copy-web.mjs +15 -0
- package/src/agent/index.ts +25 -25
- package/src/agent/llm.ts +4 -0
- package/src/agent/project.ts +366 -0
- package/src/agent/prompt-builder.ts +23 -13
- package/src/agent/providers/anthropic.ts +179 -172
- package/src/agent/providers/google.ts +210 -211
- package/src/agent/providers/ollama.ts +186 -182
- package/src/agent/providers/openai.ts +73 -5
- package/src/agent/session.ts +275 -153
- package/src/config/index.ts +342 -335
- package/src/tools/__tests__/bash.test.ts +1 -1
- package/src/tools/__tests__/edit.test.ts +981 -385
- package/src/tools/__tests__/grep.test.ts +35 -0
- package/src/tools/bash.ts +97 -11
- package/src/tools/edit.ts +414 -154
- package/src/tools/git.ts +76 -76
- package/src/tools/grep.ts +57 -30
- package/src/tools/read.ts +61 -25
- package/src/tools/registry.ts +231 -265
- package/src/tools/write_plan.ts +21 -5
- package/src/tui/index.ts +786 -785
- package/src/web/index.ts +29 -7
- package/src/web/public/app.js +5 -855
- package/src/web/public/confirm.js +70 -0
- package/src/web/public/index.html +121 -120
- package/src/web/public/input.js +107 -0
- package/src/web/public/messages.js +395 -0
- package/src/web/public/sidebar.js +82 -0
- package/src/web/public/state.js +72 -0
- package/src/web/public/style.css +996 -949
- package/src/web/public/utils.js +37 -0
- package/src/web/public/websocket.js +267 -0
- package/src/web/public/ws.js +19 -0
- package/src/web/session-bridge.ts +193 -194
- package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
- package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
- package/.lonny/plan-web-cwd-status.md +0 -38
- 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.
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
editor
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
tui.addChild(
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
tui.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
if (
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
helpOverlayHandle
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
plansOverlayHandle
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
plansOverlayHandle
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
// If
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
+
}
|