pylonite 1.0.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/bin/pylonite +5 -0
- data/lib/pylonite/cli.rb +325 -0
- data/lib/pylonite/database.rb +346 -0
- data/lib/pylonite/help.rb +133 -0
- data/lib/pylonite/tui.rb +638 -0
- data/lib/pylonite/version.rb +3 -0
- data/lib/pylonite.rb +8 -0
- data/pylonite.gemspec +20 -0
- metadata +63 -0
data/lib/pylonite/tui.rb
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
|
|
3
|
+
module Pylonite
|
|
4
|
+
module TUI
|
|
5
|
+
BOARD_COLORS = {
|
|
6
|
+
"backlog" => "\e[37m",
|
|
7
|
+
"todo" => "\e[33m",
|
|
8
|
+
"in_progress" => "\e[36m",
|
|
9
|
+
"done" => "\e[32m",
|
|
10
|
+
"archived" => "\e[90m"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
BOLD = "\e[1m"
|
|
14
|
+
RESET = "\e[0m"
|
|
15
|
+
REVERSE = "\e[7m"
|
|
16
|
+
DIM = "\e[2m"
|
|
17
|
+
|
|
18
|
+
def self.run(project_path = Dir.pwd)
|
|
19
|
+
db = Database.new(project_path)
|
|
20
|
+
state = {
|
|
21
|
+
db: db,
|
|
22
|
+
view: :board,
|
|
23
|
+
col_index: 0,
|
|
24
|
+
row_index: 0,
|
|
25
|
+
show_archived: false,
|
|
26
|
+
detail_task_id: nil,
|
|
27
|
+
detail_scroll: 0,
|
|
28
|
+
show_help: false,
|
|
29
|
+
moving: false,
|
|
30
|
+
move_task_id: nil,
|
|
31
|
+
log_scroll: 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setup_terminal
|
|
35
|
+
trap("WINCH") { render(state) }
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
render(state)
|
|
39
|
+
loop do
|
|
40
|
+
break unless handle_input(state)
|
|
41
|
+
render(state)
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
restore_terminal
|
|
45
|
+
db.close
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.setup_terminal
|
|
50
|
+
print "\e[?25l" # hide cursor
|
|
51
|
+
print "\e[?1049h" # alternate screen buffer
|
|
52
|
+
$stdout.flush
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.restore_terminal
|
|
56
|
+
print "\e[?1049l" # restore screen buffer
|
|
57
|
+
print "\e[?25h" # show cursor
|
|
58
|
+
$stdout.flush
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.handle_input(state)
|
|
62
|
+
char = read_key
|
|
63
|
+
return true unless char
|
|
64
|
+
|
|
65
|
+
if state[:show_help]
|
|
66
|
+
state[:show_help] = false
|
|
67
|
+
return true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if state[:moving]
|
|
71
|
+
return handle_move_input(state, char)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
case state[:view]
|
|
75
|
+
when :board
|
|
76
|
+
handle_board_input(state, char)
|
|
77
|
+
when :detail
|
|
78
|
+
handle_detail_input(state, char)
|
|
79
|
+
when :log
|
|
80
|
+
handle_log_input(state, char)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.read_key
|
|
85
|
+
$stdin.raw do |io|
|
|
86
|
+
c = io.getc
|
|
87
|
+
return nil unless c
|
|
88
|
+
|
|
89
|
+
if c == "\e"
|
|
90
|
+
return "\e" unless IO.select([io], nil, nil, 0.05)
|
|
91
|
+
seq = io.getc
|
|
92
|
+
if seq == "["
|
|
93
|
+
code = io.getc
|
|
94
|
+
case code
|
|
95
|
+
when "A" then return :up
|
|
96
|
+
when "B" then return :down
|
|
97
|
+
when "C" then return :right
|
|
98
|
+
when "D" then return :left
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
return "\e"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
c
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.handle_board_input(state, key)
|
|
109
|
+
boards = visible_boards(state)
|
|
110
|
+
tasks_by_board = load_board_tasks(state)
|
|
111
|
+
|
|
112
|
+
case key
|
|
113
|
+
when "q", "\e"
|
|
114
|
+
return false
|
|
115
|
+
when :up, "k"
|
|
116
|
+
state[:row_index] = [state[:row_index] - 1, 0].max
|
|
117
|
+
when :down, "j"
|
|
118
|
+
col_tasks = tasks_by_board[boards[state[:col_index]]] || []
|
|
119
|
+
max_row = [(col_tasks.length - 1), 0].max
|
|
120
|
+
state[:row_index] = [state[:row_index] + 1, max_row].min
|
|
121
|
+
when :left, "h"
|
|
122
|
+
state[:col_index] = [state[:col_index] - 1, 0].max
|
|
123
|
+
col_tasks = tasks_by_board[boards[state[:col_index]]] || []
|
|
124
|
+
max_row = [(col_tasks.length - 1), 0].max
|
|
125
|
+
state[:row_index] = [state[:row_index], max_row].min
|
|
126
|
+
when :right, "l"
|
|
127
|
+
state[:col_index] = [state[:col_index] + 1, boards.length - 1].min
|
|
128
|
+
col_tasks = tasks_by_board[boards[state[:col_index]]] || []
|
|
129
|
+
max_row = [(col_tasks.length - 1), 0].max
|
|
130
|
+
state[:row_index] = [state[:row_index], max_row].min
|
|
131
|
+
state[:row_index] = 0 if col_tasks.empty?
|
|
132
|
+
when "a"
|
|
133
|
+
state[:show_archived] = !state[:show_archived]
|
|
134
|
+
state[:col_index] = [state[:col_index], visible_boards(state).length - 1].min
|
|
135
|
+
when "\r"
|
|
136
|
+
col_tasks = tasks_by_board[boards[state[:col_index]]] || []
|
|
137
|
+
if col_tasks[state[:row_index]]
|
|
138
|
+
state[:detail_task_id] = col_tasks[state[:row_index]]["id"]
|
|
139
|
+
state[:detail_scroll] = 0
|
|
140
|
+
state[:view] = :detail
|
|
141
|
+
end
|
|
142
|
+
when "m"
|
|
143
|
+
col_tasks = tasks_by_board[boards[state[:col_index]]] || []
|
|
144
|
+
if col_tasks[state[:row_index]]
|
|
145
|
+
state[:moving] = true
|
|
146
|
+
state[:move_task_id] = col_tasks[state[:row_index]]["id"]
|
|
147
|
+
end
|
|
148
|
+
when "c"
|
|
149
|
+
col_tasks = tasks_by_board[boards[state[:col_index]]] || []
|
|
150
|
+
if col_tasks[state[:row_index]]
|
|
151
|
+
prompt_comment(state, col_tasks[state[:row_index]]["id"])
|
|
152
|
+
end
|
|
153
|
+
when "L"
|
|
154
|
+
state[:view] = :log
|
|
155
|
+
state[:log_scroll] = 0
|
|
156
|
+
when "?"
|
|
157
|
+
state[:show_help] = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.handle_detail_input(state, key)
|
|
164
|
+
case key
|
|
165
|
+
when "q"
|
|
166
|
+
return false
|
|
167
|
+
when "b", "\e"
|
|
168
|
+
state[:view] = :board
|
|
169
|
+
when :up, "k"
|
|
170
|
+
state[:detail_scroll] = [state[:detail_scroll] - 1, 0].max
|
|
171
|
+
when :down, "j"
|
|
172
|
+
state[:detail_scroll] += 1
|
|
173
|
+
when "m"
|
|
174
|
+
state[:moving] = true
|
|
175
|
+
state[:move_task_id] = state[:detail_task_id]
|
|
176
|
+
when "c"
|
|
177
|
+
prompt_comment(state, state[:detail_task_id])
|
|
178
|
+
when "?"
|
|
179
|
+
state[:show_help] = true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.handle_log_input(state, key)
|
|
186
|
+
case key
|
|
187
|
+
when "q"
|
|
188
|
+
return false
|
|
189
|
+
when "b", "\e"
|
|
190
|
+
state[:view] = :board
|
|
191
|
+
when :up, "k"
|
|
192
|
+
state[:log_scroll] = [state[:log_scroll] - 1, 0].max
|
|
193
|
+
when :down, "j"
|
|
194
|
+
state[:log_scroll] += 1
|
|
195
|
+
when "?"
|
|
196
|
+
state[:show_help] = true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.handle_move_input(state, key)
|
|
203
|
+
case key
|
|
204
|
+
when "1" then do_move(state, "backlog")
|
|
205
|
+
when "2" then do_move(state, "todo")
|
|
206
|
+
when "3" then do_move(state, "in_progress")
|
|
207
|
+
when "4" then do_move(state, "done")
|
|
208
|
+
when "5" then do_move(state, "archived")
|
|
209
|
+
else
|
|
210
|
+
state[:moving] = false
|
|
211
|
+
state[:move_task_id] = nil
|
|
212
|
+
end
|
|
213
|
+
true
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def self.do_move(state, board)
|
|
217
|
+
state[:db].move_task(state[:move_task_id], board)
|
|
218
|
+
state[:moving] = false
|
|
219
|
+
state[:move_task_id] = nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.prompt_comment(state, task_id)
|
|
223
|
+
_, rows = terminal_size
|
|
224
|
+
# Show prompt on the last row
|
|
225
|
+
print "\e[#{rows};1H\e[2K" # move to last row, clear it
|
|
226
|
+
print "#{BOLD}Comment:#{RESET} "
|
|
227
|
+
# Restore normal terminal mode for text input
|
|
228
|
+
print "\e[?25h" # show cursor
|
|
229
|
+
$stdout.flush
|
|
230
|
+
|
|
231
|
+
text = nil
|
|
232
|
+
if $stdin.respond_to?(:cooked)
|
|
233
|
+
$stdin.cooked { text = $stdin.gets }
|
|
234
|
+
else
|
|
235
|
+
text = $stdin.gets
|
|
236
|
+
end
|
|
237
|
+
print "\e[?25l" # hide cursor again
|
|
238
|
+
|
|
239
|
+
if text && !(text = text.strip).empty?
|
|
240
|
+
state[:db].add_comment(task_id, text)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def self.visible_boards(state)
|
|
245
|
+
if state[:show_archived]
|
|
246
|
+
Database::BOARDS.dup
|
|
247
|
+
else
|
|
248
|
+
Database::BOARDS.reject { |b| b == "archived" }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def self.load_board_tasks(state)
|
|
253
|
+
tasks = state[:db].list_tasks(include_archived: state[:show_archived])
|
|
254
|
+
tasks.group_by { |t| t["board"] }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def self.render(state)
|
|
258
|
+
cols, rows = terminal_size
|
|
259
|
+
buf = +""
|
|
260
|
+
buf << "\e[2J\e[H" # clear screen, cursor home
|
|
261
|
+
|
|
262
|
+
case state[:view]
|
|
263
|
+
when :board
|
|
264
|
+
buf << render_board(state, cols, rows)
|
|
265
|
+
when :detail
|
|
266
|
+
buf << render_detail(state, cols, rows)
|
|
267
|
+
when :log
|
|
268
|
+
buf << render_log(state, cols, rows)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
if state[:show_help]
|
|
272
|
+
buf << render_help_overlay(cols, rows)
|
|
273
|
+
elsif state[:moving]
|
|
274
|
+
buf << render_move_overlay(state, cols, rows)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
print buf
|
|
278
|
+
$stdout.flush
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.terminal_size
|
|
282
|
+
IO.console.winsize.reverse
|
|
283
|
+
rescue
|
|
284
|
+
[80, 24]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def self.render_board(state, cols, rows)
|
|
288
|
+
boards = visible_boards(state)
|
|
289
|
+
tasks_by_board = load_board_tasks(state)
|
|
290
|
+
buf = +""
|
|
291
|
+
|
|
292
|
+
# Title bar
|
|
293
|
+
title = " PYLONITE - Task Board "
|
|
294
|
+
pad = [(cols - title.length) / 2, 0].max
|
|
295
|
+
buf << "#{REVERSE}#{BOLD}#{' ' * pad}#{title}#{' ' * (cols - pad - title.length)}#{RESET}\n"
|
|
296
|
+
|
|
297
|
+
# Column layout
|
|
298
|
+
num_cols = boards.length
|
|
299
|
+
col_width = cols / num_cols
|
|
300
|
+
available_rows = rows - 4 # title + header + status bar + bottom padding
|
|
301
|
+
|
|
302
|
+
# Column headers
|
|
303
|
+
boards.each_with_index do |board, i|
|
|
304
|
+
color = BOARD_COLORS[board] || RESET
|
|
305
|
+
label = board_label(board)
|
|
306
|
+
task_count = (tasks_by_board[board] || []).length
|
|
307
|
+
header = " #{label} (#{task_count})"
|
|
308
|
+
header = truncate(header, col_width - 1)
|
|
309
|
+
if i == state[:col_index]
|
|
310
|
+
buf << "#{REVERSE}#{color}#{BOLD}#{header.ljust(col_width)}#{RESET}"
|
|
311
|
+
else
|
|
312
|
+
buf << "#{color}#{BOLD}#{header.ljust(col_width)}#{RESET}"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
buf << "\n"
|
|
316
|
+
|
|
317
|
+
# Separator
|
|
318
|
+
boards.each do |_board|
|
|
319
|
+
buf << "#{'─' * col_width}"
|
|
320
|
+
end
|
|
321
|
+
buf << "\n"
|
|
322
|
+
|
|
323
|
+
# Task rows
|
|
324
|
+
(0...available_rows).each do |row_i|
|
|
325
|
+
boards.each_with_index do |board, col_i|
|
|
326
|
+
color = BOARD_COLORS[board] || RESET
|
|
327
|
+
col_tasks = tasks_by_board[board] || []
|
|
328
|
+
task = col_tasks[row_i]
|
|
329
|
+
|
|
330
|
+
if task
|
|
331
|
+
id_str = "##{task['id']}"
|
|
332
|
+
max_title_len = col_width - id_str.length - 3
|
|
333
|
+
title = truncate(task["title"], [max_title_len, 4].max)
|
|
334
|
+
cell = " #{id_str} #{title}"
|
|
335
|
+
cell = truncate(cell, col_width - 1)
|
|
336
|
+
|
|
337
|
+
if col_i == state[:col_index] && row_i == state[:row_index]
|
|
338
|
+
buf << "#{REVERSE}#{color}#{cell.ljust(col_width)}#{RESET}"
|
|
339
|
+
else
|
|
340
|
+
buf << "#{color}#{cell.ljust(col_width)}#{RESET}"
|
|
341
|
+
end
|
|
342
|
+
else
|
|
343
|
+
buf << "#{' ' * col_width}"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
buf << "\n"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Status bar
|
|
350
|
+
bar_text = " q:quit hjkl:navigate enter:detail m:move c:comment L:log a:archived ?:help"
|
|
351
|
+
archived_status = state[:show_archived] ? " [archived:on]" : ""
|
|
352
|
+
bar_text += archived_status
|
|
353
|
+
buf << "\e[#{rows};1H" # move to last row
|
|
354
|
+
buf << "#{REVERSE}#{DIM}#{bar_text.ljust(cols)}#{RESET}"
|
|
355
|
+
|
|
356
|
+
buf
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def self.render_detail(state, cols, rows)
|
|
360
|
+
task = state[:db].get_task(state[:detail_task_id])
|
|
361
|
+
return render_board(state, cols, rows) unless task
|
|
362
|
+
|
|
363
|
+
lines = build_detail_lines(task, cols)
|
|
364
|
+
buf = +""
|
|
365
|
+
|
|
366
|
+
# Title bar
|
|
367
|
+
title = " Task ##{task['id']} - Detail View "
|
|
368
|
+
pad = [(cols - title.length) / 2, 0].max
|
|
369
|
+
buf << "#{REVERSE}#{BOLD}#{' ' * pad}#{title}#{' ' * (cols - pad - title.length)}#{RESET}\n"
|
|
370
|
+
|
|
371
|
+
available = rows - 3 # title bar + status bar + padding
|
|
372
|
+
scroll = [state[:detail_scroll], [lines.length - available, 0].max].min
|
|
373
|
+
state[:detail_scroll] = scroll
|
|
374
|
+
|
|
375
|
+
visible = lines[scroll, available] || []
|
|
376
|
+
visible.each { |line| buf << "#{line}\n" }
|
|
377
|
+
|
|
378
|
+
# Fill remaining lines
|
|
379
|
+
remaining = available - visible.length
|
|
380
|
+
remaining.times { buf << "\n" }
|
|
381
|
+
|
|
382
|
+
# Status bar
|
|
383
|
+
bar_text = " b:back q:quit j/k:scroll m:move c:comment ?:help"
|
|
384
|
+
buf << "\e[#{rows};1H"
|
|
385
|
+
buf << "#{REVERSE}#{DIM}#{bar_text.ljust(cols)}#{RESET}"
|
|
386
|
+
|
|
387
|
+
buf
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def self.render_log(state, cols, rows)
|
|
391
|
+
entries = state[:db].activity_log
|
|
392
|
+
buf = +""
|
|
393
|
+
|
|
394
|
+
# Title bar
|
|
395
|
+
title = " PYLONITE - Activity Log "
|
|
396
|
+
pad = [(cols - title.length) / 2, 0].max
|
|
397
|
+
buf << "#{REVERSE}#{BOLD}#{' ' * pad}#{title}#{' ' * (cols - pad - title.length)}#{RESET}\n"
|
|
398
|
+
|
|
399
|
+
lines = build_log_lines(entries, cols)
|
|
400
|
+
available = rows - 3
|
|
401
|
+
|
|
402
|
+
scroll = [state[:log_scroll], [lines.length - available, 0].max].min
|
|
403
|
+
state[:log_scroll] = scroll
|
|
404
|
+
|
|
405
|
+
visible = lines[scroll, available] || []
|
|
406
|
+
visible.each { |line| buf << "#{line}\n" }
|
|
407
|
+
|
|
408
|
+
remaining = available - visible.length
|
|
409
|
+
remaining.times { buf << "\n" }
|
|
410
|
+
|
|
411
|
+
bar_text = " b:back q:quit j/k:scroll ?:help"
|
|
412
|
+
buf << "\e[#{rows};1H"
|
|
413
|
+
buf << "#{REVERSE}#{DIM}#{bar_text.ljust(cols)}#{RESET}"
|
|
414
|
+
|
|
415
|
+
buf
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def self.build_log_lines(entries, cols)
|
|
419
|
+
return ["", " #{DIM}No activity yet.#{RESET}"] if entries.empty?
|
|
420
|
+
|
|
421
|
+
lines = []
|
|
422
|
+
entries.each do |e|
|
|
423
|
+
lines << " #{DIM}#{e["created_at"]}#{RESET} #{e["actor"]} #{format_log_action(e["action"])} #{BOLD}##{e["task_id"]}#{RESET} #{truncate(e["title"], cols - 40)}"
|
|
424
|
+
lines << " #{DIM}#{e["detail"]}#{RESET}"
|
|
425
|
+
lines << ""
|
|
426
|
+
end
|
|
427
|
+
lines
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def self.format_log_action(action)
|
|
431
|
+
case action
|
|
432
|
+
when "created" then "created"
|
|
433
|
+
when "moved" then "moved"
|
|
434
|
+
when "assigned" then "assigned"
|
|
435
|
+
when "commented" then "commented on"
|
|
436
|
+
when "updated" then "updated"
|
|
437
|
+
when "blocker_added" then "added blocker to"
|
|
438
|
+
when "blocker_removed" then "removed blocker from"
|
|
439
|
+
when "subtask_added" then "added subtask to"
|
|
440
|
+
when "subtask_created" then "created subtask"
|
|
441
|
+
else action.tr("_", " ")
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def self.render_overlay(lines, cols, rows)
|
|
446
|
+
box_width = lines.map { |l| l.gsub(/\e\[[0-9;]*m/, "").length }.max + 4
|
|
447
|
+
box_width = [box_width, cols - 4].min
|
|
448
|
+
box_height = lines.length + 2
|
|
449
|
+
start_col = [(cols - box_width) / 2, 1].max
|
|
450
|
+
start_row = [(rows - box_height) / 2, 1].max
|
|
451
|
+
|
|
452
|
+
buf = +""
|
|
453
|
+
buf << "\e[#{start_row};#{start_col}H"
|
|
454
|
+
buf << "#{REVERSE}#{' ' * box_width}#{RESET}"
|
|
455
|
+
lines.each_with_index do |line, i|
|
|
456
|
+
buf << "\e[#{start_row + 1 + i};#{start_col}H"
|
|
457
|
+
stripped = line.gsub(/\e\[[0-9;]*m/, "")
|
|
458
|
+
padding = box_width - stripped.length - 4
|
|
459
|
+
buf << "#{REVERSE} #{RESET} #{line}#{' ' * [padding, 0].max} #{REVERSE} #{RESET}"
|
|
460
|
+
end
|
|
461
|
+
buf << "\e[#{start_row + box_height - 1};#{start_col}H"
|
|
462
|
+
buf << "#{REVERSE}#{' ' * box_width}#{RESET}"
|
|
463
|
+
buf
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def self.render_help_overlay(cols, rows)
|
|
467
|
+
lines = [
|
|
468
|
+
"#{BOLD}Keyboard Shortcuts#{RESET}",
|
|
469
|
+
"",
|
|
470
|
+
"#{BOLD}Board View#{RESET}",
|
|
471
|
+
" h/l, left/right Switch columns",
|
|
472
|
+
" j/k, up/down Move between tasks",
|
|
473
|
+
" Enter View task detail",
|
|
474
|
+
" m Move selected task to another board",
|
|
475
|
+
" c Add comment to selected task",
|
|
476
|
+
" L Activity log",
|
|
477
|
+
" a Toggle archived column",
|
|
478
|
+
" q, Esc Quit",
|
|
479
|
+
"",
|
|
480
|
+
"#{BOLD}Detail View#{RESET}",
|
|
481
|
+
" j/k, up/down Scroll",
|
|
482
|
+
" m Move task to another board",
|
|
483
|
+
" c Add comment",
|
|
484
|
+
" b, Esc Back to board view",
|
|
485
|
+
" q Quit",
|
|
486
|
+
"",
|
|
487
|
+
"#{BOLD}Log View#{RESET}",
|
|
488
|
+
" j/k, up/down Scroll",
|
|
489
|
+
" b, Esc Back to board view",
|
|
490
|
+
" q Quit",
|
|
491
|
+
"",
|
|
492
|
+
"#{BOLD}Move Overlay#{RESET}",
|
|
493
|
+
" 1 Backlog",
|
|
494
|
+
" 2 Todo",
|
|
495
|
+
" 3 In Progress",
|
|
496
|
+
" 4 Done",
|
|
497
|
+
" 5 Archived",
|
|
498
|
+
" Any other key Cancel",
|
|
499
|
+
"",
|
|
500
|
+
"#{DIM}Press any key to close#{RESET}"
|
|
501
|
+
]
|
|
502
|
+
render_overlay(lines, cols, rows)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def self.render_move_overlay(state, cols, rows)
|
|
506
|
+
task = state[:db].get_task(state[:move_task_id])
|
|
507
|
+
title = task ? truncate(task["title"], 30) : "?"
|
|
508
|
+
current = task ? task["board"] : "?"
|
|
509
|
+
lines = [
|
|
510
|
+
"#{BOLD}Move task ##{state[:move_task_id]}#{RESET} #{DIM}#{title}#{RESET}",
|
|
511
|
+
"#{DIM}Currently: #{board_label(current)}#{RESET}",
|
|
512
|
+
"",
|
|
513
|
+
" #{BOARD_COLORS["backlog"]}1#{RESET} Backlog",
|
|
514
|
+
" #{BOARD_COLORS["todo"]}2#{RESET} Todo",
|
|
515
|
+
" #{BOARD_COLORS["in_progress"]}3#{RESET} In Progress",
|
|
516
|
+
" #{BOARD_COLORS["done"]}4#{RESET} Done",
|
|
517
|
+
" #{BOARD_COLORS["archived"]}5#{RESET} Archived",
|
|
518
|
+
"",
|
|
519
|
+
"#{DIM}Press 1-5 to move, any other key to cancel#{RESET}"
|
|
520
|
+
]
|
|
521
|
+
render_overlay(lines, cols, rows)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def self.build_detail_lines(task, cols)
|
|
525
|
+
lines = []
|
|
526
|
+
color = BOARD_COLORS[task["board"]] || RESET
|
|
527
|
+
|
|
528
|
+
lines << ""
|
|
529
|
+
lines << " #{BOLD}#{task['title']}#{RESET}"
|
|
530
|
+
lines << ""
|
|
531
|
+
lines << " #{DIM}Board:#{RESET} #{color}#{board_label(task['board'])}#{RESET}"
|
|
532
|
+
lines << " #{DIM}Author:#{RESET} #{task['author']}"
|
|
533
|
+
lines << " #{DIM}Assignee:#{RESET} #{task['assignee'] || '(none)'}"
|
|
534
|
+
lines << " #{DIM}Created:#{RESET} #{task['created_at']}"
|
|
535
|
+
lines << " #{DIM}Updated:#{RESET} #{task['updated_at']}"
|
|
536
|
+
|
|
537
|
+
if task["description"] && !task["description"].empty?
|
|
538
|
+
lines << ""
|
|
539
|
+
lines << " #{BOLD}Description#{RESET}"
|
|
540
|
+
wrap_text(task["description"], cols - 4).each { |l| lines << " #{l}" }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
parent = task["parent"]
|
|
544
|
+
if parent
|
|
545
|
+
lines << ""
|
|
546
|
+
lines << " #{BOLD}Parent#{RESET}"
|
|
547
|
+
lines << " ##{parent['id']} #{parent['title']}"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
subtasks = task["subtasks"] || []
|
|
551
|
+
unless subtasks.empty?
|
|
552
|
+
lines << ""
|
|
553
|
+
lines << " #{BOLD}Subtasks#{RESET}"
|
|
554
|
+
subtasks.each do |st|
|
|
555
|
+
st_color = BOARD_COLORS[st["board"]] || RESET
|
|
556
|
+
lines << " ##{st['id']} #{st_color}[#{board_label(st['board'])}]#{RESET} #{st['title']}"
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
blockers = task["blockers"] || []
|
|
561
|
+
unless blockers.empty?
|
|
562
|
+
lines << ""
|
|
563
|
+
lines << " #{BOLD}Blocked by#{RESET}"
|
|
564
|
+
blockers.each do |b|
|
|
565
|
+
b_color = BOARD_COLORS[b["board"]] || RESET
|
|
566
|
+
lines << " ##{b['id']} #{b_color}[#{board_label(b['board'])}]#{RESET} #{b['title']}"
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
blocked_by_this = task["blocked_by_this"] || []
|
|
571
|
+
unless blocked_by_this.empty?
|
|
572
|
+
lines << ""
|
|
573
|
+
lines << " #{BOLD}Blocking#{RESET}"
|
|
574
|
+
blocked_by_this.each do |b|
|
|
575
|
+
b_color = BOARD_COLORS[b["board"]] || RESET
|
|
576
|
+
lines << " ##{b['id']} #{b_color}[#{board_label(b['board'])}]#{RESET} #{b['title']}"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
comments = task["comments"] || []
|
|
581
|
+
unless comments.empty?
|
|
582
|
+
lines << ""
|
|
583
|
+
lines << " #{BOLD}Comments#{RESET}"
|
|
584
|
+
comments.each do |c|
|
|
585
|
+
lines << ""
|
|
586
|
+
lines << " #{DIM}#{c['author']} at #{c['created_at']}#{RESET}"
|
|
587
|
+
wrap_text(c["text"], cols - 6).each { |l| lines << " #{l}" }
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
history = task["history"] || []
|
|
592
|
+
unless history.empty?
|
|
593
|
+
lines << ""
|
|
594
|
+
lines << " #{BOLD}History#{RESET}"
|
|
595
|
+
history.each do |h|
|
|
596
|
+
lines << " #{DIM}#{h['created_at']}#{RESET} #{h['actor']} - #{h['detail']}"
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
lines
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def self.board_label(board)
|
|
604
|
+
board.gsub("_", " ").split.map(&:capitalize).join(" ")
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def self.truncate(str, max)
|
|
608
|
+
return str if str.length <= max
|
|
609
|
+
return str[0, max] if max <= 3
|
|
610
|
+
str[0, max - 3] + "..."
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def self.wrap_text(text, width)
|
|
614
|
+
return [""] if text.nil? || text.empty?
|
|
615
|
+
text.split("\n").flat_map do |paragraph|
|
|
616
|
+
if paragraph.empty?
|
|
617
|
+
[""]
|
|
618
|
+
else
|
|
619
|
+
words = paragraph.split
|
|
620
|
+
lines = []
|
|
621
|
+
current = +""
|
|
622
|
+
words.each do |word|
|
|
623
|
+
if current.empty?
|
|
624
|
+
current = word
|
|
625
|
+
elsif current.length + 1 + word.length <= width
|
|
626
|
+
current << " " << word
|
|
627
|
+
else
|
|
628
|
+
lines << current
|
|
629
|
+
current = +word
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
lines << current unless current.empty?
|
|
633
|
+
lines.empty? ? [""] : lines
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
end
|
data/lib/pylonite.rb
ADDED
data/pylonite.gemspec
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require_relative "lib/pylonite/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "pylonite"
|
|
5
|
+
spec.version = Pylonite::VERSION
|
|
6
|
+
spec.authors = ["Chris Maximin"]
|
|
7
|
+
spec.summary = "SQLite-backed kanban board for agents and humans"
|
|
8
|
+
spec.description = "A simple, local kanban board backed by SQLite. Designed for AI agents and humans to manage tasks, track progress, and collaborate via CLI and TUI."
|
|
9
|
+
spec.homepage = "https://github.com/chrismaximin/pylonite"
|
|
10
|
+
spec.license = "MIT"
|
|
11
|
+
spec.required_ruby_version = ">= 3.1"
|
|
12
|
+
|
|
13
|
+
spec.files = Dir["lib/**/*.rb", "bin/*", "*.gemspec"]
|
|
14
|
+
spec.bindir = "bin"
|
|
15
|
+
spec.executables = ["pylonite"]
|
|
16
|
+
|
|
17
|
+
spec.add_dependency "sqlite3", "~> 2.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
end
|