canopus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -9,9 +9,11 @@ module Canopus
9
9
  Link = Data.define(:kind, :target, :line, :column)
10
10
 
11
11
  def terminal_link_at(point)
12
- return unless @terminal_bounds&.contains?(point) && @workspace.terminal
13
- column, row = terminal_point(point)
14
- cells = terminal_row(@terminal_first + row)
12
+ return unless @terminal_bounds&.contains?(point) && displayed_terminal
13
+ position = terminal_source_point(point)
14
+ return unless position
15
+ column, row = position
16
+ cells = terminal_row(row)
15
17
  column -= 1 while column.positive? && cells[column].width.zero?
16
18
  cell = cells[column]
17
19
  return terminal_link(cell.hyperlink, explicit: true) if cell.hyperlink
@@ -63,24 +65,34 @@ module Canopus
63
65
  format("#%02x%02x%02x", ramp[index / 36], ramp[index / 6 % 6], ramp[index % 6])
64
66
  end
65
67
  def terminal_point(point)
66
- grid = @workspace.terminal.grid
67
- column = ((point.x - @terminal_bounds.x) / @terminal_cell_width).floor.clamp(0, grid.columns - 1)
68
- row = ((point.y - @terminal_bounds.y) / (@terminal_line_height || @line_height)).floor.clamp(0, grid.rows - 1)
69
- [column, row]
68
+ grid = displayed_terminal.grid
69
+ position = terminal_source_point(point)
70
+ return unless position
71
+ column, row = position
72
+ [column, (row - grid.scrollback.length).clamp(0, grid.rows - 1)]
70
73
  end
71
74
  def terminal_scroll(delta)
72
- @terminal_scroll = ((@terminal_scroll || 0) - delta).clamp(0, @workspace.terminal.grid.scrollback.length)
75
+ map = terminal_row_map
76
+ maximum = [map[:visible] - displayed_terminal.grid.rows, 0].max
77
+ @terminal_scroll = ((@terminal_scroll || 0) - delta).clamp(0, maximum)
73
78
  @terminal_selection = nil
74
79
  end
75
80
  def terminal_select(point, extend: false)
76
- column, row = terminal_point(point)
77
- index = [@terminal_first + row, column]
81
+ position = terminal_source_point(point)
82
+ return unless position
83
+ column, row = position
84
+ index = [row, column]
78
85
  @terminal_selection = extend && @terminal_selection ? [@terminal_selection.first, index] : [index, index]
79
86
  end
80
87
  def terminal_selected_text
81
88
  return "" unless @terminal_selection
82
89
  first, last = @terminal_selection.sort
83
- (first.first..last.first).map do |row|
90
+ map = terminal_row_map
91
+ from_row = terminal_display_for_source(map, first.first)
92
+ to_row = terminal_display_for_source(map, last.first)
93
+ return "" unless from_row && to_row
94
+ (from_row..to_row).map do |display_row|
95
+ row = terminal_source_for_display(map, display_row)
84
96
  cells = terminal_row(row)
85
97
  from = row == first.first ? first.last : 0
86
98
  to = row == last.first ? last.last : cells.length - 1
@@ -88,7 +100,150 @@ module Canopus
88
100
  end.join("\n")
89
101
  end
90
102
 
