Skip to content

Commit 78a3c56

Browse files
authored
Merge pull request #126 from itmisx/fix/newline
🐛 fix newline
2 parents a1465ac + c6f8e28 commit 78a3c56

4 files changed

Lines changed: 108 additions & 67 deletions

File tree

tui/i18n.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,10 +332,11 @@ var translations = map[string]map[Lang]string{
332332
"- `@` — 引用文件(弹出文件选择器,选中后插入路径,模型按需读取)\n\n" +
333333
"**快捷键**\n\n" +
334334
"- `Enter` — 发送;模型回答中按 Enter 则把输入排队,本轮结束自动发出\n" +
335+
"- `ctrl+j` — 换行(多行输入)\n" +
335336
"- `Ctrl+B` — 显示/隐藏右侧状态栏\n" +
336337
"- `Ctrl+V` — 粘贴(含图片)\n" +
337338
"- `Esc` — 中断当前对话\n" +
338-
"- `Ctrl+C` — 按两次退出程序(1 秒内;弹窗内则关弹窗)",
339+
"- `Ctrl+C` — 清空输入框;输入为空时按两次退出程序(1 秒内;弹窗内则关弹窗)",
339340
LangEN: "\n**Slash commands**\n\n" +
340341
"- `/plan` — Switch to read-only mode (Read / List / Grep / Glob / Tree / Search / Fetch / Memory only)\n" +
341342
"- `/auto` — Switch back to full-tools mode (default)\n" +
@@ -364,10 +365,11 @@ var translations = map[string]map[Lang]string{
364365
"- `@` — Reference a file (opens a picker; inserts the path for the model to read)\n\n" +
365366
"**Keybindings**\n\n" +
366367
"- `Enter` — Send; while the model is responding, Enter queues your input and it's sent when the turn ends\n" +
368+
"- `ctrl+j` — Newline (multi-line input)\n" +
367369
"- `Ctrl+B` — Show/hide the right status panel\n" +
368370
"- `Ctrl+V` — Paste (including images)\n" +
369371
"- `Esc` — Interrupt current turn\n" +
370-
"- `Ctrl+C` — Press twice within 1s to quit (closes modal if one is open)",
372+
"- `Ctrl+C` — Clear the input; when empty, press twice within 1s to quit (closes modal if open)",
371373
},
372374

373375
// === 模式提示 ===
@@ -616,8 +618,8 @@ var translations = map[string]map[Lang]string{
616618
LangEN: "Press Ctrl+C again to quit deepx (within 1 second)",
617619
},
618620
"misc.input_placeholder": {
619-
LangZH: "Type a message… Enter 发送 · Option/Alt+Enter 换行 · Esc 中断",
620-
LangEN: "Type a message… Enter to send · Option/Alt+Enter newline · Esc interrupt",
621+
LangZH: "Type a message… Enter 发送 · ctrl+j 换行 · ctrl+c 清空 · Esc 中断",
622+
LangEN: "Type a message… Enter send · ctrl+j newline · ctrl+c clear · Esc interrupt",
621623
},
622624
"misc.history_suffix": {
623625
LangZH: "_(以上为历史对话,共 %d 条)_",

tui/model.go

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,9 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub
441441
ti.Placeholder = T("misc.input_placeholder")
442442
ti.CharLimit = 4000
443443
ti.ShowLineNumbers = false
444-
ti.SetHeight(3)
444+
// 输入框固定 inputRows 行高。内容多于此时,靠 ↑/↓ 移动光标带动 textarea 内部滚动,
445+
// 把多出的行"移出来"看(见按键处理里 ↑/↓ 落到 input.Update 的注释)。
446+
ti.SetHeight(inputTextRows)
445447
// 输入框样式定制:
446448
// - 第一行显示 "> ",后续行只缩进 2 空格(对齐到内容列)避免每行重复 prompt
447449
// - Prompt 染亮青(同 banner 品牌主色),focus / blur 状态都保留亮度
@@ -1196,10 +1198,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11961198
leftW, vpH := m.layout()
11971199
m.chatViewport.SetWidth(leftW)
11981200
m.chatViewport.SetHeight(vpH)
1199-
// 输入区 = 左侧固定 gutter("> ")+ 右侧 textarea。textarea 占整宽 m.width-gutter
1200-
//(分隔线只到 body 底,输入区横跨整行)。gutter 由 view.go 单独画。
1201-
m.input.SetWidth(m.width - inputGutterWidth)
1202-
m.input.SetHeight(inputAreaHeight - 2) // 减去上下各 1 行居中留白
1201+
// 输入区收进左列(与对话同宽):textarea 宽 = leftW - gutter。
1202+
// 状态栏占满全高、分隔线贯穿到底,所以输入不再横跨整宽。gutter 由 view.go 单独画。
1203+
m.input.SetWidth(leftW - inputGutterWidth)
12031204
// 窗口尺寸变了 → wrap 重算 → 老 line 号失效,必须清选区
12041205
m.selecting = false
12051206
m.refreshViewport()
@@ -1239,8 +1240,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12391240
// 选区只认内容列 [chatLeft, leftW),分隔线列按 X 与上面互斥。
12401241
inChat := msg.X >= chatLeft && msg.X < leftW &&
12411242
msg.Y >= chatTop && msg.Y < chatBottom
1242-
// 输入区:body 下方整块(空白行 + textarea 行),Y ∈ [vpH, m.height)。
1243-
inInput := msg.Y >= vpH && msg.Y < m.height && msg.X >= 0 && msg.X < m.width
1243+
// 输入区:左列 body 下方那块(空白行 + textarea 行),Y ∈ [vpH, m.height) 且 X ∈ [0, leftW)。
1244+
// X 上界收到 leftW —— 右侧是全高状态栏,点那里不该进输入编辑。
1245+
inInput := msg.Y >= vpH && msg.Y < m.height && msg.X >= 0 && msg.X < leftW
12441246

12451247
if inInput {
12461248
// 单击进入输入区:清 chat 选区 + 记下拖拽起点(双击全选已移除;全选只走真拖动)。
@@ -2038,10 +2040,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
20382040
}
20392041
switch msg.String() {
20402042
case "ctrl+c":
2041-
// Ctrl+C 双击退出保护:防止误触一下就退。
2043+
// 输入框有内容(或挂了图)→ 单次 Ctrl+C 清空,符合 shell / REPL 习惯。
2044+
// 不计入退出双击(重置计时),清完再按才进入下面的退出逻辑。
2045+
if strings.TrimSpace(m.input.Value()) != "" || len(m.attachedImagePaths) > 0 {
2046+
m.input.SetValue("")
2047+
m.attachedImagePaths = nil
2048+
m.inputAllSelected = false
2049+
m.lastCtrlCAt = time.Time{}
2050+
m.input.SetHeight(inputTextRows) // 多行清空后把视口/高度复位
2051+
return m, nil
2052+
}
2053+
// 输入框为空时才走"双击退出"保护:防止误触一下就退。
20422054
// - streaming 中第一次:先取消(同 Esc 行为)+ 提示再按退出
20432055
// - idle 中第一次:只提示,不退
2044-
// - 任何时候 2s 内第二次:tea.Quit
2056+
// - 任何时候窗口内第二次:tea.Quit
20452057
now := time.Now()
20462058
if !m.lastCtrlCAt.IsZero() && now.Sub(m.lastCtrlCAt) <= ctrlcExitWindow {
20472059
// 第二次,窗口内,退
@@ -2113,20 +2125,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
21132125
return m, nil
21142126
}
21152127
return m, nil
2116-
case "up", "down", "pgup", "pgdown", "pageup", "pagedown", "home", "end", "ctrl+u", "ctrl+d":
2117-
// chat 区滚动键全部拦截,textarea 不再消费方向键 — 用户明确要求 ↑/↓
2118-
// 滚 chat,代价是多行 input 不能用方向键移光标(仍可 Left/Right + Backspace 编辑)。
2128+
case "pgup", "pgdown", "pageup", "pagedown", "home", "end", "ctrl+u", "ctrl+d":
2129+
// 翻页 / 顶底键滚 chat。↑/↓ 不在此列 —— 它们落到下方 input.Update 移动输入框光标:
2130+
// 输入区固定 3 行,光标移过可视区会带动 textarea 内部滚动,把多出的行"移出来"看。
2131+
// chat 滚动仍可用 PgUp/PgDn、鼠标滚轮、拖动右侧滚动条。
21192132
var c tea.Cmd
21202133
m.chatViewport, c = m.chatViewport.Update(msg)
21212134
return m, c
2122-
case "alt+enter", "alt+\r", "ctrl+enter", "ctrl+\r", "shift+enter":
2135+
case "ctrl+j":
21232136
// 在光标处插入换行,实现多行输入。Enter 仍走下方 submit 分支。
2124-
// 同时接 Alt+Enter / Ctrl+Enter / Shift+Enter — 不同终端 / OS 上的"换行"组合键各异,
2125-
// macOS Terminal.app 多用 Alt+Enter,iTerm2 / VSCode / Linux 用户更习惯 Ctrl+Enter / Shift+Enter。
2137+
// 换行键统一为 ctrl+j:它就是 LF(\n),终端原生支持、三平台一致、不被 OS 拦截,
2138+
// bubbletea 报成 "ctrl+j",与 Enter(CR→"enter")天然区分 —— 终端里唯一可靠的换行键。
2139+
// (不再接 Alt/Ctrl/Shift+Enter:Win11 下 Alt+Enter 被系统吃掉切全屏 #124,
2140+
// Ctrl/Shift+Enter 在 Terminal.app 等又跟 Enter 不可区分,留着只会平台不一致。)
21262141
if m.streaming {
21272142
return m, nil
21282143
}
21292144
m.input.InsertRune('\n')
2145+
// InsertRune 直调不会重定位 textarea 内部视口(只有走 textarea.Update 末尾才会),
2146+
// 在已满 3 行时插入新行后光标会跑到可视区外、被钳回行首。SetHeight(同值)内部会
2147+
// 调 repositionView 把视口滚到光标行,且不动光标——以此手动触发一次重定位。
2148+
m.input.SetHeight(inputTextRows)
21302149
return m, nil
21312150
case "ctrl+b":
21322151
// 显示/隐藏右侧状态栏(chat 铺满整宽);记忆到 meta。

tui/session_modal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ func (m *model) toggleStatusPanel() {
142142
leftW, vpH := m.layout()
143143
m.chatViewport.SetWidth(leftW)
144144
m.chatViewport.SetHeight(vpH)
145+
m.input.SetWidth(leftW - inputGutterWidth) // 输入区跟着左列宽变化(状态栏显隐改变 leftW)
145146
m.selecting = false
146147
m.refreshViewport()
147148
}

tui/view.go

Lines changed: 67 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,8 @@ func (m model) wrapView(content string) tea.View {
3232
if m.showSetup || m.showMcpAdd || m.showSkillAdd || m.showWebConfig {
3333
v.MouseMode = tea.MouseModeNone
3434
}
35-
// 开 Kitty keyboard 协议的"alternate keys"上报 — 让 Ctrl+Enter / Shift+Enter
36-
// 等组合键以独立 escape 序列发到程序,而不是被终端合并成普通 Enter。
37-
// 支持的终端:Kitty / Wezterm / Foot / iTerm2(实验性);macOS Terminal.app 不支持
38-
// (这俩组合在 Terminal.app 下仍跟 Enter 等价,只能用 Alt/Option+Enter 换行)。
39-
v.KeyboardEnhancements.ReportAlternateKeys = true
35+
// 换行键统一为 ctrl+j(LF,终端原生、不依赖 Kitty 协议、三平台一致,见 issue #124),
36+
// 不再绑定 Ctrl+Enter / Shift+Enter,故无需开 Kitty "alternate keys" 上报。
4037
return v
4138
}
4239

@@ -45,9 +42,12 @@ func (m model) wrapView(content string) tea.View {
4542
const inputTopPad = 2
4643
const inputBotPad = 0
4744

48-
// inputAreaHeight 是底部输入框占用的固定行数:textarea 3 行 + 上下留白。
49-
// textarea 高 = inputAreaHeight - inputTopPad - inputBotPad。
50-
const inputAreaHeight = 3 + inputTopPad + inputBotPad
45+
// inputTextRows 是输入框 textarea 的固定显示行数。内容超过时不长高,
46+
// 靠 ↑/↓ 移动光标带动 textarea 内部滚动(见 model.go 按键处理)。
47+
const inputTextRows = 3
48+
49+
// inputAreaHeight 是底部输入框区域占用的固定行数:textarea inputTextRows 行 + 上下留白。
50+
const inputAreaHeight = inputTextRows + inputTopPad + inputBotPad
5151

5252
// inputGutterWidth 是输入区左侧固定 gutter 列宽:首行画 "❱ ",其余行 " "。
5353
// textarea 实际宽度 = m.width - inputGutterWidth。
@@ -181,8 +181,8 @@ func (m model) View() tea.View {
181181
leftW = 1
182182
}
183183
// 排队区(流式中暂存的待发送消息)挂在输入框上方,占 queuedH 行,从 body 高度里扣。
184-
// 别让它把对话挤没:至少给 chat 留 1 行。
185-
queuedLines := m.queuedDisplayLines(m.width)
184+
// 别让它把对话挤没:至少给 chat 留 1 行。它在左列,按 leftW 折行。
185+
queuedLines := m.queuedDisplayLines(leftW)
186186
if maxQ := m.height - inputAreaHeight - 1; len(queuedLines) > maxQ {
187187
if maxQ < 0 {
188188
maxQ = 0
@@ -208,34 +208,12 @@ func (m model) View() tea.View {
208208
chatLines = chatLines[len(chatLines)-bodyH:]
209209
}
210210

211-
// 右栏:status section 区,固定 rightW × bodyH。隐藏时全空行(不渲染状态栏)。
212-
rightLines := make([]string, bodyH)
213-
if !m.hideStatusPanel {
214-
right := lipgloss.NewStyle().
215-
Width(rightW).
216-
Height(bodyH).
217-
Padding(0, 1).
218-
Render(m.rightPanelView())
219-
rightLines = strings.Split(right, "\n")
220-
for len(rightLines) < bodyH {
221-
rightLines = append(rightLines, strings.Repeat(" ", rightW))
222-
}
223-
if len(rightLines) > bodyH {
224-
rightLines = rightLines[:bodyH]
225-
}
226-
}
227-
228-
// 手动逐行拼接:chat_line + 分隔线/滚动条(scrollbarWidth 列)+ right_line。
229-
divs := m.scrollbarDividers(bodyH)
230-
bodyLines := make([]string, bodyH)
231-
for i := 0; i < bodyH; i++ {
232-
bodyLines[i] = chatLines[i] + divs[i] + rightLines[i]
233-
}
234-
body := strings.Join(bodyLines, "\n")
211+
// === 布局:左列(对话 + 输入)│ 分隔线 │ 右列(状态栏,全高)===
212+
// 状态栏独占右半区、从顶到底;分隔线一条 ┃ 贯穿全高,把左半区(对话+输入)与状态栏隔开。
213+
// 输入区因此收进左列(宽 leftW,见 resize / toggleStatusPanel 的 SetWidth)。
214+
// 分隔线始终贯穿全高;状态栏隐藏(rightW==0)时右列为空,线仍在最右列一直到底。
235215

236-
// 输入区 = 左侧固定 gutter + 右侧 textarea,逐行拼接。
237-
// gutter 首行 "> "(粉紫),其余行 " ";textarea 宽度已是 m.width-gutter。
238-
// 这样多行粘贴 / 滚动时 "> " 始终钉在左上角,不会跟内容滚走。
216+
// 输入列内容:gutter + textarea 逐行拼接,首行 "❱ ";上接活动状态行/留白,中间夹排队区。
239217
taView := m.input.View()
240218
taLines := strings.Split(taView, "\n")
241219
if m.inputAllSelected {
@@ -257,27 +235,68 @@ func (m model) View() tea.View {
257235
}
258236
inputRows[i] = gutter + tl
259237
}
260-
// 输入区不画竖分隔线 —— 分隔线只到 body 底(对话+右栏区),输入区整宽。
261-
// 顶部 / 底部按 inputTopPad / inputBotPad 留白,normalizeFrame 会把空行补成整宽。
262238
inputLines := make([]string, 0, queuedH+len(inputRows)+inputTopPad+inputBotPad)
263239
for i := 0; i < inputTopPad; i++ {
264-
// 顶部留白的第一行用来挂活动状态行(运行中 spinner+耗时 / 空闲"就绪"),
265-
// 其余仍是空行。inputTopPad 不变,光标 Y(bodyH+inputTopPad)也不变。
240+
// 顶部留白第一行挂活动状态行(运行中 spinner+耗时 / 空闲"就绪"),其余空行。
241+
// inputTopPad 不变光标 Y(bodyH+queuedH+inputTopPad)也不变。
266242
if i == 0 && inputTopPad > 0 {
267-
inputLines = append(inputLines, m.statusFooterLine(m.width))
243+
inputLines = append(inputLines, m.statusFooterLine(leftW))
268244
continue
269245
}
270-
inputLines = append(inputLines, "") // 顶部留白行
246+
inputLines = append(inputLines, "")
271247
}
272-
// 排队区放在活动状态行之后、输入框之前(紧贴输入框),让"待发送"和你正在打的字成组。
273-
inputLines = append(inputLines, queuedLines...)
248+
inputLines = append(inputLines, queuedLines...) // 排队区紧贴输入框上方
274249
inputLines = append(inputLines, inputRows...)
275250
for i := 0; i < inputBotPad; i++ {
276-
inputLines = append(inputLines, "") // 底部留白行
251+
inputLines = append(inputLines, "")
277252
}
278-
inputBlock := strings.Join(inputLines, "\n")
279253

280-
mainUI := lipgloss.JoinVertical(lipgloss.Left, body, inputBlock)
254+
// 左列 = 对话(bodyH 行)+ 输入列,逐行锁到精确 leftW(短补空格/长截断),
255+
// 保证分隔线在每行都落在同一列、不会参差。
256+
leftLines := make([]string, 0, len(chatLines)+len(inputLines))
257+
leftLines = append(leftLines, chatLines...)
258+
leftLines = append(leftLines, inputLines...)
259+
leftCol := strings.Split(padLinesToWidth(strings.Join(leftLines, "\n"), leftW), "\n")
260+
261+
// 右列 = 状态栏,全高 rightW;隐藏时空。
262+
panelShown := !m.hideStatusPanel && rightW > 0
263+
rightCol := make([]string, m.height)
264+
if panelShown {
265+
right := lipgloss.NewStyle().
266+
Width(rightW).
267+
Height(m.height).
268+
Padding(0, 1).
269+
Render(m.rightPanelView())
270+
rightCol = strings.Split(right, "\n")
271+
for len(rightCol) < m.height {
272+
rightCol = append(rightCol, strings.Repeat(" ", rightW))
273+
}
274+
if len(rightCol) > m.height {
275+
rightCol = rightCol[:m.height]
276+
}
277+
}
278+
279+
// 分隔线始终贯穿全高:对话区那 bodyH 行是滚动条(可拖滑块,亮白滑块+暗轨道),
280+
// 其余行(输入区)是纯暗色 ┃ —— 状态栏显隐都一样,线一直到底。
281+
divs := m.scrollbarDividers(bodyH)
282+
track := scrollbarDividerStyle.Render("┃")
283+
rows := make([]string, m.height)
284+
for i := 0; i < m.height; i++ {
285+
l := strings.Repeat(" ", leftW)
286+
if i < len(leftCol) {
287+
l = leftCol[i]
288+
}
289+
d := track
290+
if i < bodyH && i < len(divs) {
291+
d = divs[i]
292+
}
293+
r := ""
294+
if i < len(rightCol) {
295+
r = rightCol[i]
296+
}
297+
rows[i] = l + d + r
298+
}
299+
mainUI := strings.Join(rows, "\n")
281300

282301
// 复制成功提示:在鼠标松开的位置叠一个绿色"✓ 已复制"小标(copyHintClearMsg 到点清空)。
283302
if m.copyHint != "" {

0 commit comments

Comments
 (0)