thincoder 0.12.13 → 0.12.14
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/package.json +1 -1
- package/src/agent-tools/advisor.mjs +7 -17
- package/src/tui/clipboard.mjs +8 -0
- package/src/tui/index.mjs +5 -2
- package/src/tui/interaction.mjs +2 -2
- package/src/tui/key-handler-search.mjs +113 -0
- package/src/tui/key-handler.mjs +9 -110
- package/src/tui/layout.mjs +16 -8
package/package.json
CHANGED
|
@@ -50,23 +50,14 @@ export function validateDesignToken(token) {
|
|
|
50
50
|
return signature === expectedSig
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/** Build a [DESIGN-TOKEN:...] regex; escapes special chars as a safety net even though UUIDs contain only hex/hyphens.
|
|
60
|
-
* Flexible matching: allows token to be on its own line, in a code block, or surrounded by whitespace.
|
|
61
|
-
* Rejects partial matches by requiring word boundaries or brackets around the token. */
|
|
53
|
+
/** Build a [DESIGN-TOKEN:...] regex; escapes special chars as a safety net.
|
|
54
|
+
* Matches the FULL token (uuid:expiresAt:signature) — prompt tells advisor
|
|
55
|
+
* to echo the complete token verbatim, not just the UUID segment.
|
|
56
|
+
* Flexible matching: allows token to be on its own line, in a code block,
|
|
57
|
+
* or surrounded by whitespace. */
|
|
62
58
|
const makeDesignTokenRegex = (token, flags = "") => {
|
|
63
|
-
//
|
|
64
|
-
const
|
|
65
|
-
const escaped = uuid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
66
|
-
// Match [DESIGN-TOKEN: <uuid>] with flexible surrounding context:
|
|
67
|
-
// - Allow leading/trailing whitespace and newlines
|
|
68
|
-
// - Allow being inside code blocks (```...```)
|
|
69
|
-
// - Require complete token (not truncated)
|
|
59
|
+
// Escape the entire token, not just UUID — advisor echoes [DESIGN-TOKEN:uuid:expiresAt:signature]
|
|
60
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
70
61
|
return new RegExp(
|
|
71
62
|
`(?:^|\\s|\`|\\*)\\[DESIGN-TOKEN:\\s*${escaped}\\s*\\](?:\\s|$|\`|\\*)`,
|
|
72
63
|
flags + "ms"
|
|
@@ -165,7 +156,6 @@ export const advisorTool = {
|
|
|
165
156
|
if (agent._role === "eng-coder") agent._engDesignReviewed = true
|
|
166
157
|
// Strip the bracketed token so only ONE unambiguous format (plain UUID) reaches the main agent
|
|
167
158
|
const cleanResult = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
|
|
168
|
-
const tokenUUID = extractTokenUUID(designToken)
|
|
169
159
|
return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
|
|
170
160
|
}
|
|
171
161
|
// Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -47,6 +47,14 @@ export function translateShiftEnter(text) {
|
|
|
47
47
|
return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Strip keyboard protocol CSI sequences that readline in raw mode does not recognize.
|
|
51
|
+
* Kitty CSI u: \x1b[key;modu — regular keys (e.g. Ctrl+C → \x1b[99;5u)
|
|
52
|
+
* modifyOtherKeys: \x1b[27;mod;key~ — function keys
|
|
53
|
+
* Call AFTER translateShiftEnter (which already handles Shift+Enter). */
|
|
54
|
+
export function stripKeyboardProtocol(text) {
|
|
55
|
+
return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
/** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
|
|
51
59
|
* Extracted from index.mjs.
|
|
52
60
|
* ctx: { agent, state, pushLine, render } */
|
package/src/tui/index.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { createWizard } from "./wizard.mjs"
|
|
|
27
27
|
import { createPickers } from "./pickers.mjs"
|
|
28
28
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
29
29
|
import { createInteraction } from "./interaction.mjs"
|
|
30
|
-
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter } from "./clipboard.mjs"
|
|
30
|
+
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
|
|
31
31
|
import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
|
|
32
32
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
33
33
|
import { createKeyHandler } from "./key-handler.mjs"
|
|
@@ -201,6 +201,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
201
201
|
|
|
202
202
|
// Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
|
|
203
203
|
text = translateShiftEnter(text)
|
|
204
|
+
text = stripKeyboardProtocol(text)
|
|
204
205
|
|
|
205
206
|
if (state.scroll !== lastRenderedScroll) {
|
|
206
207
|
lastRenderedScroll = state.scroll
|
|
@@ -280,8 +281,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
280
281
|
state.input = []
|
|
281
282
|
state.cursor = 0
|
|
282
283
|
state.history.push(text)
|
|
284
|
+
const wasInHistory = state.historyIndex !== -1
|
|
285
|
+
state.history.push(text)
|
|
283
286
|
state.historyIndex = -1
|
|
284
|
-
state._draft = null // submitted — the draft is now history
|
|
287
|
+
if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
|
|
285
288
|
state.scroll = 0
|
|
286
289
|
|
|
287
290
|
// Slash commands: handled locally, don't enter agent loop
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -8,12 +8,12 @@ export function createInteraction(ctx) {
|
|
|
8
8
|
|
|
9
9
|
/** Key info for permission request (customized by tool), returns array of lines. name may have subagent prefix ("coder/bash"), use base name to match */
|
|
10
10
|
function formatPermission(name, args) {
|
|
11
|
-
const cap = (s, n =
|
|
11
|
+
const cap = (s, n = 3000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} chars total)` : s)
|
|
12
12
|
const base = name.includes("/") ? name.split("/").pop() : name
|
|
13
13
|
if (base === "bash") return cap(args.command ?? "").split("\n")
|
|
14
14
|
if (base === "write") {
|
|
15
15
|
// approving file writes must show what's being written: path + content preview
|
|
16
|
-
return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "",
|
|
16
|
+
return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 3000).split("\n")]
|
|
17
17
|
}
|
|
18
18
|
if (base === "edit") {
|
|
19
19
|
// simple diff: - old content / + new content
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Perform search on state.lines, update state.search.matches and clamp index */
|
|
2
|
+
export function performSearch(state) {
|
|
3
|
+
if (!state.search) return
|
|
4
|
+
const query = state.search.query.toLowerCase()
|
|
5
|
+
state.search.matches = []
|
|
6
|
+
// Clear old highlights
|
|
7
|
+
state.lines.forEach(l => delete l._searchMatches)
|
|
8
|
+
|
|
9
|
+
if (!query) { state.search.index = 0; return }
|
|
10
|
+
|
|
11
|
+
state.lines.forEach((line, lineIndex) => {
|
|
12
|
+
const text = (line.text || "").toLowerCase()
|
|
13
|
+
let charIndex = text.indexOf(query)
|
|
14
|
+
if (charIndex !== -1) {
|
|
15
|
+
line._searchMatches = []
|
|
16
|
+
}
|
|
17
|
+
while (charIndex !== -1) {
|
|
18
|
+
state.search.matches.push({ lineIndex, charIndex })
|
|
19
|
+
line._searchMatches.push(charIndex)
|
|
20
|
+
charIndex = text.indexOf(query, charIndex + 1)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (state.search.matches.length === 0) {
|
|
25
|
+
state.search.index = 0
|
|
26
|
+
} else {
|
|
27
|
+
state.search.index = Math.min(state.search.index, state.search.matches.length - 1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Estimate scroll position to make a line visible (simplified) */
|
|
32
|
+
export function scrollToMatch(state, lineIndex) {
|
|
33
|
+
if (lineIndex == null) return
|
|
34
|
+
// Rough estimate: each state.line takes 1-2 rendered lines
|
|
35
|
+
let estimatedLine = 0
|
|
36
|
+
for (let i = 0; i < lineIndex && i < state.lines.length; i++) {
|
|
37
|
+
estimatedLine += Math.max(1, Math.ceil((state.lines[i].text?.length || 0) / 80))
|
|
38
|
+
}
|
|
39
|
+
const rows = process.stdout.rows || 24
|
|
40
|
+
const visibleH = Math.max(5, rows - 10) // reserve space for header, input, status, etc.
|
|
41
|
+
// scroll is number of lines hidden above viewport
|
|
42
|
+
// We want estimatedLine to be near the bottom of the viewport
|
|
43
|
+
state.scroll = Math.max(0, state.lines.length - estimatedLine - visibleH + 3)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Handle search-mode keyboard events.
|
|
47
|
+
* Returns true if the key was consumed (search mode handled it).
|
|
48
|
+
* Call before other handlers so search mode has priority. */
|
|
49
|
+
export function handleSearchKey(str, key, state, render) {
|
|
50
|
+
// Ctrl+F toggles search mode ON
|
|
51
|
+
if (key.ctrl && key.name === "f" && !state.permission && !state.question) {
|
|
52
|
+
if (!state.search) {
|
|
53
|
+
state.search = { query: "", matches: [], index: 0 }
|
|
54
|
+
}
|
|
55
|
+
render()
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!state.search) return false
|
|
60
|
+
|
|
61
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
62
|
+
state.search = null
|
|
63
|
+
render()
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
// Navigation requires Ctrl — bare n/p are query characters (regression fix: bare n/p
|
|
67
|
+
// used to hijack typing, making it impossible to type those letters into the query)
|
|
68
|
+
if ((key.ctrl && key.name === "n") || (key.ctrl && key.name === "g")) {
|
|
69
|
+
// Next match
|
|
70
|
+
if (state.search.matches.length > 0) {
|
|
71
|
+
state.search.index = (state.search.index + 1) % state.search.matches.length
|
|
72
|
+
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
73
|
+
}
|
|
74
|
+
render()
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
if ((key.ctrl && key.name === "p") || (key.ctrl && key.name === "r")) {
|
|
78
|
+
// Previous match
|
|
79
|
+
if (state.search.matches.length > 0) {
|
|
80
|
+
state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
|
|
81
|
+
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
82
|
+
}
|
|
83
|
+
render()
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
if (key.name === "return") {
|
|
87
|
+
// Exit search mode but keep highlighting until next Ctrl+F
|
|
88
|
+
state.search = null
|
|
89
|
+
render()
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
if (key.name === "backspace") {
|
|
93
|
+
if (state.search.query.length > 0) {
|
|
94
|
+
state.search.query = state.search.query.slice(0, -1)
|
|
95
|
+
performSearch(state)
|
|
96
|
+
render()
|
|
97
|
+
}
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
// Regular character input
|
|
101
|
+
if (str && str.length === 1 && !key.ctrl && !key.alt && !key.meta) {
|
|
102
|
+
state.search.query += str
|
|
103
|
+
performSearch(state)
|
|
104
|
+
if (state.search.matches.length > 0) {
|
|
105
|
+
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
106
|
+
}
|
|
107
|
+
render()
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
// Swallow every other key (arrows/Tab/Delete/…) — without this they fall through
|
|
111
|
+
// to normal input handling and edit the HIDDEN state.input instead of the search box
|
|
112
|
+
return true
|
|
113
|
+
}
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -1,51 +1,7 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
3
|
import { computeLayout } from "./layout.mjs"
|
|
4
|
-
|
|
5
|
-
/** Perform search on state.lines, update state.search.matches and clamp index */
|
|
6
|
-
function performSearch(state) {
|
|
7
|
-
if (!state.search) return
|
|
8
|
-
const query = state.search.query.toLowerCase()
|
|
9
|
-
state.search.matches = []
|
|
10
|
-
// Clear old highlights
|
|
11
|
-
state.lines.forEach(l => delete l._searchMatches)
|
|
12
|
-
|
|
13
|
-
if (!query) { state.search.index = 0; return }
|
|
14
|
-
|
|
15
|
-
state.lines.forEach((line, lineIndex) => {
|
|
16
|
-
const text = (line.text || "").toLowerCase()
|
|
17
|
-
let charIndex = text.indexOf(query)
|
|
18
|
-
if (charIndex !== -1) {
|
|
19
|
-
line._searchMatches = []
|
|
20
|
-
}
|
|
21
|
-
while (charIndex !== -1) {
|
|
22
|
-
state.search.matches.push({ lineIndex, charIndex })
|
|
23
|
-
line._searchMatches.push(charIndex)
|
|
24
|
-
charIndex = text.indexOf(query, charIndex + 1)
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
if (state.search.matches.length === 0) {
|
|
29
|
-
state.search.index = 0
|
|
30
|
-
} else {
|
|
31
|
-
state.search.index = Math.min(state.search.index, state.search.matches.length - 1)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Estimate scroll position to make a line visible (simplified) */
|
|
36
|
-
function scrollToMatch(state, lineIndex) {
|
|
37
|
-
if (lineIndex == null) return
|
|
38
|
-
// Rough estimate: each state.line takes 1-2 rendered lines
|
|
39
|
-
let estimatedLine = 0
|
|
40
|
-
for (let i = 0; i < lineIndex && i < state.lines.length; i++) {
|
|
41
|
-
estimatedLine += Math.max(1, Math.ceil((state.lines[i].text?.length || 0) / 80))
|
|
42
|
-
}
|
|
43
|
-
const rows = process.stdout.rows || 24
|
|
44
|
-
const visibleH = Math.max(5, rows - 10) // reserve space for header, input, status, etc.
|
|
45
|
-
// scroll is number of lines hidden above viewport
|
|
46
|
-
// We want estimatedLine to be near the bottom of the viewport
|
|
47
|
-
state.scroll = Math.max(0, state.lines.length - estimatedLine - visibleH + 3)
|
|
48
|
-
}
|
|
4
|
+
import { handleSearchKey } from "./key-handler-search.mjs"
|
|
49
5
|
|
|
50
6
|
/** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
|
|
51
7
|
* Extracted from index.mjs.
|
|
@@ -150,68 +106,7 @@ export function createKeyHandler(ctx) {
|
|
|
150
106
|
}
|
|
151
107
|
|
|
152
108
|
// Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
|
|
153
|
-
|
|
154
|
-
if (!state.search) {
|
|
155
|
-
state.search = { query: "", matches: [], index: 0 }
|
|
156
|
-
}
|
|
157
|
-
render()
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (state.search) {
|
|
162
|
-
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
163
|
-
state.search = null
|
|
164
|
-
render()
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
// Navigation requires Ctrl — bare n/p are query characters (regression fix: bare n/p
|
|
168
|
-
// used to hijack typing, making it impossible to type those letters into the query)
|
|
169
|
-
if ((key.ctrl && key.name === "n") || (key.ctrl && key.name === "g")) {
|
|
170
|
-
// Next match
|
|
171
|
-
if (state.search.matches.length > 0) {
|
|
172
|
-
state.search.index = (state.search.index + 1) % state.search.matches.length
|
|
173
|
-
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
174
|
-
}
|
|
175
|
-
render()
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
if ((key.ctrl && key.name === "p") || (key.ctrl && key.name === "r")) {
|
|
179
|
-
// Previous match
|
|
180
|
-
if (state.search.matches.length > 0) {
|
|
181
|
-
state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
|
|
182
|
-
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
183
|
-
}
|
|
184
|
-
render()
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
|
-
if (key.name === "return") {
|
|
188
|
-
// Exit search mode but keep highlighting until next Ctrl+F
|
|
189
|
-
state.search = null
|
|
190
|
-
render()
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
if (key.name === "backspace") {
|
|
194
|
-
if (state.search.query.length > 0) {
|
|
195
|
-
state.search.query = state.search.query.slice(0, -1)
|
|
196
|
-
performSearch(state)
|
|
197
|
-
render()
|
|
198
|
-
}
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
// Regular character input
|
|
202
|
-
if (str && str.length === 1 && !key.ctrl && !key.alt && !key.meta) {
|
|
203
|
-
state.search.query += str
|
|
204
|
-
performSearch(state)
|
|
205
|
-
if (state.search.matches.length > 0) {
|
|
206
|
-
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
207
|
-
}
|
|
208
|
-
render()
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
// Swallow every other key (arrows/Tab/Delete/…) — without this they fall through
|
|
212
|
-
// to normal input handling and edit the HIDDEN state.input instead of the search box
|
|
213
|
-
return
|
|
214
|
-
}
|
|
109
|
+
if (handleSearchKey(str, key, state, render)) return
|
|
215
110
|
|
|
216
111
|
if (key.ctrl && key.name === "c") {
|
|
217
112
|
// picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
|
|
@@ -405,6 +300,7 @@ export function createKeyHandler(ctx) {
|
|
|
405
300
|
|
|
406
301
|
// Ctrl+U: clear entire input box
|
|
407
302
|
if ((key.name === "u" && key.ctrl) || str === "\x15") {
|
|
303
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
408
304
|
state.input = []
|
|
409
305
|
state.cursor = 0
|
|
410
306
|
render()
|
|
@@ -414,9 +310,9 @@ export function createKeyHandler(ctx) {
|
|
|
414
310
|
// input history
|
|
415
311
|
if (key.name === "up") {
|
|
416
312
|
if (state.history.length) {
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
if (state.historyIndex === -1
|
|
313
|
+
// Save current input as draft when entering history mode (historyIndex === -1).
|
|
314
|
+
// Subsequent edits while in history mode are saved separately (see printable/editing handlers).
|
|
315
|
+
if (state.historyIndex === -1) {
|
|
420
316
|
state._draft = [...state.input]
|
|
421
317
|
}
|
|
422
318
|
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
@@ -470,6 +366,7 @@ export function createKeyHandler(ctx) {
|
|
|
470
366
|
if (state.cursor > 0) {
|
|
471
367
|
state.input.splice(state.cursor - 1, 1)
|
|
472
368
|
state.cursor--
|
|
369
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
473
370
|
render()
|
|
474
371
|
}
|
|
475
372
|
return
|
|
@@ -477,6 +374,7 @@ export function createKeyHandler(ctx) {
|
|
|
477
374
|
if (key.name === "delete") {
|
|
478
375
|
if (state.cursor < state.input.length) {
|
|
479
376
|
state.input.splice(state.cursor, 1)
|
|
377
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
480
378
|
render()
|
|
481
379
|
}
|
|
482
380
|
return
|
|
@@ -532,6 +430,7 @@ export function createKeyHandler(ctx) {
|
|
|
532
430
|
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
533
431
|
state.input.splice(state.cursor, 0, ...chars)
|
|
534
432
|
state.cursor += chars.length
|
|
433
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
535
434
|
render()
|
|
536
435
|
}
|
|
537
436
|
}
|
package/src/tui/layout.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
78
78
|
let permPreviewLines = []
|
|
79
79
|
let permPreviewH = 0
|
|
80
80
|
if (state.permission) {
|
|
81
|
-
const maxLines = Math.max(1, rows -
|
|
81
|
+
const maxLines = Math.min(8, Math.max(1, rows - 10))
|
|
82
82
|
outer: for (const l of state.permissionPreview) {
|
|
83
83
|
for (const wrapped of wrapText(` ${l}`, W - 1)) {
|
|
84
84
|
if (permPreviewLines.length >= maxLines) break outer
|
|
@@ -95,14 +95,22 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
95
95
|
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
|
|
96
96
|
let convH = Math.max(1, rows - fixedH)
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// 溢出 —— 其余面板不强行裁剪,由渲染层自行截断
|
|
98
|
+
// 小终端高度补偿:先压 conversation 到最小 1 行,再压 picker 到最小 3 行,
|
|
99
|
+
// 仍溢出再压 permission preview 到最小 1 行(仅标题)。
|
|
101
100
|
let pickerFinalH = pickerH
|
|
101
|
+
let permFinalH = permPreviewH
|
|
102
102
|
const overflow = fixedH + convH - rows
|
|
103
|
-
if (overflow > 0
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
if (overflow > 0) {
|
|
104
|
+
if (pickerH > 0) {
|
|
105
|
+
pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow)
|
|
106
|
+
}
|
|
107
|
+
const afterPicker = fixedH - pickerH + pickerFinalH
|
|
108
|
+
convH = Math.max(1, rows - afterPicker)
|
|
109
|
+
const remaining = afterPicker + convH - rows
|
|
110
|
+
if (remaining > 0 && permPreviewH > 0) {
|
|
111
|
+
permFinalH = Math.max(1, permPreviewH - remaining)
|
|
112
|
+
convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
|
|
113
|
+
}
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
// --- Y coordinates (0-indexed, +1 when used with ANSI) ---
|
|
@@ -113,7 +121,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
113
121
|
const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
|
|
114
122
|
const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
|
|
115
123
|
const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
|
|
116
|
-
const permission =
|
|
124
|
+
const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
|
|
117
125
|
const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
|
|
118
126
|
const inputBox = { y, h: inputBoxH }; y += inputBoxH
|
|
119
127
|
const status = { y, h: statusH }
|