103
+ def terminal_command(direction)
104
+ terminal = displayed_terminal
105
+ return false unless terminal&.respond_to?(:commands) && !terminal.grid.alternate?
106
+ map = terminal_row_map(terminal)
107
+ maximum = [map[:visible] - terminal.grid.rows, 0].max
108
+ first_display = maximum - (@terminal_scroll || 0).floor
109
+ current = map[:base] + terminal_source_for_display(map, first_display)
110
+ commands = terminal.commands.select { |command| command.prompt_row >= map[:base] }
111
+ command = if direction == :previous
112
+ commands.reverse.find { |item| @terminal_scroll.to_i.zero? ? item.prompt_row <= current : item.prompt_row < current }
113
+ elsif direction == :next
114
+ commands.find { |item| item.prompt_row > current }
115
+ else
116
+ raise ArgumentError, "invalid terminal command direction"
117
+ end
118
+ terminal_command_jump(command)
119
+ end
120
+
121
+ def terminal_command_jump(command)
122
+ return false unless command && displayed_terminal && !displayed_terminal.grid.alternate?
123
+ map = terminal_row_map
124
+ source = command.prompt_row - map[:base]
125
+ display = terminal_display_for_source(map, source)
126
+ return false unless display
127
+ maximum = [map[:visible] - displayed_terminal.grid.rows, 0].max
128
+ @terminal_scroll = maximum - [display, maximum].min
129
+ @terminal_selection = nil
130
+ true
131
+ end
132
+
133
+ def terminal_toggle_command(command = nil)
134
+ terminal = displayed_terminal
135
+ return false unless terminal&.respond_to?(:commands) && !terminal.grid.alternate?
136
+ map = terminal_row_map(terminal)
137
+ unless command
138
+ maximum = [map[:visible] - terminal.grid.rows, 0].max
139
+ first = maximum - (@terminal_scroll || 0).floor
140
+ last = [first + terminal.grid.rows - 1, map[:visible] - 1].min
141
+ current = map[:base] + terminal_source_for_display(map, last)
142
+ command = terminal.commands.reverse.find { |item| item.prompt_row <= current && terminal_fold_interval(item, map) }
143
+ end
144
+ return false unless command && terminal_fold_interval(command, map)
145
+ folds = terminal_folds(terminal)
146
+ folds[command.id] ? folds.delete(command.id) : folds[command.id] = true
147
+ @terminal_selection = nil
148
+ terminal_command_jump(command)
149
+ true
150
+ end
151
+
152
+ def focus_terminal(terminal)
153
+ return terminal if @terminal_owner.equal?(terminal)
154
+ store_terminal_state if @terminal_owner
155
+ @terminal_owner = terminal
156
+ state = terminal&.instance_variable_get(:@canopus_terminal_view_state)
157
+ @terminal_scroll = state&.fetch(:scroll, 0) || 0
158
+ @terminal_selection = state&.[](:selection)
159
+ @terminal_bounds = state&.[](:bounds)
160
+ @terminal_visible_rows = state&.[](:visible_rows)
161
+ @terminal_first = state&.fetch(:first, 0) || 0
162
+ @terminal_first_display = state&.fetch(:first_display, 0) || 0
163
+ @terminal_cell_width = state&.[](:cell_width)
164
+ @terminal_line_height = state&.[](:line_height)
165
+ @terminal_font_size = state&.[](:font_size)
166
+ terminal
167
+ end
168
+
91
169
  private
170
+ def store_terminal_state
171
+ @terminal_owner.instance_variable_set(:@canopus_terminal_view_state,
172
+ {scroll: @terminal_scroll || 0, selection: @terminal_selection,
173
+ bounds: @terminal_bounds, visible_rows: @terminal_visible_rows, first: @terminal_first || 0,
174
+ first_display: @terminal_first_display || 0, cell_width: @terminal_cell_width,
175
+ line_height: @terminal_line_height, font_size: @terminal_font_size})
176
+ end
177
+
178
+ def terminal_visual_point(point)
179
+ grid = displayed_terminal.grid
180
+ column = ((point.x - @terminal_bounds.x) / @terminal_cell_width).floor.clamp(0, grid.columns - 1)
181
+ row = ((point.y - @terminal_bounds.y) / (@terminal_line_height || @line_height)).floor.clamp(0, grid.rows - 1)
182
+ [column, row]
183
+ end
184
+
185
+ def terminal_source_point(point)
186
+ column, row = terminal_visual_point(point)
187
+ source = @terminal_visible_rows ? @terminal_visible_rows[row] : @terminal_first.to_i + row
188
+ [column, source] if source
189
+ end
190
+
191
+ def terminal_folds(terminal)
192
+ terminal.instance_variable_get(:@canopus_command_folds) ||
193
+ terminal.instance_variable_set(:@canopus_command_folds, {})
194
+ end
195
+
196
+ def terminal_fold_interval(command, map)
197
+ range = command.output_range
198
+ return unless range && range.begin.is_a?(Integer) && range.end.is_a?(Integer)
199
+ first = [range.begin, command.prompt_row + 1, map[:base]].max - map[:base]
200
+ last = [range.end - 1, map[:base] + map[:length] - 1].min - map[:base]
201
+ [first, last] if first <= last
202
+ end
203
+
204
+ def terminal_row_map(terminal = displayed_terminal)
205
+ grid = terminal.grid
206
+ base = grid.scrollback.total - grid.scrollback.length
207
+ length = grid.scrollback.length + grid.rows
208
+ map = {base: base, length: length}
209
+ return map.merge(intervals: [], visible: length) if grid.alternate?
210
+ commands = terminal.respond_to?(:commands) ? terminal.commands : []
211
+ commands = commands.select { |command| command.prompt_row.between?(base, base + length - 1) }
212
+ folds = terminal_folds(terminal)
213
+ ids = commands.to_h { |command| [command.id, true] }
214
+ folds.delete_if { |id| !ids.key?(id) }
215
+ intervals = commands.filter_map { |command| terminal_fold_interval(command, map) if folds[command.id] }
216
+ .sort_by(&:first).each_with_object([]) do |interval, merged|
217
+ if merged.last && interval.first <= merged.last.last + 1
218
+ merged.last[1] = [merged.last.last, interval.last].max
219
+ else
220
+ merged << interval
221
+ end
222
+ end
223
+ map.merge(intervals: intervals, visible: length - intervals.sum { |first, last| last - first + 1 })
224
+ end
225
+
226
+ def terminal_source_for_display(map, display)
227
+ return unless display.between?(0, map[:visible] - 1)
228
+ source = display
229
+ map[:intervals].each do |first, last|
230
+ break if source < first
231
+ source += last - first + 1
232
+ end
233
+ source
234
+ end
235
+
236
+ def terminal_display_for_source(map, source)
237
+ return unless source.between?(0, map[:length] - 1)
238
+ hidden = 0
239
+ map[:intervals].each do |first, last|
240
+ return if source.between?(first, last)
241
+ break if source < first
242
+ hidden += last - first + 1
243
+ end
244
+ source - hidden
245
+ end
246
+
92
247
  def terminal_link(value, explicit: false)
