architext 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/bin/architext +6 -0
- data/bin/setup +27 -0
- data/lib/architext/bundle.rb +20 -0
- data/lib/architext/cli.rb +444 -0
- data/lib/architext/clipboard.rb +58 -0
- data/lib/architext/obsidian.rb +84 -0
- data/lib/architext/picker.rb +39 -0
- data/lib/architext/search_results.rb +67 -0
- data/lib/architext/selection_parser.rb +32 -0
- data/lib/architext/settings.rb +40 -0
- data/lib/architext/terminal.rb +130 -0
- data/lib/architext/tui.rb +512 -0
- data/lib/architext/version.rb +5 -0
- data/lib/architext.rb +13 -0
- metadata +133 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
require_relative 'terminal'
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
|
|
8
|
+
module Architext
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
10
|
+
class TUI
|
|
11
|
+
HELP = 'Up/k Down/j move space select a all / filter n new search v vault enter confirm q back'
|
|
12
|
+
KEY_BINDINGS = {
|
|
13
|
+
' ' => :space,
|
|
14
|
+
'k' => :up,
|
|
15
|
+
'j' => :down,
|
|
16
|
+
'a' => :all,
|
|
17
|
+
'/' => :filter,
|
|
18
|
+
'n' => :new_query,
|
|
19
|
+
'v' => :new_vault,
|
|
20
|
+
'q' => :quit
|
|
21
|
+
}.freeze
|
|
22
|
+
LOGO = [
|
|
23
|
+
' ___ __ _ __ __ ',
|
|
24
|
+
' / | __________/ /_ (_) /____ _ __/ /_',
|
|
25
|
+
' / /| | / ___/ ___/ __ \\/ / __/ _ \\| |/_/ __/',
|
|
26
|
+
' / ___ |/ / / /__/ / / / / /_/ __/> </ /_ ',
|
|
27
|
+
'/_/ |_/_/ \\___/_/ /_/_/\\__/\\___/_/|_|\\__/ '
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
Selection = Data.define(:paths, :new_query, :new_vault, :reprompt_query)
|
|
31
|
+
QueryPrompt = Data.define(:query, :open_vault_config, :quit)
|
|
32
|
+
VaultConfigAction = Data.define(:session_vault, :set_default_vault, :clear_default, :back)
|
|
33
|
+
|
|
34
|
+
def initialize(stdin:, stdout:, stderr:, app_name:)
|
|
35
|
+
@stdin = stdin
|
|
36
|
+
@stdout = stdout
|
|
37
|
+
@stderr = stderr
|
|
38
|
+
@app_name = app_name
|
|
39
|
+
@color = Terminal.enabled?(@stdout)
|
|
40
|
+
@intro_rendered = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prompt_query(default:, context:)
|
|
44
|
+
draw_intro
|
|
45
|
+
draw_startup_vault_status(
|
|
46
|
+
context[:vault],
|
|
47
|
+
context[:vault_source],
|
|
48
|
+
context[:default_vault],
|
|
49
|
+
context[:default_vault_path],
|
|
50
|
+
context[:connection_report]
|
|
51
|
+
)
|
|
52
|
+
@stdout.print render("[bold][cyan]Search query[/] [dim](default: #{default})[/] [dim]| type 'v' for vault config, 'q' to quit:[/] ")
|
|
53
|
+
input = @stdin.gets&.strip
|
|
54
|
+
return QueryPrompt.new(query: nil, open_vault_config: false, quit: true) if input.nil?
|
|
55
|
+
|
|
56
|
+
normalized = input.strip
|
|
57
|
+
return QueryPrompt.new(query: nil, open_vault_config: true, quit: false) if %w[v /v vault /vault].include?(normalized.downcase)
|
|
58
|
+
return QueryPrompt.new(query: nil, open_vault_config: false, quit: true) if %w[q /q quit /quit].include?(normalized.downcase)
|
|
59
|
+
|
|
60
|
+
QueryPrompt.new(query: normalized.empty? ? default : normalized, open_vault_config: false, quit: false)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# rubocop:disable Metrics/AbcSize
|
|
64
|
+
def prompt_vault_config(active_vault:, active_vault_source:, default_vault:, default_vault_path:)
|
|
65
|
+
draw_intro
|
|
66
|
+
@stdout.puts render('[bold][cyan]Vault Configuration[/]')
|
|
67
|
+
@stdout.puts render("[dim]active:[/] #{format_vault_label(active_vault, active_vault_source)}")
|
|
68
|
+
@stdout.puts render("[dim]saved default:[/] #{format_saved_default(default_vault)}")
|
|
69
|
+
@stdout.puts render("[dim]default config path:[/] #{default_vault_path}")
|
|
70
|
+
@stdout.puts render('[dim]vault path resolution is handled by Obsidian CLI via vault=<name_or_id>.[/]')
|
|
71
|
+
@stdout.puts
|
|
72
|
+
@stdout.puts render('[dim]Commands:[/]')
|
|
73
|
+
@stdout.puts render(' [cyan]use <vault>[/] [dim]set active vault for this run[/]')
|
|
74
|
+
@stdout.puts render(' [cyan]save <vault>[/] [dim]save persistent default vault[/]')
|
|
75
|
+
@stdout.puts render(' [cyan]clear[/] [dim]clear persistent default vault[/]')
|
|
76
|
+
@stdout.puts render(' [cyan]none[/] [dim]clear active vault (use Obsidian CLI default)[/]')
|
|
77
|
+
@stdout.puts render(' [cyan]back[/] [dim]return to search prompt[/]')
|
|
78
|
+
@stdout.puts
|
|
79
|
+
@stdout.print render('[bold][cyan]vault-config[/]> ')
|
|
80
|
+
input = @stdin.gets&.strip
|
|
81
|
+
return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: false, back: true) if input.nil?
|
|
82
|
+
|
|
83
|
+
command = input.strip
|
|
84
|
+
return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: false, back: true) if command.empty?
|
|
85
|
+
return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: false, back: true) if command.casecmp('back').zero?
|
|
86
|
+
return VaultConfigAction.new(session_vault: '', set_default_vault: nil, clear_default: false, back: false) if command.casecmp('none').zero?
|
|
87
|
+
return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: true, back: false) if command.casecmp('clear').zero?
|
|
88
|
+
|
|
89
|
+
if (match = command.match(/\Asave\s+(.+)\z/i))
|
|
90
|
+
return VaultConfigAction.new(session_vault: nil, set_default_vault: match[1].strip, clear_default: false, back: false)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if (match = command.match(/\Ause\s+(.+)\z/i))
|
|
94
|
+
return VaultConfigAction.new(session_vault: match[1].strip, set_default_vault: nil, clear_default: false, back: false)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
VaultConfigAction.new(session_vault: command, set_default_vault: nil, clear_default: false, back: false)
|
|
98
|
+
end
|
|
99
|
+
# rubocop:enable Metrics/AbcSize
|
|
100
|
+
|
|
101
|
+
# rubocop:disable Metrics/BlockLength, Metrics/MethodLength
|
|
102
|
+
def select(paths, query:, vault:, vault_source:)
|
|
103
|
+
state = {
|
|
104
|
+
query: query,
|
|
105
|
+
vault: vault,
|
|
106
|
+
vault_source: vault_source,
|
|
107
|
+
filter: '',
|
|
108
|
+
cursor: 0,
|
|
109
|
+
offset: 0,
|
|
110
|
+
selected: {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
with_screen do
|
|
114
|
+
loop do
|
|
115
|
+
visible = filtered_paths(paths, state[:filter])
|
|
116
|
+
clamp_cursor!(state, visible.length)
|
|
117
|
+
draw_selector(paths:, visible:, state:)
|
|
118
|
+
|
|
119
|
+
case read_key
|
|
120
|
+
when :up
|
|
121
|
+
state[:cursor] -= 1
|
|
122
|
+
when :down
|
|
123
|
+
state[:cursor] += 1
|
|
124
|
+
when :space
|
|
125
|
+
toggle_current(visible, state)
|
|
126
|
+
when :all
|
|
127
|
+
toggle_all(visible, state)
|
|
128
|
+
when :filter
|
|
129
|
+
state[:filter] = prompt_inline('Filter visible results', state[:filter])
|
|
130
|
+
when :new_query
|
|
131
|
+
return Selection.new(
|
|
132
|
+
paths: [],
|
|
133
|
+
new_query: prompt_inline('New Obsidian search', state[:query]),
|
|
134
|
+
new_vault: nil,
|
|
135
|
+
reprompt_query: false
|
|
136
|
+
)
|
|
137
|
+
when :new_vault
|
|
138
|
+
return Selection.new(
|
|
139
|
+
paths: [],
|
|
140
|
+
new_query: nil,
|
|
141
|
+
new_vault: prompt_inline('Vault name or id (blank clears)', state[:vault].to_s),
|
|
142
|
+
reprompt_query: false
|
|
143
|
+
)
|
|
144
|
+
when :enter
|
|
145
|
+
selected = selected_paths(paths, state)
|
|
146
|
+
return Selection.new(paths: selected, new_query: nil, new_vault: nil, reprompt_query: false)
|
|
147
|
+
when :quit
|
|
148
|
+
return Selection.new(paths: [], new_query: nil, new_vault: nil, reprompt_query: true)
|
|
149
|
+
when :ctrl_c
|
|
150
|
+
return Selection.new(paths: [], new_query: nil, new_vault: nil, reprompt_query: false)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
clamp_cursor!(state, visible.length)
|
|
154
|
+
keep_cursor_visible!(state, visible.length)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
# rubocop:enable Metrics/BlockLength, Metrics/MethodLength
|
|
159
|
+
|
|
160
|
+
def show_no_results(query, vault:, vault_source:, default_vault_path:, obsidian_executable:)
|
|
161
|
+
vault_label = format_vault_label(vault, vault_source)
|
|
162
|
+
@stderr.puts render("[red]No Obsidian notes matched[/] [amber]#{query.inspect}[/] #{vault_label}")
|
|
163
|
+
@stderr.puts render("[dim]default vault config:[/] #{default_vault_path}")
|
|
164
|
+
@stderr.puts render("[dim]obsidian cli:[/] #{obsidian_executable}")
|
|
165
|
+
@stderr.puts render('[amber]Tip:[/] at search prompt type [bold]v[/] for vault config, or pass [bold]--vault[/].')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def show_no_selection
|
|
169
|
+
@stderr.puts render('[amber]No files selected.[/]')
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def show_copied(bytes)
|
|
173
|
+
@stdout.puts render("[green]Copied[/] [bold]#{bytes} bytes[/] [dim]to clipboard.[/]")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def show_error(message)
|
|
177
|
+
@stderr.puts render("[red]#{message}[/]")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def show_info(message)
|
|
181
|
+
@stdout.puts render("[green]#{message}[/]")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def show_dry_run(selected_paths, bytes)
|
|
185
|
+
draw_intro
|
|
186
|
+
@stdout.puts render("[bold][green]Dry run[/] [dim]#{selected_paths.length} selected file(s)[/]")
|
|
187
|
+
@stdout.puts
|
|
188
|
+
selected_paths.each { |path| @stdout.puts render(" [cyan]•[/] #{path}") }
|
|
189
|
+
@stdout.puts
|
|
190
|
+
@stdout.puts render("[dim]Estimated context size:[/] [bold]#{bytes} bytes[/]")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def draw_intro
|
|
196
|
+
return unless @stdout.tty?
|
|
197
|
+
return if @intro_rendered
|
|
198
|
+
|
|
199
|
+
width = terminal_size.last
|
|
200
|
+
play_intro_animation(width)
|
|
201
|
+
@intro_rendered = true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def play_intro_animation(width)
|
|
205
|
+
frames = @color ? [%i[faint dim], %i[blue dim], %i[cyan white]] : [[nil, nil]]
|
|
206
|
+
|
|
207
|
+
frames.each do |logo_style, version_style|
|
|
208
|
+
@stdout.write Terminal::HOME
|
|
209
|
+
@stdout.write Terminal::CLEAR
|
|
210
|
+
@stdout.puts
|
|
211
|
+
LOGO.each do |line|
|
|
212
|
+
styled = logo_style ? Terminal.paint(line, logo_style, enabled: @color) : line
|
|
213
|
+
@stdout.puts center(styled, width)
|
|
214
|
+
end
|
|
215
|
+
@stdout.puts center(render('[dim]Architect Obsidian context and stitch for agent work[/]'), width)
|
|
216
|
+
version = "v#{Architext::VERSION}"
|
|
217
|
+
styled_version = version_style ? Terminal.paint(version, version_style, enabled: @color) : version
|
|
218
|
+
@stdout.puts center(styled_version, width)
|
|
219
|
+
@stdout.puts
|
|
220
|
+
@stdout.flush
|
|
221
|
+
sleep(0.08) if frames.length > 1
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def with_screen
|
|
226
|
+
use_alt_screen = Terminal.alt_screen_supported?(@stdout)
|
|
227
|
+
@selector_frame_started = false
|
|
228
|
+
@stdout.write Terminal::ALT_SCREEN if use_alt_screen
|
|
229
|
+
@stdout.write Terminal::HIDE_CURSOR
|
|
230
|
+
@stdout.write Terminal::HOME
|
|
231
|
+
@stdout.write Terminal::CLEAR
|
|
232
|
+
yield
|
|
233
|
+
ensure
|
|
234
|
+
@stdout.write Terminal::SHOW_CURSOR
|
|
235
|
+
@stdout.write Terminal::MAIN_SCREEN if use_alt_screen
|
|
236
|
+
@selector_frame_started = false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def draw_selector(paths:, visible:, state:)
|
|
240
|
+
height, terminal_width = terminal_size
|
|
241
|
+
width = usable_width(terminal_width)
|
|
242
|
+
list_height = [height - 8, 5].max
|
|
243
|
+
keep_cursor_visible!(state, visible.length, list_height:)
|
|
244
|
+
rows = visible.drop(state[:offset]).first(list_height)
|
|
245
|
+
lines = []
|
|
246
|
+
|
|
247
|
+
lines.concat(header_lines(width, state, paths.length, visible.length))
|
|
248
|
+
lines << section_title(width, 'Context Candidates')
|
|
249
|
+
|
|
250
|
+
rows.each_with_index do |path, index|
|
|
251
|
+
absolute_index = state[:offset] + index
|
|
252
|
+
active = absolute_index == state[:cursor]
|
|
253
|
+
checked = state[:selected][path]
|
|
254
|
+
marker = checked ? '[x]' : '[ ]'
|
|
255
|
+
pointer = active ? '>' : ' '
|
|
256
|
+
style = if active
|
|
257
|
+
:inverse
|
|
258
|
+
else
|
|
259
|
+
checked ? :green : :ink
|
|
260
|
+
end
|
|
261
|
+
line = "#{pointer} #{marker} #{path}"
|
|
262
|
+
truncated = Terminal.truncate(line, width)
|
|
263
|
+
lines << Terminal.paint(truncated, style, enabled: @color)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
empty_rows = list_height - rows.length
|
|
267
|
+
empty_rows.times { lines << '' }
|
|
268
|
+
|
|
269
|
+
lines.concat(status_lines(width, state, visible.length))
|
|
270
|
+
frame = lines.map { |line| clear_line(line) }.join("\n")
|
|
271
|
+
|
|
272
|
+
@stdout.write Terminal::HOME
|
|
273
|
+
@stdout.write(@selector_frame_started ? Terminal::CLEAR_TO_END : Terminal::CLEAR)
|
|
274
|
+
@stdout.write frame
|
|
275
|
+
@stdout.write Terminal::CLEAR_TO_END
|
|
276
|
+
@stdout.flush
|
|
277
|
+
@selector_frame_started = true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def header_lines(width, state, total, visible_count)
|
|
281
|
+
filter_label = state[:filter].empty? ? 'none' : state[:filter]
|
|
282
|
+
[
|
|
283
|
+
Terminal.truncate(render("[bold][cyan]ARCHiTEXT[/] [dim]#{state[:vault] || 'obsidian default'}[/]"), width),
|
|
284
|
+
Terminal.truncate(render("[dim]query:[/] [amber]#{state[:query]}[/] [dim]filter:[/] [cyan]#{filter_label}[/]"), width),
|
|
285
|
+
Terminal.truncate(render("[dim]results:[/] #{visible_count}/#{total} [dim]selected:[/] #{state[:selected].length}"), width),
|
|
286
|
+
Terminal.paint('-' * width, :faint, enabled: @color)
|
|
287
|
+
]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def section_title(width, title)
|
|
291
|
+
Terminal.paint(Terminal.truncate("-- #{title} ", width), :blue, enabled: @color)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def status_lines(width, state, visible_count)
|
|
295
|
+
detail = if visible_count.zero?
|
|
296
|
+
'[amber]No visible results. Press / to change filter or n for a new search.[/]'
|
|
297
|
+
elsif state[:selected].empty?
|
|
298
|
+
'[dim]Select one or more notes, then press enter.[/]'
|
|
299
|
+
else
|
|
300
|
+
"[green]Ready:[/] #{state[:selected].length} note(s) selected."
|
|
301
|
+
end
|
|
302
|
+
[
|
|
303
|
+
Terminal.paint('-' * width, :faint, enabled: @color),
|
|
304
|
+
Terminal.truncate(render("[dim]#{HELP}[/]"), width),
|
|
305
|
+
Terminal.truncate(render(detail), width)
|
|
306
|
+
]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def clear_line(text)
|
|
310
|
+
"#{Terminal::CLEAR_LINE}\r#{text}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def usable_width(width)
|
|
314
|
+
(width.to_i - 2).clamp(40, 160)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def prompt_inline(label, current)
|
|
318
|
+
@stdout.write Terminal::SHOW_CURSOR
|
|
319
|
+
@stdout.print render("\n[bold][cyan]#{label}[/] [dim](blank keeps current)[/]: ")
|
|
320
|
+
input = @stdin.gets&.strip
|
|
321
|
+
@stdout.write Terminal::HIDE_CURSOR
|
|
322
|
+
input.nil? || input.empty? ? current : input
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def filtered_paths(paths, filter)
|
|
326
|
+
needle = filter.to_s.downcase
|
|
327
|
+
return paths if needle.empty?
|
|
328
|
+
|
|
329
|
+
paths.select { |path| path.downcase.include?(needle) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def selected_paths(paths, state)
|
|
333
|
+
paths.select { |path| state[:selected][path] }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def toggle_current(visible, state)
|
|
337
|
+
path = visible[state[:cursor]]
|
|
338
|
+
return unless path
|
|
339
|
+
|
|
340
|
+
state[:selected][path] ? state[:selected].delete(path) : state[:selected][path] = true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def toggle_all(visible, state)
|
|
344
|
+
return if visible.empty?
|
|
345
|
+
|
|
346
|
+
all_selected = visible.all? { |path| state[:selected][path] }
|
|
347
|
+
visible.each do |path|
|
|
348
|
+
all_selected ? state[:selected].delete(path) : state[:selected][path] = true
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def clamp_cursor!(state, count)
|
|
353
|
+
max = [count - 1, 0].max
|
|
354
|
+
state[:cursor] = state[:cursor].clamp(0, max)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def keep_cursor_visible!(state, count, list_height: nil)
|
|
358
|
+
list_height ||= [terminal_size.first - 8, 5].max
|
|
359
|
+
state[:offset] = state[:offset].clamp(0, [count - list_height, 0].max)
|
|
360
|
+
state[:offset] = state[:cursor] if state[:cursor] < state[:offset]
|
|
361
|
+
return unless state[:cursor] >= state[:offset] + list_height
|
|
362
|
+
|
|
363
|
+
state[:offset] = state[:cursor] - list_height + 1
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def read_key
|
|
367
|
+
key = @stdin.getch
|
|
368
|
+
return :ctrl_c if key == "\u0003"
|
|
369
|
+
|
|
370
|
+
return :enter if ["\r", "\n"].include?(key)
|
|
371
|
+
|
|
372
|
+
mapped = KEY_BINDINGS[key]
|
|
373
|
+
return mapped if mapped
|
|
374
|
+
|
|
375
|
+
bytes = key.to_s.bytes
|
|
376
|
+
if bytes.first == 27 && bytes.length > 1
|
|
377
|
+
parse_escape_sequence(bytes[1..])
|
|
378
|
+
elsif windows_extended_combo?(key)
|
|
379
|
+
parse_windows_extended_code(bytes[1])
|
|
380
|
+
elsif windows_extended_key_prefix?(key)
|
|
381
|
+
parse_windows_extended_key
|
|
382
|
+
elsif key == "\e"
|
|
383
|
+
parse_escape_key
|
|
384
|
+
else
|
|
385
|
+
:unknown
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def windows_extended_key_prefix?(key)
|
|
390
|
+
[0, 224].include?(key.to_s.bytes.first)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def windows_extended_combo?(key)
|
|
394
|
+
key.to_s.bytes.length > 1 && windows_extended_key_prefix?(key)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def parse_windows_extended_key
|
|
398
|
+
parse_windows_extended_code(@stdin.getch)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def parse_windows_extended_code(code)
|
|
402
|
+
case code_byte(code)
|
|
403
|
+
when 72
|
|
404
|
+
:up
|
|
405
|
+
when 80
|
|
406
|
+
:down
|
|
407
|
+
else
|
|
408
|
+
:unknown
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def parse_escape_key
|
|
413
|
+
parse_escape_sequence(read_escape_sequence)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def parse_escape_sequence(sequence)
|
|
417
|
+
text = sequence.is_a?(Array) ? sequence.pack('C*') : sequence.to_s
|
|
418
|
+
|
|
419
|
+
case text
|
|
420
|
+
when /\A(?:\[A|OA)/
|
|
421
|
+
:up
|
|
422
|
+
when /\A(?:\[B|OB)/
|
|
423
|
+
:down
|
|
424
|
+
else
|
|
425
|
+
:unknown
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def read_escape_sequence
|
|
430
|
+
sequence = []
|
|
431
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.08
|
|
432
|
+
|
|
433
|
+
loop do
|
|
434
|
+
sequence.concat(@stdin.read_nonblock(8).bytes)
|
|
435
|
+
break if escape_sequence_complete?(sequence)
|
|
436
|
+
rescue IO::WaitReadable, EOFError
|
|
437
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
438
|
+
|
|
439
|
+
sleep 0.005
|
|
440
|
+
rescue NotImplementedError
|
|
441
|
+
sequence.concat(read_escape_sequence_with_getch)
|
|
442
|
+
break
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
sequence
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def escape_sequence_complete?(sequence)
|
|
449
|
+
sequence.pack('C*').match?(/\A(?:O[A-D]|\[[0-9;?]*[A-Za-z~])\z/)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def read_escape_sequence_with_getch
|
|
453
|
+
bytes = []
|
|
454
|
+
2.times do
|
|
455
|
+
key = @stdin.getch
|
|
456
|
+
bytes.concat(key.to_s.bytes)
|
|
457
|
+
break if escape_sequence_complete?(bytes)
|
|
458
|
+
rescue EOFError
|
|
459
|
+
break
|
|
460
|
+
end
|
|
461
|
+
bytes
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def code_byte(value)
|
|
465
|
+
return value if value.is_a?(Integer)
|
|
466
|
+
|
|
467
|
+
value.to_s.bytes.first
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def terminal_size
|
|
471
|
+
@stdout.winsize
|
|
472
|
+
rescue StandardError
|
|
473
|
+
[28, 100]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def center(text, width)
|
|
477
|
+
visible = Terminal.visible_length(text)
|
|
478
|
+
"#{' ' * [(width - visible) / 2, 0].max}#{text}"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def render(markup)
|
|
482
|
+
Terminal.render(markup, enabled: @color)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def draw_startup_vault_status(vault, vault_source, default_vault, default_vault_path, connection_report)
|
|
486
|
+
@stdout.puts render("[dim]active vault:[/] #{format_vault_label(vault, vault_source)}")
|
|
487
|
+
@stdout.puts render("[dim]saved default:[/] #{format_saved_default(default_vault)}")
|
|
488
|
+
@stdout.puts render("[dim]default config path:[/] #{default_vault_path}")
|
|
489
|
+
@stdout.puts render("[dim]obsidian cli:[/] #{connection_report[:executable]}")
|
|
490
|
+
@stdout.puts render("[dim]obsidian version:[/] #{connection_report[:version] || 'unknown'}")
|
|
491
|
+
status_style = connection_report[:status] == 'ok' ? '[green]ok[/]' : '[red]error[/]'
|
|
492
|
+
@stdout.puts render("[dim]connection check:[/] #{status_style}")
|
|
493
|
+
@stdout.puts render("[dim]resolved vault:[/] #{connection_report[:resolved_vault_summary]}") if connection_report[:resolved_vault_summary]
|
|
494
|
+
@stdout.puts render("[amber]diagnostic:[/] #{connection_report[:warning]}") if connection_report[:warning]
|
|
495
|
+
@stdout.puts render('[dim]vault target semantics: CWD vault if inside one, otherwise active Obsidian vault unless overridden.[/]')
|
|
496
|
+
@stdout.puts
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def format_vault_label(vault, source)
|
|
500
|
+
return "[amber]none selected[/] [dim](#{source})[/]" if vault.to_s.strip.empty?
|
|
501
|
+
|
|
502
|
+
"[cyan]#{vault}[/] [dim](#{source})[/]"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def format_saved_default(default_vault)
|
|
506
|
+
return '[dim]none[/]' if default_vault.to_s.strip.empty?
|
|
507
|
+
|
|
508
|
+
"[cyan]#{default_vault}[/]"
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
# rubocop:enable Metrics/ClassLength
|
|
512
|
+
end
|
data/lib/architext.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'architext/bundle'
|
|
4
|
+
require_relative 'architext/clipboard'
|
|
5
|
+
require_relative 'architext/cli'
|
|
6
|
+
require_relative 'architext/obsidian'
|
|
7
|
+
require_relative 'architext/picker'
|
|
8
|
+
require_relative 'architext/terminal'
|
|
9
|
+
require_relative 'architext/search_results'
|
|
10
|
+
require_relative 'architext/selection_parser'
|
|
11
|
+
require_relative 'architext/settings'
|
|
12
|
+
require_relative 'architext/tui'
|
|
13
|
+
require_relative 'architext/version'
|
metadata
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: architext
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Can
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: tty-prompt
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.23'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.23'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rubocop
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.60'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.60'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rubocop-minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.34'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.34'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop-rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.6'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.6'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: minitest
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '5.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '5.0'
|
|
82
|
+
description: A terminal interface to interactively select and bundle Obsidian notes
|
|
83
|
+
for AI agent context.
|
|
84
|
+
executables:
|
|
85
|
+
- architext
|
|
86
|
+
extensions: []
|
|
87
|
+
extra_rdoc_files: []
|
|
88
|
+
files:
|
|
89
|
+
- CHANGELOG.md
|
|
90
|
+
- LICENSE
|
|
91
|
+
- README.md
|
|
92
|
+
- bin/architext
|
|
93
|
+
- bin/setup
|
|
94
|
+
- lib/architext.rb
|
|
95
|
+
- lib/architext/bundle.rb
|
|
96
|
+
- lib/architext/cli.rb
|
|
97
|
+
- lib/architext/clipboard.rb
|
|
98
|
+
- lib/architext/obsidian.rb
|
|
99
|
+
- lib/architext/picker.rb
|
|
100
|
+
- lib/architext/search_results.rb
|
|
101
|
+
- lib/architext/selection_parser.rb
|
|
102
|
+
- lib/architext/settings.rb
|
|
103
|
+
- lib/architext/terminal.rb
|
|
104
|
+
- lib/architext/tui.rb
|
|
105
|
+
- lib/architext/version.rb
|
|
106
|
+
homepage: https://github.com/CanPixel/ARCHiTEXT#readme
|
|
107
|
+
licenses:
|
|
108
|
+
- MIT
|
|
109
|
+
metadata:
|
|
110
|
+
homepage_uri: https://github.com/CanPixel/ARCHiTEXT#readme
|
|
111
|
+
source_code_uri: https://github.com/CanPixel/ARCHiTEXT
|
|
112
|
+
bug_tracker_uri: https://github.com/CanPixel/ARCHiTEXT/issues
|
|
113
|
+
changelog_uri: https://github.com/CanPixel/ARCHiTEXT/blob/master/CHANGELOG.md
|
|
114
|
+
allowed_push_host: https://rubygems.org
|
|
115
|
+
rubygems_mfa_required: 'true'
|
|
116
|
+
rdoc_options: []
|
|
117
|
+
require_paths:
|
|
118
|
+
- lib
|
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '3.2'
|
|
124
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '0'
|
|
129
|
+
requirements: []
|
|
130
|
+
rubygems_version: 3.6.9
|
|
131
|
+
specification_version: 4
|
|
132
|
+
summary: A visual Obsidian context stitching TUI for agent workflows.
|
|
133
|
+
test_files: []
|