openclacky 0.9.34 → 0.9.35
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +14 -10
- data/lib/clacky/agent/memory_updater.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +2 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/tool_executor.rb +13 -16
- data/lib/clacky/agent/tool_registry.rb +0 -3
- data/lib/clacky/agent.rb +63 -38
- data/lib/clacky/agent_config.rb +5 -1
- data/lib/clacky/brand_config.rb +11 -27
- data/lib/clacky/cli.rb +36 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
- data/lib/clacky/default_skills/new/SKILL.md +1 -1
- data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
- data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
- data/lib/clacky/idle_compression_timer.rb +8 -0
- data/lib/clacky/json_ui_controller.rb +2 -1
- data/lib/clacky/plain_ui_controller.rb +10 -3
- data/lib/clacky/platform_http_client.rb +161 -1
- data/lib/clacky/server/channel/channel_manager.rb +5 -3
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
- data/lib/clacky/server/http_server.rb +235 -40
- data/lib/clacky/server/scheduler.rb +17 -16
- data/lib/clacky/server/session_registry.rb +1 -5
- data/lib/clacky/server/web_ui_controller.rb +7 -6
- data/lib/clacky/session_manager.rb +22 -0
- data/lib/clacky/skill.rb +19 -3
- data/lib/clacky/skill_loader.rb +5 -59
- data/lib/clacky/tools/browser.rb +25 -73
- data/lib/clacky/tools/security.rb +326 -0
- data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
- data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
- data/lib/clacky/tools/terminal/session_manager.rb +208 -0
- data/lib/clacky/tools/terminal.rb +818 -0
- data/lib/clacky/tools/todo_manager.rb +6 -16
- data/lib/clacky/tools/trash_manager.rb +2 -2
- data/lib/clacky/ui2/components/input_area.rb +11 -2
- data/lib/clacky/ui2/layout_manager.rb +438 -488
- data/lib/clacky/ui2/output_buffer.rb +310 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -21
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/encoding.rb +1 -1
- data/lib/clacky/utils/environment_detector.rb +43 -0
- data/lib/clacky/utils/model_pricing.rb +3 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +479 -178
- data/lib/clacky/web/app.js +146 -4
- data/lib/clacky/web/auth.js +101 -0
- data/lib/clacky/web/i18n.js +35 -1
- data/lib/clacky/web/index.html +9 -2
- data/lib/clacky/web/sessions.js +254 -15
- data/lib/clacky/web/skills.js +20 -6
- data/lib/clacky/web/tasks.js +54 -2
- data/lib/clacky/web/theme.js +58 -20
- data/lib/clacky/web/ws.js +11 -2
- data/lib/clacky.rb +2 -2
- metadata +8 -3
- data/lib/clacky/tools/safe_shell.rb +0 -608
- data/lib/clacky/tools/shell.rb +0 -522
|
@@ -1,192 +1,343 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "screen_buffer"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "output_buffer"
|
|
5
5
|
require_relative "../utils/encoding"
|
|
6
6
|
|
|
7
7
|
module Clacky
|
|
8
8
|
module UI2
|
|
9
|
-
# LayoutManager
|
|
9
|
+
# LayoutManager coordinates the split-screen layout:
|
|
10
|
+
# [ scrollable output area ]
|
|
11
|
+
# [ gap / todo / input (fixed) ]
|
|
12
|
+
#
|
|
13
|
+
# Responsibilities:
|
|
14
|
+
# - Own an OutputBuffer (logical source of truth for output content).
|
|
15
|
+
# - Translate buffer mutations into screen paints, handling:
|
|
16
|
+
# * Native terminal scrolling when output overflows the output area.
|
|
17
|
+
# * Committing scrolled lines to the buffer (so they are never repainted
|
|
18
|
+
# from the buffer again — prevents the classic "double render on
|
|
19
|
+
# scroll up" bug).
|
|
20
|
+
# - Keep the fixed area (gap + todo + input) pinned at the bottom of the
|
|
21
|
+
# screen, repainting it only when it is dirty.
|
|
22
|
+
#
|
|
23
|
+
# Public API (id-based, preferred):
|
|
24
|
+
# append(content, kind: :text) -> id # add entry, returns id
|
|
25
|
+
# replace_entry(id, content) # edit entry's content
|
|
26
|
+
# remove_entry(id) # drop entry
|
|
27
|
+
#
|
|
28
|
+
# Legacy API (shims, still used by InlineInput / progress):
|
|
29
|
+
# append_output(content) -> id # alias for append
|
|
30
|
+
# update_last_line(content, old_n, id: nil) # uses id if given
|
|
31
|
+
# remove_last_line(n, id: nil) # uses id if given
|
|
10
32
|
class LayoutManager
|
|
11
|
-
attr_reader :screen, :input_area, :todo_area
|
|
33
|
+
attr_reader :screen, :input_area, :todo_area, :buffer
|
|
12
34
|
|
|
13
35
|
def initialize(input_area:, todo_area: nil)
|
|
14
|
-
@screen
|
|
15
|
-
@input_area
|
|
16
|
-
@todo_area
|
|
36
|
+
@screen = ScreenBuffer.new
|
|
37
|
+
@input_area = input_area
|
|
38
|
+
@todo_area = todo_area
|
|
39
|
+
@buffer = OutputBuffer.new
|
|
17
40
|
@render_mutex = Mutex.new
|
|
18
|
-
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@
|
|
41
|
+
|
|
42
|
+
@output_row = 0 # Next output row to paint into
|
|
43
|
+
@last_fixed_area_height = 0
|
|
44
|
+
@fullscreen_mode = false
|
|
45
|
+
@resize_pending = false
|
|
46
|
+
|
|
47
|
+
# Tracks the most recent append's id so the legacy
|
|
48
|
+
# update_last_line / remove_last_line shims still work without the
|
|
49
|
+
# caller threading an id through.
|
|
50
|
+
@last_append_id = nil
|
|
23
51
|
|
|
24
52
|
calculate_layout
|
|
25
53
|
setup_resize_handler
|
|
26
54
|
end
|
|
27
55
|
|
|
28
|
-
#
|
|
56
|
+
# -----------------------------------------------------------------------
|
|
57
|
+
# Layout math
|
|
58
|
+
# -----------------------------------------------------------------------
|
|
59
|
+
|
|
29
60
|
def calculate_layout
|
|
30
|
-
todo_height
|
|
61
|
+
todo_height = @todo_area&.height || 0
|
|
31
62
|
input_height = @input_area.required_height
|
|
32
|
-
gap_height
|
|
63
|
+
gap_height = 1
|
|
33
64
|
|
|
34
|
-
# Layout: output -> gap -> todo -> input (with its own separators and status)
|
|
35
65
|
@output_height = screen.height - gap_height - todo_height - input_height
|
|
36
|
-
@output_height = [1, @output_height].max
|
|
66
|
+
@output_height = [1, @output_height].max
|
|
37
67
|
|
|
38
|
-
@gap_row
|
|
39
|
-
@todo_row
|
|
68
|
+
@gap_row = @output_height
|
|
69
|
+
@todo_row = @gap_row + gap_height
|
|
40
70
|
@input_row = @todo_row + todo_height
|
|
41
71
|
|
|
42
|
-
# Update component dimensions
|
|
43
72
|
@input_area.row = @input_row
|
|
44
73
|
end
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
old_input_row = @input_row
|
|
75
|
+
def fixed_area_height
|
|
76
|
+
todo_h = @todo_area&.height || 0
|
|
77
|
+
input_h = @input_area.required_height
|
|
78
|
+
1 + todo_h + input_h
|
|
79
|
+
end
|
|
52
80
|
|
|
53
|
-
|
|
81
|
+
def fixed_area_start_row
|
|
82
|
+
screen.height - fixed_area_height
|
|
83
|
+
end
|
|
54
84
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
([old_gap_row, 0].max...screen.height).each do |row|
|
|
59
|
-
screen.move_cursor(row, 0)
|
|
60
|
-
screen.clear_line
|
|
61
|
-
end
|
|
85
|
+
# -----------------------------------------------------------------------
|
|
86
|
+
# Public output API (id-based)
|
|
87
|
+
# -----------------------------------------------------------------------
|
|
62
88
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# Re-render fixed areas at new position
|
|
70
|
-
render_fixed_areas
|
|
71
|
-
end
|
|
72
|
-
screen.flush
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
89
|
+
# Append an output entry. Returns the entry id so callers can later
|
|
90
|
+
# replace_entry / remove_entry. Multi-line content is wrapped and
|
|
91
|
+
# stored as one logical entry.
|
|
92
|
+
def append(content, kind: :text)
|
|
93
|
+
return nil if content.nil?
|
|
94
|
+
content = sanitize(content)
|
|
76
95
|
|
|
77
|
-
# Render all layout areas
|
|
78
|
-
def render_all
|
|
79
96
|
@render_mutex.synchronize do
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
lines = wrap_content_to_lines(content)
|
|
98
|
+
id = @buffer.append(lines, kind: kind)
|
|
99
|
+
@last_append_id = id
|
|
83
100
|
|
|
84
|
-
|
|
85
|
-
def render_output
|
|
86
|
-
@render_mutex.synchronize do
|
|
87
|
-
# Output is written directly, just need to re-render fixed areas
|
|
101
|
+
paint_new_lines(lines) unless @fullscreen_mode
|
|
88
102
|
render_fixed_areas
|
|
89
103
|
screen.flush
|
|
104
|
+
id
|
|
90
105
|
end
|
|
91
106
|
end
|
|
92
107
|
|
|
93
|
-
#
|
|
94
|
-
def
|
|
108
|
+
# Legacy: append, return id (callers that ignore it still work).
|
|
109
|
+
def append_output(content)
|
|
110
|
+
append(content)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Replace an existing entry's content. The screen is updated in place
|
|
114
|
+
# if the entry still lives in the output area; otherwise (committed
|
|
115
|
+
# to scrollback) this is a silent no-op.
|
|
116
|
+
def replace_entry(id, content)
|
|
117
|
+
return if id.nil? || content.nil?
|
|
118
|
+
content = sanitize(content)
|
|
119
|
+
|
|
95
120
|
@render_mutex.synchronize do
|
|
96
|
-
|
|
121
|
+
entry = @buffer.entry_by_id(id)
|
|
122
|
+
return if entry.nil? || entry.committed
|
|
123
|
+
|
|
124
|
+
old_lines = entry.lines.dup
|
|
125
|
+
new_lines = wrap_content_to_lines(content)
|
|
126
|
+
@buffer.replace(id, new_lines)
|
|
127
|
+
|
|
128
|
+
repaint_entry_in_place(entry, old_lines, new_lines) unless @fullscreen_mode
|
|
97
129
|
render_fixed_areas
|
|
98
130
|
screen.flush
|
|
99
131
|
end
|
|
100
132
|
end
|
|
101
133
|
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
134
|
+
# Is this id still a live (not yet committed to scrollback) entry?
|
|
135
|
+
# Cheap probe callers use before deciding between replace vs append.
|
|
136
|
+
def live_entry?(id)
|
|
137
|
+
return false if id.nil?
|
|
138
|
+
@buffer.live?(id)
|
|
139
|
+
end
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
# Remove an entry. If it's the last live entry, the screen area it
|
|
142
|
+
# occupied is cleared and the output cursor rolls back.
|
|
143
|
+
def remove_entry(id)
|
|
144
|
+
return if id.nil?
|
|
145
|
+
|
|
146
|
+
@render_mutex.synchronize do
|
|
147
|
+
entry = @buffer.entry_by_id(id)
|
|
148
|
+
return if entry.nil? || entry.committed
|
|
149
|
+
|
|
150
|
+
height = entry.height
|
|
151
|
+
# Check whether this entry is the tail of live entries. Only tail
|
|
152
|
+
# removal is cheap — mid-buffer removal would require a full
|
|
153
|
+
# output repaint. In practice only the progress / inline-input
|
|
154
|
+
# entries are removed, and they are always the tail.
|
|
155
|
+
is_tail = @buffer.live_entries.last&.id == id
|
|
156
|
+
|
|
157
|
+
@buffer.remove(id)
|
|
158
|
+
@last_append_id = nil if @last_append_id == id
|
|
159
|
+
|
|
160
|
+
unless @fullscreen_mode
|
|
161
|
+
if is_tail
|
|
162
|
+
clear_tail_rows(height)
|
|
163
|
+
else
|
|
164
|
+
# Non-tail removal: rebuild the entire output area from buffer
|
|
165
|
+
render_output_from_buffer
|
|
166
|
+
end
|
|
167
|
+
end
|
|
110
168
|
|
|
111
|
-
# Re-render fixed areas at new positions
|
|
112
169
|
render_fixed_areas
|
|
113
170
|
screen.flush
|
|
114
171
|
end
|
|
115
172
|
end
|
|
116
173
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
174
|
+
# -----------------------------------------------------------------------
|
|
175
|
+
# Legacy shims (kept for InlineInput + other callers that don't carry ids)
|
|
176
|
+
# -----------------------------------------------------------------------
|
|
120
177
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
178
|
+
# Update the most recently appended entry. Prefer passing +id:+; when
|
|
179
|
+
# omitted the last-append id is used. +old_line_count+ is ignored
|
|
180
|
+
# (buffer knows the true height).
|
|
181
|
+
def update_last_line(content, old_line_count = nil, id: nil)
|
|
182
|
+
target = id || @last_append_id
|
|
183
|
+
replace_entry(target, content) if target
|
|
184
|
+
end
|
|
126
185
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
186
|
+
# Remove the most recently appended entry (or the given id).
|
|
187
|
+
def remove_last_line(line_count = 1, id: nil)
|
|
188
|
+
target = id || @last_append_id
|
|
189
|
+
remove_entry(target) if target
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# -----------------------------------------------------------------------
|
|
193
|
+
# Paint primitives (private)
|
|
194
|
+
# -----------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
# Paint fresh lines into the output area, scrolling via native \n when
|
|
197
|
+
# we reach the fixed area. CRUCIAL INVARIANT: every time we scroll,
|
|
198
|
+
# we tell the buffer "N oldest live lines just moved into scrollback"
|
|
199
|
+
# so they are NEVER re-painted from the buffer again. This is what
|
|
200
|
+
# eliminates the double-render bug.
|
|
201
|
+
private def paint_new_lines(lines)
|
|
202
|
+
max_output_row = fixed_area_start_row
|
|
203
|
+
|
|
204
|
+
lines.each do |line|
|
|
205
|
+
if @output_row >= max_output_row
|
|
206
|
+
# Scroll the terminal by emitting a real \n at the very bottom.
|
|
207
|
+
# That pushes the top visible row into the native scrollback
|
|
208
|
+
# buffer — exactly where the user will see it on scroll-up.
|
|
209
|
+
screen.move_cursor(screen.height - 1, 0)
|
|
210
|
+
print "\n"
|
|
211
|
+
|
|
212
|
+
# Tell the buffer one line of live content just left the screen.
|
|
213
|
+
# Committed entries become untouchable, so a later full repaint
|
|
214
|
+
# (resize, fixed-area height change, fullscreen exit) will NOT
|
|
215
|
+
# re-emit them and duplicate them in scrollback.
|
|
216
|
+
@buffer.commit_oldest_lines(1)
|
|
217
|
+
|
|
218
|
+
@output_row = max_output_row - 1
|
|
219
|
+
|
|
220
|
+
# The fixed area got scrolled up too — restore it. Don't trigger
|
|
221
|
+
# an output rebuild; the buffer's tail hasn't changed.
|
|
222
|
+
render_fixed_areas(skip_buffer_rerender: true)
|
|
223
|
+
end
|
|
130
224
|
|
|
131
|
-
@output_buffer.last(visible_lines).each do |line|
|
|
132
225
|
screen.move_cursor(@output_row, 0)
|
|
226
|
+
screen.clear_line
|
|
133
227
|
print line
|
|
134
228
|
@output_row += 1
|
|
135
229
|
end
|
|
136
230
|
end
|
|
137
231
|
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
232
|
+
# Repaint a single entry in place after its content changed.
|
|
233
|
+
# Handles both grow and shrink. If the new content would overflow
|
|
234
|
+
# into the fixed area, we scroll up to make room (same rules as
|
|
235
|
+
# paint_new_lines — scrolled rows get committed to scrollback).
|
|
236
|
+
private def repaint_entry_in_place(entry, old_lines, new_lines)
|
|
237
|
+
old_n = old_lines.length
|
|
238
|
+
new_n = new_lines.length
|
|
239
|
+
return if @output_row == 0
|
|
142
240
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
wrap_row, wrap_col = inline_input.cursor_position_for_display(width)
|
|
241
|
+
start_row = @output_row - old_n
|
|
242
|
+
start_row = 0 if start_row < 0
|
|
146
243
|
|
|
147
|
-
|
|
148
|
-
line_count = inline_input.line_count(width)
|
|
244
|
+
max_output_row = fixed_area_start_row
|
|
149
245
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
246
|
+
# Grow + would overflow → scroll first
|
|
247
|
+
if new_n > old_n
|
|
248
|
+
needed_end = start_row + new_n
|
|
249
|
+
if needed_end > max_output_row
|
|
250
|
+
overflow = needed_end - max_output_row
|
|
251
|
+
overflow.times do
|
|
252
|
+
screen.move_cursor(screen.height - 1, 0)
|
|
253
|
+
print "\n"
|
|
254
|
+
@buffer.commit_oldest_lines(1)
|
|
255
|
+
end
|
|
256
|
+
start_row -= overflow
|
|
257
|
+
start_row = 0 if start_row < 0
|
|
258
|
+
@output_row = [start_row + old_n, max_output_row].min
|
|
259
|
+
render_fixed_areas(skip_buffer_rerender: true)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
154
262
|
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
263
|
+
# Clear the rows the entry currently occupies
|
|
264
|
+
(start_row...@output_row).each do |row|
|
|
265
|
+
screen.move_cursor(row, 0)
|
|
266
|
+
screen.clear_line
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Paint the new content
|
|
270
|
+
cur = start_row
|
|
271
|
+
new_lines.each do |line|
|
|
272
|
+
screen.move_cursor(cur, 0)
|
|
273
|
+
print line
|
|
274
|
+
cur += 1
|
|
275
|
+
end
|
|
276
|
+
@output_row = start_row + new_n
|
|
277
|
+
|
|
278
|
+
# If content shrank, extra rows below may still hold the old content
|
|
279
|
+
# if they were outside the cleared range — but since we cleared the
|
|
280
|
+
# full old span above, nothing extra is needed here.
|
|
158
281
|
end
|
|
159
282
|
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return unless @todo_area
|
|
283
|
+
# Clear the last N rows of the output area (used by remove_entry on tail).
|
|
284
|
+
private def clear_tail_rows(n)
|
|
285
|
+
return if n <= 0 || @output_row == 0
|
|
164
286
|
|
|
165
|
-
@
|
|
166
|
-
|
|
167
|
-
old_gap_row = @gap_row
|
|
287
|
+
start_row = @output_row - n
|
|
288
|
+
start_row = 0 if start_row < 0
|
|
168
289
|
|
|
169
|
-
|
|
170
|
-
|
|
290
|
+
(start_row...@output_row).each do |row|
|
|
291
|
+
screen.move_cursor(row, 0)
|
|
292
|
+
screen.clear_line
|
|
293
|
+
end
|
|
294
|
+
@output_row = start_row
|
|
295
|
+
end
|
|
171
296
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
297
|
+
# Repaint the entire output area from the buffer's live entries.
|
|
298
|
+
# Only called on layout changes (resize, fixed-area height change,
|
|
299
|
+
# /clear, fullscreen exit) — never on a normal append path.
|
|
300
|
+
private def render_output_from_buffer
|
|
301
|
+
max_output_row = fixed_area_start_row
|
|
175
302
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
end
|
|
303
|
+
# Wipe the output area
|
|
304
|
+
(0...max_output_row).each do |row|
|
|
305
|
+
screen.move_cursor(row, 0)
|
|
306
|
+
screen.clear_line
|
|
307
|
+
end
|
|
182
308
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
309
|
+
# Fill from the buffer's tail (live lines only — committed lines
|
|
310
|
+
# are already in terminal scrollback and MUST NOT be repainted).
|
|
311
|
+
lines = @buffer.tail_lines(max_output_row)
|
|
312
|
+
@output_row = 0
|
|
313
|
+
lines.each do |line|
|
|
314
|
+
screen.move_cursor(@output_row, 0)
|
|
315
|
+
print line
|
|
316
|
+
@output_row += 1
|
|
186
317
|
end
|
|
187
318
|
end
|
|
188
319
|
|
|
189
|
-
#
|
|
320
|
+
# Wrap user content into screen-width visual lines using the existing
|
|
321
|
+
# ANSI-aware helper. Guarantees at least one line (possibly empty).
|
|
322
|
+
private def wrap_content_to_lines(content)
|
|
323
|
+
raw_lines = content.split("\n", -1)
|
|
324
|
+
wrapped = []
|
|
325
|
+
raw_lines.each do |rl|
|
|
326
|
+
wrapped.concat(wrap_long_line(rl))
|
|
327
|
+
end
|
|
328
|
+
wrapped = [""] if wrapped.empty?
|
|
329
|
+
wrapped
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private def sanitize(content)
|
|
333
|
+
return content if content.valid_encoding?
|
|
334
|
+
Clacky::Utils::Encoding.sanitize_utf8(content)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# -----------------------------------------------------------------------
|
|
338
|
+
# Lifecycle + layout
|
|
339
|
+
# -----------------------------------------------------------------------
|
|
340
|
+
|
|
190
341
|
def initialize_screen
|
|
191
342
|
screen.clear_screen
|
|
192
343
|
screen.hide_cursor
|
|
@@ -194,288 +345,198 @@ module Clacky
|
|
|
194
345
|
render_all
|
|
195
346
|
end
|
|
196
347
|
|
|
197
|
-
# Cleanup the screen (restore cursor)
|
|
198
348
|
def cleanup_screen
|
|
199
349
|
@render_mutex.synchronize do
|
|
200
|
-
# Clear fixed areas (gap + todo + input)
|
|
201
350
|
fixed_start = fixed_area_start_row
|
|
202
351
|
(fixed_start...screen.height).each do |row|
|
|
203
352
|
screen.move_cursor(row, 0)
|
|
204
353
|
screen.clear_line
|
|
205
354
|
end
|
|
206
|
-
|
|
207
|
-
# Move cursor to start of a new line after last output
|
|
208
|
-
# Use \r to ensure we're at column 0, then move down
|
|
209
355
|
screen.move_cursor([@output_row, 0].max, 0)
|
|
210
|
-
print "\r"
|
|
356
|
+
print "\r"
|
|
211
357
|
screen.show_cursor
|
|
212
358
|
screen.flush
|
|
213
359
|
end
|
|
214
360
|
end
|
|
215
361
|
|
|
216
|
-
#
|
|
362
|
+
# /clear: wipe output area + buffer, keep fixed area.
|
|
217
363
|
def clear_output
|
|
218
364
|
@render_mutex.synchronize do
|
|
219
|
-
# Clear all lines in output area (from 0 to where fixed area starts)
|
|
220
365
|
max_row = fixed_area_start_row
|
|
221
366
|
(0...max_row).each do |row|
|
|
222
367
|
screen.move_cursor(row, 0)
|
|
223
368
|
screen.clear_line
|
|
224
369
|
end
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
@
|
|
228
|
-
|
|
229
|
-
# Clear the output buffer so re-renders don't restore old content
|
|
230
|
-
@output_buffer.clear
|
|
231
|
-
|
|
232
|
-
# Re-render fixed areas to ensure they stay in place
|
|
370
|
+
@output_row = 0
|
|
371
|
+
@last_append_id = nil
|
|
372
|
+
@buffer.clear
|
|
233
373
|
render_fixed_areas
|
|
234
374
|
screen.flush
|
|
235
375
|
end
|
|
236
376
|
end
|
|
237
377
|
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
def append_output(content)
|
|
242
|
-
return if content.nil?
|
|
243
|
-
|
|
244
|
-
# Scrub any invalid byte sequences before they reach the render pipeline.
|
|
245
|
-
# wrap_long_line calls each_char which raises ArgumentError on invalid UTF-8.
|
|
246
|
-
content = Clacky::Utils::Encoding.sanitize_utf8(content) unless content.valid_encoding?
|
|
247
|
-
|
|
378
|
+
# Recalculate layout after input height changed. If the layout moved,
|
|
379
|
+
# clear the old fixed area rows and re-render at the new position.
|
|
380
|
+
def recalculate_layout
|
|
248
381
|
@render_mutex.synchronize do
|
|
249
|
-
|
|
382
|
+
old_gap_row = @gap_row
|
|
383
|
+
old_input_row = @input_row
|
|
250
384
|
|
|
251
|
-
|
|
252
|
-
# Wrap long lines to prevent display issues
|
|
253
|
-
wrapped_lines = wrap_long_line(line)
|
|
385
|
+
calculate_layout
|
|
254
386
|
|
|
255
|
-
|
|
256
|
-
|
|
387
|
+
if @input_row != old_input_row
|
|
388
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
389
|
+
screen.move_cursor(row, 0)
|
|
390
|
+
screen.clear_line
|
|
257
391
|
end
|
|
392
|
+
|
|
393
|
+
if input_area.paused?
|
|
394
|
+
# Input paused (InlineInput active) — fixed area shrank, so the
|
|
395
|
+
# cleared rows are now part of the output area. Repaint from
|
|
396
|
+
# buffer to fill them in.
|
|
397
|
+
render_output_from_buffer
|
|
398
|
+
else
|
|
399
|
+
render_fixed_areas
|
|
400
|
+
end
|
|
401
|
+
screen.flush
|
|
258
402
|
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def render_all
|
|
407
|
+
@render_mutex.synchronize { render_all_internal }
|
|
408
|
+
end
|
|
259
409
|
|
|
260
|
-
|
|
410
|
+
def render_output
|
|
411
|
+
@render_mutex.synchronize do
|
|
261
412
|
render_fixed_areas
|
|
262
413
|
screen.flush
|
|
263
414
|
end
|
|
264
415
|
end
|
|
265
416
|
|
|
266
|
-
|
|
267
|
-
# @param content [String] Content to update (may contain newlines for wrapped lines)
|
|
268
|
-
# @param old_line_count [Integer] Number of lines currently occupied (for clearing)
|
|
269
|
-
def update_last_line(content, old_line_count = 1)
|
|
417
|
+
def render_input
|
|
270
418
|
@render_mutex.synchronize do
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
lines = content.split("\n", -1)
|
|
277
|
-
new_line_count = lines.length
|
|
278
|
-
|
|
279
|
-
# Calculate start row (last N lines)
|
|
280
|
-
start_row = @output_row - old_line_count
|
|
281
|
-
start_row = 0 if start_row < 0
|
|
282
|
-
|
|
283
|
-
# If lines grew, check if we would overflow into the fixed area and scroll if needed
|
|
284
|
-
if new_line_count > old_line_count
|
|
285
|
-
max_output_row = fixed_area_start_row
|
|
286
|
-
needed_end_row = start_row + new_line_count
|
|
287
|
-
|
|
288
|
-
if needed_end_row > max_output_row
|
|
289
|
-
# Calculate how many extra rows we need
|
|
290
|
-
overflow = needed_end_row - max_output_row
|
|
291
|
-
|
|
292
|
-
# Scroll the terminal by printing newlines at the bottom of the output area
|
|
293
|
-
overflow.times do
|
|
294
|
-
screen.move_cursor(screen.height - 1, 0)
|
|
295
|
-
print "\n"
|
|
296
|
-
end
|
|
419
|
+
render_fixed_areas
|
|
420
|
+
screen.flush
|
|
421
|
+
end
|
|
422
|
+
end
|
|
297
423
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
424
|
+
def rerender_all
|
|
425
|
+
@render_mutex.synchronize do
|
|
426
|
+
screen.clear_screen
|
|
427
|
+
render_output_from_buffer
|
|
428
|
+
render_fixed_areas
|
|
429
|
+
screen.flush
|
|
430
|
+
end
|
|
431
|
+
end
|
|
302
432
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
433
|
+
# Restore cursor to input area (used after dialogs).
|
|
434
|
+
def restore_cursor_to_input
|
|
435
|
+
input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
|
|
436
|
+
input_area.position_cursor(input_row)
|
|
437
|
+
screen.show_cursor
|
|
438
|
+
end
|
|
308
439
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
440
|
+
# Position cursor for inline input in output area.
|
|
441
|
+
def position_inline_input_cursor(inline_input)
|
|
442
|
+
return unless inline_input
|
|
443
|
+
width = screen.width
|
|
444
|
+
wrap_row, wrap_col = inline_input.cursor_position_for_display(width)
|
|
445
|
+
line_count = inline_input.line_count(width)
|
|
314
446
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
447
|
+
cursor_row = @output_row - line_count + wrap_row
|
|
448
|
+
cursor_col = wrap_col
|
|
449
|
+
screen.move_cursor(cursor_row, cursor_col)
|
|
450
|
+
screen.flush
|
|
451
|
+
end
|
|
319
452
|
|
|
320
|
-
|
|
321
|
-
|
|
453
|
+
# Update todos display; recalculates layout if height changed.
|
|
454
|
+
def update_todos(todos)
|
|
455
|
+
return unless @todo_area
|
|
322
456
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
# Add updated line to buffer
|
|
327
|
-
@output_buffer << line
|
|
328
|
-
current_row += 1
|
|
329
|
-
end
|
|
457
|
+
@render_mutex.synchronize do
|
|
458
|
+
old_height = @todo_area.height
|
|
459
|
+
old_gap_row = @gap_row
|
|
330
460
|
|
|
331
|
-
|
|
332
|
-
|
|
461
|
+
@todo_area.update(todos)
|
|
462
|
+
new_height = @todo_area.height
|
|
333
463
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if old_end_row > @output_row && old_end_row <= start_row + old_line_count
|
|
338
|
-
# Clear the extra old lines
|
|
339
|
-
(@output_row...old_end_row).each do |row|
|
|
464
|
+
if old_height != new_height
|
|
465
|
+
calculate_layout
|
|
466
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
340
467
|
screen.move_cursor(row, 0)
|
|
341
468
|
screen.clear_line
|
|
342
469
|
end
|
|
343
470
|
end
|
|
344
471
|
|
|
345
|
-
|
|
346
|
-
# Skip buffer re-render: the content was written directly above, so
|
|
347
|
-
# re-rendering from buffer would duplicate it in the terminal scrollback.
|
|
348
|
-
render_fixed_areas(skip_buffer_rerender: true)
|
|
472
|
+
render_fixed_areas
|
|
349
473
|
screen.flush
|
|
350
474
|
end
|
|
351
475
|
end
|
|
352
476
|
|
|
353
|
-
# Remove the last N lines from output area
|
|
354
|
-
# @param line_count [Integer] Number of lines to remove (default: 1)
|
|
355
|
-
def remove_last_line(line_count = 1)
|
|
356
|
-
@render_mutex.synchronize do
|
|
357
|
-
# Fullscreen owns the alternate screen; skip main-screen updates
|
|
358
|
-
return if @fullscreen_mode
|
|
359
|
-
|
|
360
|
-
return if @output_row == 0 # No output to remove
|
|
361
|
-
|
|
362
|
-
# Calculate start row for removal
|
|
363
|
-
start_row = @output_row - line_count
|
|
364
|
-
start_row = 0 if start_row < 0
|
|
365
477
|
|
|
366
|
-
# Clear all lines being removed
|
|
367
|
-
(start_row...@output_row).each do |row|
|
|
368
|
-
screen.move_cursor(row, 0)
|
|
369
|
-
screen.clear_line
|
|
370
|
-
end
|
|
371
478
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
end
|
|
479
|
+
# -----------------------------------------------------------------------
|
|
480
|
+
# Fixed area (gap + todo + input) rendering
|
|
481
|
+
# -----------------------------------------------------------------------
|
|
376
482
|
|
|
377
|
-
|
|
378
|
-
|
|
483
|
+
# Repaint gap + todo + input at the bottom of the screen.
|
|
484
|
+
#
|
|
485
|
+
# @param skip_buffer_rerender [Boolean] When true, skip repainting the
|
|
486
|
+
# output area from the buffer even if the fixed-area height changed.
|
|
487
|
+
# Used by the scroll path in paint_new_lines — the caller has just
|
|
488
|
+
# written the correct content directly; a full buffer repaint would
|
|
489
|
+
# duplicate it in terminal scrollback.
|
|
490
|
+
def render_fixed_areas(skip_buffer_rerender: false)
|
|
491
|
+
# When input is paused (InlineInput active), the "input area" is
|
|
492
|
+
# rendered inline with output. Nothing to paint down here.
|
|
493
|
+
return if input_area.paused?
|
|
494
|
+
return if @fullscreen_mode
|
|
379
495
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
496
|
+
current_fixed_height = fixed_area_height
|
|
497
|
+
start_row = fixed_area_start_row
|
|
498
|
+
gap_row = start_row
|
|
499
|
+
todo_row = gap_row + 1
|
|
500
|
+
|
|
501
|
+
# Fixed-area height changed (e.g. multi-line input appeared or
|
|
502
|
+
# command-suggestions popped) → repaint the output from buffer so
|
|
503
|
+
# nothing is hidden.
|
|
504
|
+
if !skip_buffer_rerender &&
|
|
505
|
+
@last_fixed_area_height > 0 &&
|
|
506
|
+
@last_fixed_area_height != current_fixed_height
|
|
507
|
+
render_output_from_buffer
|
|
385
508
|
end
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
# Scroll output area up (legacy no-op)
|
|
389
|
-
# @param lines [Integer] Number of lines to scroll
|
|
390
|
-
def scroll_output_up(lines = 1)
|
|
391
|
-
# No-op - terminal handles scrolling natively
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
# Scroll output area down (legacy no-op)
|
|
395
|
-
# @param lines [Integer] Number of lines to scroll
|
|
396
|
-
def scroll_output_down(lines = 1)
|
|
397
|
-
# No-op - terminal handles scrolling natively
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
# Handle window resize
|
|
401
|
-
private def handle_resize
|
|
402
|
-
# Record old dimensions before updating to detect shrink vs grow
|
|
403
|
-
old_height = screen.height
|
|
404
|
-
old_width = screen.width
|
|
405
|
-
|
|
406
|
-
# Update terminal dimensions and recalculate layout
|
|
407
|
-
screen.update_dimensions
|
|
408
|
-
calculate_layout
|
|
409
|
-
|
|
410
|
-
# When shrinking: full reset (clears scrollback too), otherwise just clear current screen
|
|
411
|
-
shrinking = screen.height < old_height || screen.width < old_width
|
|
412
|
-
screen.clear_screen(mode: shrinking ? :reset : :current)
|
|
509
|
+
@last_fixed_area_height = current_fixed_height
|
|
413
510
|
|
|
414
|
-
#
|
|
415
|
-
|
|
416
|
-
|
|
511
|
+
# gap line
|
|
512
|
+
screen.move_cursor(gap_row, 0)
|
|
513
|
+
screen.clear_line
|
|
417
514
|
|
|
418
|
-
#
|
|
419
|
-
|
|
515
|
+
# todo
|
|
516
|
+
@todo_area.render(start_row: todo_row) if @todo_area&.visible?
|
|
420
517
|
|
|
421
|
-
#
|
|
422
|
-
@
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
@output_row += 1
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
# Sync @last_fixed_area_height so render_fixed_areas won't think the height
|
|
429
|
-
# changed and trigger a second render_output_from_buffer call
|
|
430
|
-
@last_fixed_area_height = fixed_area_height
|
|
518
|
+
# input (renders its own visual cursor)
|
|
519
|
+
input_row = todo_row + (@todo_area&.height || 0)
|
|
520
|
+
input_area.render(start_row: input_row, width: screen.width)
|
|
521
|
+
end
|
|
431
522
|
|
|
432
|
-
|
|
523
|
+
private def render_all_internal
|
|
433
524
|
render_fixed_areas
|
|
434
525
|
screen.flush
|
|
435
526
|
end
|
|
436
527
|
|
|
437
|
-
#
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def write_output_line(line)
|
|
441
|
-
# Add to buffer so content is available when returning from fullscreen
|
|
442
|
-
@output_buffer << line
|
|
528
|
+
# Legacy no-ops — terminal handles native scroll natively.
|
|
529
|
+
def scroll_output_up(_lines = 1); end
|
|
530
|
+
def scroll_output_down(_lines = 1); end
|
|
443
531
|
|
|
444
|
-
# Fullscreen owns the alternate screen; skip rendering to avoid corruption
|
|
445
|
-
return if @fullscreen_mode
|
|
446
|
-
|
|
447
|
-
# Calculate where fixed area starts (this is where output area ends)
|
|
448
|
-
max_output_row = fixed_area_start_row
|
|
449
532
|
|
|
450
|
-
# If we're about to write into the fixed area, scroll first
|
|
451
|
-
if @output_row >= max_output_row
|
|
452
|
-
# Trigger terminal scroll by printing newline at bottom
|
|
453
|
-
screen.move_cursor(screen.height - 1, 0)
|
|
454
|
-
print "\n"
|
|
455
533
|
|
|
456
|
-
|
|
457
|
-
|
|
534
|
+
# -----------------------------------------------------------------------
|
|
535
|
+
# Wrapping helpers (ANSI-aware, East-Asian-width aware)
|
|
536
|
+
# -----------------------------------------------------------------------
|
|
458
537
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
# buffer would put a duplicate into the scrollback (the \n above already
|
|
462
|
-
# pushed previous content up, so a full buffer repaint here would repeat it).
|
|
463
|
-
render_fixed_areas(skip_buffer_rerender: true)
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
# Now write the line at current position
|
|
467
|
-
screen.move_cursor(@output_row, 0)
|
|
468
|
-
screen.clear_line
|
|
469
|
-
print line
|
|
470
|
-
|
|
471
|
-
# Move to next row for next write
|
|
472
|
-
@output_row += 1
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
# Wrap a long line into multiple lines based on terminal width
|
|
476
|
-
# Considers display width of multi-byte characters (e.g., Chinese characters)
|
|
477
|
-
# @param line [String] Line to wrap
|
|
478
|
-
# @return [Array<String>] Array of wrapped lines
|
|
538
|
+
# Wrap a long line into multiple lines based on terminal width.
|
|
539
|
+
# Considers display width of multi-byte characters (e.g., Chinese characters).
|
|
479
540
|
def wrap_long_line(line)
|
|
480
541
|
return [""] if line.nil? || line.empty?
|
|
481
542
|
|
|
@@ -485,55 +546,40 @@ module Clacky
|
|
|
485
546
|
# Strip ANSI codes for width calculation
|
|
486
547
|
visible_line = line.gsub(/\e\[[0-9;]*m/, '')
|
|
487
548
|
|
|
488
|
-
# Check if line needs wrapping
|
|
489
549
|
display_width = calculate_display_width(visible_line)
|
|
490
550
|
return [line] if display_width <= max_width
|
|
491
551
|
|
|
492
|
-
|
|
493
|
-
wrapped = []
|
|
552
|
+
wrapped = []
|
|
494
553
|
current_line = ""
|
|
495
554
|
current_width = 0
|
|
496
|
-
ansi_codes
|
|
555
|
+
ansi_codes = []
|
|
497
556
|
|
|
498
|
-
# Extract ANSI codes and text segments
|
|
499
557
|
segments = line.split(/(\e\[[0-9;]*m)/)
|
|
500
558
|
|
|
501
559
|
segments.each do |segment|
|
|
502
560
|
if segment =~ /^\e\[[0-9;]*m$/
|
|
503
|
-
# ANSI code - add to current codes
|
|
504
561
|
ansi_codes << segment
|
|
505
562
|
current_line += segment
|
|
506
563
|
else
|
|
507
|
-
# Text segment - process character by character
|
|
508
564
|
segment.each_char do |char|
|
|
509
565
|
char_width = char_display_width(char)
|
|
510
|
-
|
|
511
566
|
if current_width + char_width > max_width && !current_line.empty?
|
|
512
|
-
# Complete current line
|
|
513
567
|
wrapped << current_line
|
|
514
|
-
# Start new line with carried-over ANSI codes
|
|
515
568
|
current_line = ansi_codes.join
|
|
516
569
|
current_width = 0
|
|
517
570
|
end
|
|
518
|
-
|
|
519
571
|
current_line += char
|
|
520
572
|
current_width += char_width
|
|
521
573
|
end
|
|
522
574
|
end
|
|
523
575
|
end
|
|
524
576
|
|
|
525
|
-
# Add remaining content
|
|
526
577
|
wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join
|
|
527
|
-
|
|
528
578
|
wrapped.empty? ? [""] : wrapped
|
|
529
579
|
end
|
|
530
580
|
|
|
531
|
-
# Calculate display width of a single character
|
|
532
|
-
# @param char [String] Single character
|
|
533
|
-
# @return [Integer] Display width (1 or 2)
|
|
534
581
|
def char_display_width(char)
|
|
535
582
|
code = char.ord
|
|
536
|
-
# East Asian Wide and Fullwidth characters take 2 columns
|
|
537
583
|
if (code >= 0x1100 && code <= 0x115F) ||
|
|
538
584
|
(code >= 0x2329 && code <= 0x232A) ||
|
|
539
585
|
(code >= 0x2E80 && code <= 0x303E) ||
|
|
@@ -553,221 +599,125 @@ module Clacky
|
|
|
553
599
|
end
|
|
554
600
|
end
|
|
555
601
|
|
|
556
|
-
# Calculate display width of a string (considering multi-byte characters)
|
|
557
|
-
# @param text [String] Text to calculate
|
|
558
|
-
# @return [Integer] Display width
|
|
559
602
|
def calculate_display_width(text)
|
|
560
603
|
width = 0
|
|
561
|
-
text.each_char
|
|
562
|
-
width += char_display_width(char)
|
|
563
|
-
end
|
|
604
|
+
text.each_char { |c| width += char_display_width(c) }
|
|
564
605
|
width
|
|
565
606
|
end
|
|
566
607
|
|
|
567
|
-
#
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
input_height = @input_area.required_height
|
|
571
|
-
1 + todo_height + input_height # gap + todo + input
|
|
572
|
-
end
|
|
608
|
+
# -----------------------------------------------------------------------
|
|
609
|
+
# Resize handling
|
|
610
|
+
# -----------------------------------------------------------------------
|
|
573
611
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
screen.
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
# Render fixed areas (gap, todo, input) at screen bottom
|
|
580
|
-
# @param skip_buffer_rerender [Boolean] When true, skip the render_output_from_buffer
|
|
581
|
-
# even if fixed_area_height changed. Use this when the caller has already written
|
|
582
|
-
# the correct content directly (update_last_line, remove_last_line) to avoid
|
|
583
|
-
# re-rendering buffer content that would duplicate entries in the terminal scrollback.
|
|
584
|
-
def render_fixed_areas(skip_buffer_rerender: false)
|
|
585
|
-
# When input is paused (InlineInput active), don't render fixed areas
|
|
586
|
-
# The InlineInput is rendered inline with output
|
|
587
|
-
return if input_area.paused?
|
|
588
|
-
|
|
589
|
-
# Do not corrupt the alternate screen while in fullscreen mode
|
|
590
|
-
return if @fullscreen_mode
|
|
591
|
-
|
|
592
|
-
current_fixed_height = fixed_area_height
|
|
593
|
-
start_row = fixed_area_start_row
|
|
594
|
-
gap_row = start_row
|
|
595
|
-
todo_row = gap_row + 1
|
|
596
|
-
input_row = todo_row + (@todo_area&.height || 0)
|
|
597
|
-
|
|
598
|
-
# Detect height changes and re-render output area if needed.
|
|
599
|
-
# Skip when the caller (update_last_line / remove_last_line) has already
|
|
600
|
-
# written the correct content directly — re-rendering would push duplicate
|
|
601
|
-
# content into the terminal scrollback, causing repeated lines on scroll-up.
|
|
602
|
-
if !skip_buffer_rerender && @last_fixed_area_height > 0 && @last_fixed_area_height != current_fixed_height
|
|
603
|
-
# Fixed area height changed - re-render output area from buffer
|
|
604
|
-
# This prevents output content from being hidden when fixed area grows
|
|
605
|
-
# (e.g., multi-line input, command suggestions appearing)
|
|
606
|
-
render_output_from_buffer
|
|
607
|
-
end
|
|
608
|
-
|
|
609
|
-
# Update last height for next comparison
|
|
610
|
-
@last_fixed_area_height = current_fixed_height
|
|
612
|
+
private def handle_resize
|
|
613
|
+
old_height = screen.height
|
|
614
|
+
old_width = screen.width
|
|
611
615
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
screen.clear_line
|
|
616
|
+
screen.update_dimensions
|
|
617
|
+
calculate_layout
|
|
615
618
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
@todo_area.render(start_row: todo_row)
|
|
619
|
-
end
|
|
619
|
+
shrinking = screen.height < old_height || screen.width < old_width
|
|
620
|
+
screen.clear_screen(mode: shrinking ? :reset : :current)
|
|
620
621
|
|
|
621
|
-
#
|
|
622
|
-
|
|
623
|
-
|
|
622
|
+
# Repaint from buffer — only live (uncommitted) lines, which is
|
|
623
|
+
# exactly what we want: committed content already sits in the
|
|
624
|
+
# native scrollback above.
|
|
625
|
+
render_output_from_buffer
|
|
624
626
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
627
|
+
# Sync so render_fixed_areas won't think height changed and
|
|
628
|
+
# trigger a second repaint.
|
|
629
|
+
@last_fixed_area_height = fixed_area_height
|
|
628
630
|
render_fixed_areas
|
|
629
631
|
screen.flush
|
|
630
632
|
end
|
|
631
633
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
screen.show_cursor
|
|
634
|
+
private def setup_resize_handler
|
|
635
|
+
Signal.trap("WINCH") { @resize_pending = true }
|
|
636
|
+
rescue ArgumentError => e
|
|
637
|
+
warn "WINCH signal already trapped: #{e.message}"
|
|
637
638
|
end
|
|
638
639
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
@
|
|
642
|
-
|
|
643
|
-
screen.hide_cursor
|
|
644
|
-
render_all_internal
|
|
645
|
-
end
|
|
640
|
+
def process_pending_resize
|
|
641
|
+
return unless @resize_pending
|
|
642
|
+
@resize_pending = false
|
|
643
|
+
handle_resize_safely
|
|
646
644
|
end
|
|
647
645
|
|
|
648
|
-
|
|
649
|
-
|
|
646
|
+
private def handle_resize_safely
|
|
647
|
+
@render_mutex.synchronize { handle_resize }
|
|
648
|
+
rescue => e
|
|
649
|
+
warn "Resize error: #{e.message}"
|
|
650
|
+
warn e.backtrace.first(5).join("\n") if e.backtrace
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# -----------------------------------------------------------------------
|
|
654
|
+
# Fullscreen (alternate screen buffer)
|
|
655
|
+
# -----------------------------------------------------------------------
|
|
656
|
+
|
|
650
657
|
def fullscreen_mode?
|
|
651
658
|
@fullscreen_mode
|
|
652
659
|
end
|
|
653
660
|
|
|
654
|
-
# Enter fullscreen mode with alternate screen buffer
|
|
655
|
-
# @param lines [Array<String>] Lines to display
|
|
656
|
-
# @param hint [String] Hint message at bottom
|
|
657
661
|
def enter_fullscreen(lines, hint: "Press Ctrl+O to return")
|
|
658
662
|
@render_mutex.synchronize do
|
|
659
663
|
return if @fullscreen_mode
|
|
660
|
-
|
|
661
664
|
@fullscreen_mode = true
|
|
662
665
|
@fullscreen_hint = hint
|
|
663
666
|
|
|
664
|
-
#
|
|
665
|
-
# \e[?1049h - switch to alternate screen buffer (separate from primary)
|
|
666
|
-
# \e[2J - erase the entire visible screen
|
|
667
|
-
# \e[H - move cursor to top-left
|
|
668
|
-
# The alternate screen buffer has no scrollback history by design, so
|
|
669
|
-
# there is nothing to scroll up to once we clear the visible area.
|
|
667
|
+
# Switch to alternate screen, clear it, position top-left.
|
|
670
668
|
print "\e[?1049h\e[2J\e[H"
|
|
671
669
|
$stdout.flush
|
|
672
|
-
|
|
673
670
|
render_fullscreen_content(lines)
|
|
674
671
|
end
|
|
675
672
|
end
|
|
676
673
|
|
|
677
|
-
# Refresh fullscreen content in-place (for real-time updates without re-entering alt screen)
|
|
678
|
-
# @param lines [Array<String>] Updated lines to display
|
|
679
674
|
def refresh_fullscreen(lines)
|
|
680
675
|
@render_mutex.synchronize do
|
|
681
676
|
return unless @fullscreen_mode
|
|
682
|
-
|
|
683
|
-
# Move cursor to top-left and erase visible area, then redraw
|
|
684
677
|
print "\e[2J\e[H"
|
|
685
678
|
render_fullscreen_content(lines)
|
|
686
679
|
end
|
|
687
680
|
end
|
|
688
681
|
|
|
689
|
-
# Exit fullscreen mode and restore previous screen
|
|
690
682
|
def exit_fullscreen
|
|
691
683
|
@render_mutex.synchronize do
|
|
692
684
|
return unless @fullscreen_mode
|
|
693
|
-
|
|
694
685
|
@fullscreen_mode = false
|
|
695
686
|
@fullscreen_hint = nil
|
|
696
|
-
|
|
697
|
-
# Exit alternate screen buffer (automatically restores previous screen content)
|
|
698
687
|
print "\e[?1049l"
|
|
699
688
|
$stdout.flush
|
|
700
689
|
end
|
|
701
690
|
end
|
|
702
691
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
692
|
+
def restore_screen
|
|
693
|
+
@render_mutex.synchronize do
|
|
694
|
+
screen.clear_screen
|
|
695
|
+
screen.hide_cursor
|
|
696
|
+
render_all_internal
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
707
700
|
private def render_fullscreen_content(lines)
|
|
708
701
|
term_height = screen.height
|
|
709
702
|
term_width = screen.width
|
|
710
703
|
|
|
711
|
-
|
|
712
|
-
content_rows = term_height - 1
|
|
713
|
-
|
|
714
|
-
# Trim or pad lines to exactly fill the content area
|
|
704
|
+
content_rows = term_height - 1
|
|
715
705
|
display_lines = lines.first(content_rows)
|
|
716
706
|
|
|
717
|
-
# Print each content line, padded with spaces to full terminal width so
|
|
718
|
-
# no stale characters from a previous render remain on the right side.
|
|
719
707
|
display_lines.each do |line|
|
|
720
|
-
# Strip trailing whitespace then pad to terminal width (ignoring ANSI codes for width calc)
|
|
721
708
|
visible = line.chomp.gsub(/\e\[[0-9;]*m/, "")
|
|
722
709
|
padding = [term_width - visible.length, 0].max
|
|
723
710
|
print line.chomp + (" " * padding) + "\r\n"
|
|
724
711
|
end
|
|
725
712
|
|
|
726
|
-
# Fill any remaining content rows with blank lines so nothing from a
|
|
727
|
-
# previous render bleeds through when content shrinks.
|
|
728
713
|
blank_row = " " * term_width
|
|
729
|
-
(display_lines.length...content_rows).each
|
|
730
|
-
print blank_row + "\r\n"
|
|
731
|
-
end
|
|
714
|
+
(display_lines.length...content_rows).each { print blank_row + "\r\n" }
|
|
732
715
|
|
|
733
|
-
# Pin the hint bar at the very bottom row using absolute cursor positioning.
|
|
734
|
-
# \e[{row};{col}H moves to the given 1-based row/col.
|
|
735
716
|
hint_text = "\e[36m#{@fullscreen_hint}\e[0m"
|
|
736
717
|
print "\e[#{term_height};1H#{hint_text}\e[0K"
|
|
737
|
-
|
|
738
718
|
$stdout.flush
|
|
739
719
|
end
|
|
740
720
|
|
|
741
|
-
# Setup handler for window resize
|
|
742
|
-
# Note: Signal handlers run in trap context where many operations are restricted
|
|
743
|
-
private def setup_resize_handler
|
|
744
|
-
Signal.trap("WINCH") do
|
|
745
|
-
# Simply set a flag - actual resize handling happens in main thread
|
|
746
|
-
@resize_pending = true
|
|
747
|
-
end
|
|
748
|
-
rescue ArgumentError => e
|
|
749
|
-
# Signal already trapped (shouldn't happen now)
|
|
750
|
-
warn "WINCH signal already trapped: #{e.message}"
|
|
751
|
-
end
|
|
752
|
-
|
|
753
|
-
# Check and process pending resize (should be called from main thread periodically)
|
|
754
|
-
def process_pending_resize
|
|
755
|
-
return unless @resize_pending
|
|
756
|
-
|
|
757
|
-
@resize_pending = false
|
|
758
|
-
handle_resize_safely
|
|
759
|
-
end
|
|
760
|
-
|
|
761
|
-
# Thread-safe wrapper for handle_resize
|
|
762
|
-
private def handle_resize_safely
|
|
763
|
-
@render_mutex.synchronize do
|
|
764
|
-
handle_resize
|
|
765
|
-
end
|
|
766
|
-
rescue => e
|
|
767
|
-
# Catch and log errors to prevent resize from crashing the app
|
|
768
|
-
warn "Resize error: #{e.message}"
|
|
769
|
-
warn e.backtrace.first(5).join("\n") if e.backtrace
|
|
770
|
-
end
|
|
771
721
|
end
|
|
772
722
|
end
|
|
773
723
|
end
|