93
248
  return if value.empty? || value.bytesize > 8192 || value.match?(/[\x00-\x1f\x7f]/)
94
249
  if value.match?(/\Ahttps?:\/\//i)
@@ -113,7 +268,7 @@ module Canopus
113
268
  if (location = value.match(/\A(.+?)(?::(\d+)(?::(\d+))?|\((\d+)(?:,(\d+))?\))\z/))
114
269
  value, line, column = location[1], (location[2] || location[4]).to_i, (location[3] || location[5])&.to_i
115
270
  end
116
- directory = @workspace.terminal.vt.cwd
271
+ directory = displayed_terminal.respond_to?(:cwd) ? displayed_terminal.cwd : displayed_terminal.vt.cwd
117
272
  directory = @workspace.root unless directory && File.directory?(directory)
118
273
  path = File.expand_path(value, directory)
119
274
  Link.new(:file, path, line, column) if File.file?(path)
@@ -131,47 +286,113 @@ module Canopus
131
286
  end
132
287
 
133
288
  def terminal_row(index)
134
- grid = @workspace.terminal.grid
289
+ grid = displayed_terminal.grid
135
290
  index < grid.scrollback.length ? grid.scrollback[index] : grid.cells[index - grid.scrollback.length]
136
291
  end
292
+ def displayed_terminal = @terminal_owner || @workspace.terminal
137
293
  def render_terminal(bounds)
138
294
  fill(bounds, :panel)
139
- @terminal_bounds = nil
140
- terminal = @workspace.terminal
141
- unless terminal
295
+ active = @workspace.terminal
296
+ unless active
297
+ focus_terminal(nil)
142
298
  text("No terminals — Ctrl+Shift+` to create one", bounds.x + 12, bounds.y + 8, color: :muted, size: 12)
143
299
  return
144
300
  end
145
- if @terminal_owner != terminal
146
- @terminal_owner, @terminal_scroll, @terminal_selection = terminal, 0, nil
301
+ render_pty_tabs(bounds, entries: @workspace.terminals, active: active,
302
+ title: ->(item) { @workspace.terminal_title(item) }, prefix: :terminal)
303
+ body = Zaniah::Bounds.new(bounds.x, bounds.y + 28, bounds.width, [bounds.height - 28, 0].max)
304
+ render_terminal_layout(@workspace.terminal_layout || {terminal: active}, body)
305
+ focus_terminal(active)
306
+ end
307
+
308
+ def render_task_output(bounds)
309
+ render_pty_panel(bounds, entries: @workspace.task_outputs, active: @workspace.task_output,
310
+ title: ->(item) { @workspace.task_output_title(item) }, prefix: :task_output,
311
+ empty: "No task output — run task with Cmd/Ctrl+Shift+B") { |item| item.terminal }
312
+ end
313
+
314
+ def render_pty_panel(bounds, entries:, active:, title:, prefix:, empty:, &terminal_for)
315
+ fill(bounds, :panel)
316
+ terminal = active && terminal_for.call(active)
317
+ unless terminal
318
+ focus_terminal(nil)
319
+ text(empty, bounds.x + 12, bounds.y + 8, color: :muted, size: 12)
320
+ return
147
321
  end
322
+ render_pty_tabs(bounds, entries: entries, active: active, title: title, prefix: prefix)
323
+ focus_terminal(terminal)
324
+ body = Zaniah::Bounds.new(bounds.x, bounds.y + 28, bounds.width, [bounds.height - 28, 0].max)
325
+ render_terminal_content(terminal, body, prefix)
326
+ store_terminal_state
327
+ end
328
+
329
+ def render_pty_tabs(bounds, entries:, active:, title:, prefix:)
148
330
  x = bounds.x
149
- @workspace.terminals.each_with_index do |current, index|
150
- label = @workspace.terminal_title(current)
331
+ entries.each_with_index do |current, index|
332
+ label = title.call(current)
151
333
  width = [[label.length * 7.5 + 42, 100].max, 220].min
152
334
  tab = Zaniah::Bounds.new(x, bounds.y, width, 28)
153
- fill(tab, :active_tab) if current.equal?(terminal)
154
- text(label, tab.x + 10, tab.y + 6, color: current.equal?(terminal) ? :foreground : :muted, size: 12)
155
- region(tab, role: :tab, label: label, action: [:terminal_tab, index])
335
+ fill(tab, :active_tab) if current.equal?(active)
336
+ text(label, tab.x + 10, tab.y + 6, color: current.equal?(active) ? :foreground : :muted, size: 12)
337
+ tab_action = prefix == :terminal ? :terminal_tab : :task_output_tab
338
+ region(tab, role: :tab, label: label, action: [tab_action, index])
156
339
  close = Zaniah::Bounds.new(tab.right - 25, tab.y, 25, tab.height)
157
- text("×", close.x + 6, close.y + 5, color: :muted, size: 12)
158
- region(close, role: :button, label: "Close #{label}", action: [:terminal_close, index])
340
+ running = prefix == :task_output && @workspace.task_runner.running?(current)
341
+ text(running ? "■" : "×", close.x + 6, close.y + 5, color: :muted, size: 12)
342
+ close_action = prefix == :terminal ? :terminal_close : :task_output_close
343
+ region(close, role: :button, label: "#{running ? 'Stop' : 'Close'} #{label}", action: [close_action, index])
159
344
  x += width
160
345
  end
346
+ end
347
+
348
+ def render_terminal_layout(node, bounds)
349
+ if node[:terminal]
350
+ focus_terminal(node[:terminal])
351
+ render_terminal_content(node[:terminal], bounds, :terminal)
352
+ store_terminal_state
353
+ return
354
+ end
355
+ horizontal = node[:direction] == :horizontal
356
+ ratio = node.fetch(:ratio, 0.5)
357
+ node[:children].each_with_index do |child, index|
358
+ start, fraction = index.zero? ? [0, ratio] : [ratio, 1 - ratio]
359
+ part = if horizontal
360
+ Zaniah::Bounds.new(bounds.x + bounds.width * start, bounds.y, bounds.width * fraction, bounds.height)
361
+ else
362
+ Zaniah::Bounds.new(bounds.x, bounds.y + bounds.height * start, bounds.width, bounds.height * fraction)
363
+ end
364
+ render_terminal_layout(child, part)
365
+ end
366
+ divider = horizontal ?
367
+ Zaniah::Bounds.new(bounds.x + bounds.width * ratio - 2, bounds.y, 4, bounds.height) :
368
+ Zaniah::Bounds.new(bounds.x, bounds.y + bounds.height * ratio - 2, bounds.width, 4)
369
+ fill(divider, :border)
370
+ region(divider, role: :separator, label: "Resize terminal split", action: [:split_resize, node, bounds])
371
+ end
372
+
373
+ def render_terminal_content(terminal, bounds, prefix)
161
374
  @terminal_font_size = @workspace.settings["terminal"]["font_size"] || @font_size
162
375
  @terminal_line_height = (@terminal_font_size * @workspace.settings["terminal"]["line_height"]).ceil
163
376
  @terminal_cell_width = @cx.text_system&.layout_line("M", size: @terminal_font_size)&.width || @terminal_font_size * 0.6
164
377
  columns = [(bounds.width - 24) / @terminal_cell_width, @workspace.settings["terminal"]["min_cols"]].max.floor
165
- rows = [(bounds.height - 40) / @terminal_line_height, @workspace.settings["terminal"]["min_rows"]].max.floor
166
- @workspace.resize_terminal(columns, rows)
378
+ rows = [(bounds.height - 12) / @terminal_line_height, @workspace.settings["terminal"]["min_rows"]].max.floor
379
+ prefix == :terminal ? @workspace.resize_terminal(columns, rows, terminal: terminal) : @workspace.resize_task_output(columns, rows)
167
380
  grid = terminal.grid
168
- @terminal_bounds = Zaniah::Bounds.new(bounds.x + 12, bounds.y + 32, columns * @terminal_cell_width, rows * @terminal_line_height).intersect(bounds)
169
- @terminal_scroll = (@terminal_scroll || 0).clamp(0, grid.scrollback.length)
170
- @terminal_first = grid.scrollback.length - @terminal_scroll.floor
381
+ @terminal_bounds = Zaniah::Bounds.new(bounds.x + 12, bounds.y + 4,
382
+ columns * @terminal_cell_width, rows * @terminal_line_height).intersect(bounds)
383
+ map = terminal_row_map(terminal)
384
+ maximum = [map[:visible] - rows, 0].max
385
+ @terminal_scroll = (@terminal_scroll || 0).clamp(0, maximum)
386
+ @terminal_first_display = maximum - @terminal_scroll.floor
387
+ @terminal_visible_rows = rows.times.map { |row| terminal_source_for_display(map, @terminal_first_display + row) }
388
+ @terminal_first = @terminal_visible_rows.compact.first || 0
171
389
  selection = @terminal_selection&.sort
390
+ command_regions = []
172
391
  @scene.clip(@terminal_bounds) do
173
392
  rows.times do |row|
174
- cells = terminal_row(@terminal_first + row)
393
+ source_row = @terminal_visible_rows[row]
394
+ next unless source_row
395
+ cells = terminal_row(source_row)
175
396
  cells.each_with_index do |cell, column|
176
397
  next if cell.width.zero?
177
398
  x, y = @terminal_bounds.x + column * @terminal_cell_width, @terminal_bounds.y + row * @terminal_line_height
@@ -180,7 +401,7 @@ module Canopus
180
401
  foreground, background = background, foreground if cell.attributes[:inverse]
181
402
  rect = Zaniah::Bounds.new(x, y, cell.width * @terminal_cell_width, @terminal_line_height)
182
403
  fill(rect, background) if background != @theme[:panel]
183
- position = [@terminal_first + row, column]
404
+ position = [source_row, column]
184
405
  fill(rect, :selection) if selection && (position <=> selection.first) >= 0 && (position <=> selection.last) <= 0
185
406
  next if cell.attributes[:hidden]
186
407
  foreground = Zaniah::Color.parse(foreground).opacity(0.6) if cell.attributes[:dim]
@@ -195,31 +416,61 @@ module Canopus
195
416
  fill(Zaniah::Bounds.new(x, y + @terminal_line_height / 2, rect.width, 1), foreground) if cell.attributes[:strikethrough]
196
417
  end
197
418
  end
419
+ if prefix == :terminal && terminal.respond_to?(:commands) && !grid.alternate?
420
+ terminal.commands.each do |command|
421
+ source_row = command.prompt_row - map[:base]
422
+ display_row = terminal_display_for_source(map, source_row)
423
+ next unless display_row
424
+ row = display_row - @terminal_first_display
425
+ next unless row.between?(0, rows - 1)
426
+ y = @terminal_bounds.y + row * @terminal_line_height
427
+ fill(Zaniah::Bounds.new(@terminal_bounds.x, y, @terminal_bounds.width, 1), :border)
428
+ foldable = terminal_fold_interval(command, map)
429
+ marker = foldable ? (terminal_folds(terminal)[command.id] ? "▸ " : "▾ ") : ""
430
+ label = marker + (command.exit_status.zero? ? "✓" : "exit #{command.exit_status}")
431
+ width = label.length * 7 + 8
432
+ text(label, @terminal_bounds.right - width + 4, y, color: command.exit_status.zero? ? :muted : :error, size: 10)
433
+ command_regions << [Zaniah::Bounds.new(@terminal_bounds.right - width, y, width, @terminal_line_height), command, terminal] if foldable
434
+ end
435
+ end
198
436
  blinking = @workspace.settings["terminal"]["blinking"]
199
437
  if grid.cursor_visible && @terminal_scroll.zero? &&
200
438
  (blinking == "off" || @cursor_visible || !@workspace.terminal_composition&.text.to_s.empty?)
201
- x = @terminal_bounds.x + grid.cursor_x * @terminal_cell_width
202
- y = @terminal_bounds.y + grid.cursor_y * @terminal_line_height
203
- cursor = case @workspace.settings["terminal"]["cursor_shape"]
204
- when "bar" then Zaniah::Bounds.new(x, y, 2, @terminal_line_height)
205
- when "underline" then Zaniah::Bounds.new(x, y + @terminal_line_height - 2, @terminal_cell_width, 2)
206
- else Zaniah::Bounds.new(x, y, @terminal_cell_width, @terminal_line_height)
207
- end
208
- fill(cursor, "#e9a66166")
209
- composition = @workspace.terminal_composition
210
- if composition && !composition.text.empty?
211
- layout = text(composition.text, x, y, color: :accent, size: @terminal_font_size)
212
- fill(Zaniah::Bounds.new(x, y + @terminal_line_height - 2, layout&.width || 20, 1), :accent)
213
- @cx.window.ime_state = Zaniah::Bounds.new(x, y, 2, @terminal_line_height)
439
+ cursor_source = grid.scrollback.length + grid.cursor_y
440
+ cursor_display = terminal_display_for_source(map, cursor_source)
441
+ cursor_row = cursor_display && cursor_display - @terminal_first_display
442
+ if cursor_row&.between?(0, rows - 1)
443
+ x = @terminal_bounds.x + grid.cursor_x * @terminal_cell_width
444
+ y = @terminal_bounds.y + cursor_row * @terminal_line_height
445
+ cursor = case @workspace.settings["terminal"]["cursor_shape"]
446
+ when "bar" then Zaniah::Bounds.new(x, y, 2, @terminal_line_height)
447
+ when "underline" then Zaniah::Bounds.new(x, y + @terminal_line_height - 2, @terminal_cell_width, 2)
448
+ else Zaniah::Bounds.new(x, y, @terminal_cell_width, @terminal_line_height)
449
+ end
450
+ fill(cursor, "#e9a66166")
451
+ composition = @workspace.terminal_composition
452
+ if composition && !composition.text.empty?
453
+ layout = text(composition.text, x, y, color: :accent, size: @terminal_font_size)
454
+ fill(Zaniah::Bounds.new(x, y + @terminal_line_height - 2, layout&.width || 20, 1), :accent)
455
+ @cx.window.ime_state = Zaniah::Bounds.new(x, y, 2, @terminal_line_height)
456
+ end
214
457
  end
215
458
  end
216
459
  end
217
460
  if terminal.respond_to?(:status) && terminal.status
218
461
  code = terminal.status.exited? ? terminal.status.exitstatus : "signal #{terminal.status.termsig}"
219
- text("Process exited with #{code} — press Enter to restart", @terminal_bounds.x + 4,
462
+ hint = prefix == :terminal ? "Process exited with #{code} — press Enter to restart" : "Task exited with #{code}"
463
+ text(hint, @terminal_bounds.x + 4,
220
464
  [@terminal_bounds.bottom - @terminal_line_height, @terminal_bounds.y].max, color: :muted, size: 11)
221
465
  end
222
- region(@terminal_bounds, role: :terminal, label: "Terminal", action: [:terminal])
466
+ if prefix == :terminal && terminal.equal?(@workspace.terminal)
467
+ fill(Zaniah::Bounds.new(bounds.x, bounds.y, bounds.width, 2), :accent)
468
+ end
469
+ action = prefix == :terminal ? [prefix, terminal] : [prefix]
470
+ region(@terminal_bounds, role: :terminal, label: prefix == :terminal ? "Terminal" : "Task output", action: action)
471
+ command_regions.each do |bounds, command, command_terminal|
472
+ region(bounds, role: :button, label: "Toggle command output", action: [:terminal_command, command, command_terminal])
473
+ end
223
474
  end
224
475
  end
225
476
  end