tasku 0.3.9 → 0.4.1

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.
data/lib/tasku/config.rb CHANGED
@@ -27,14 +27,104 @@ module Tasku
27
27
  values: %w[on off],
28
28
  default: "on",
29
29
  description: "Show status colour segment in row bar"
30
+ },
31
+ "col_project" => {
32
+ values: %w[on off],
33
+ default: "off",
34
+ description: "Show project column in task list"
35
+ },
36
+ "col_priority" => {
37
+ values: %w[on off],
38
+ default: "on",
39
+ description: "Show priority column in task list"
40
+ },
41
+ "col_status" => {
42
+ values: %w[on off],
43
+ default: "on",
44
+ description: "Show status column in task list"
45
+ },
46
+ "col_due" => {
47
+ values: %w[on off],
48
+ default: "on",
49
+ description: "Show due date column in task list"
50
+ },
51
+ "col_name_min" => {
52
+ range: 10..200,
53
+ default: "20",
54
+ description: "Minimum width (chars) of the name column"
55
+ },
56
+ "col_priority_min" => {
57
+ range: 4..40,
58
+ default: "10",
59
+ description: "Minimum width (chars) of the priority column"
60
+ },
61
+ "col_status_min" => {
62
+ range: 4..40,
63
+ default: "11",
64
+ description: "Minimum width (chars) of the status column"
65
+ },
66
+ "col_due_min" => {
67
+ range: 4..40,
68
+ default: "19",
69
+ description: "Minimum width (chars) of the due date column"
30
70
  }
31
71
  }.freeze
32
72
 
73
+ # ── Cloud sync settings ────────────────────────────────────────────────
74
+ CLOUD_URL_DEFAULT = "https://tasku.cloud".freeze
75
+
76
+ def self.cloud_token
77
+ cloud["token"]
78
+ end
79
+
80
+ def self.cloud_token=(value)
81
+ save_cloud("token", value)
82
+ end
83
+
84
+ def self.cloud_url
85
+ cloud["url"] || CLOUD_URL_DEFAULT
86
+ end
87
+
88
+ def self.cloud_url=(value)
89
+ save_cloud("url", value)
90
+ end
91
+
92
+ def self.last_synced_at
93
+ raw = cloud["last_synced_at"]
94
+ raw ? Time.parse(raw).utc : nil
95
+ rescue ArgumentError, TypeError
96
+ nil
97
+ end
98
+
99
+ def self.last_synced_at=(time)
100
+ save_cloud("last_synced_at", time&.utc&.iso8601)
101
+ end
102
+
103
+ def self.cloud_configured?
104
+ !cloud_token.nil?
105
+ end
106
+
33
107
  def self.get(key)
34
108
  all[key] || VALID_KEYS.dig(key, :default)
35
109
  end
36
110
 
111
+ def self.get_int(key)
112
+ get(key).to_i
113
+ end
114
+
37
115
  def self.set(key, value)
116
+ pastel = Pastel.new rescue nil
117
+ meta = VALID_KEYS[key]
118
+ raise ArgumentError, "Unknown key '#{key}'" unless meta
119
+
120
+ if meta[:range]
121
+ int = Integer(value) rescue nil
122
+ raise ArgumentError, "Value must be an integer between #{meta[:range].min} and #{meta[:range].max}" unless int && meta[:range].include?(int)
123
+ value = int.to_s
124
+ elsif meta[:values]
125
+ raise ArgumentError, "Invalid value '#{value}'. Valid: #{meta[:values].join(', ')}" unless meta[:values].include?(value)
126
+ end
127
+
38
128
  current = all
39
129
  current[key] = value
40
130
  FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
@@ -48,5 +138,18 @@ module Tasku
48
138
  rescue JSON::ParserError
49
139
  {}
50
140
  end
141
+
142
+ def self.cloud
143
+ all["cloud"] || {}
144
+ end
145
+
146
+ def self.save_cloud(key, value)
147
+ current = all
148
+ current["cloud"] ||= {}
149
+ current["cloud"][key] = value
150
+ FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
151
+ File.write(CONFIG_PATH, JSON.pretty_generate(current))
152
+ end
153
+ private_class_method :cloud, :save_cloud
51
154
  end
52
155
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "sequel"
4
4
  require "fileutils"
5
+ require "securerandom"
5
6
 
6
7
  module Tasku
7
8
  module Database
@@ -10,6 +11,9 @@ module Tasku
10
11
 
11
12
  def self.connect
12
13
  FileUtils.mkdir_p(DB_DIR)
14
+ # Treat all stored datetime strings as UTC so comparisons with
15
+ # cloud timestamps (which are always UTC) stay consistent.
16
+ Sequel.database_timezone = :utc
13
17
  @db = Sequel.sqlite(DB_PATH)
14
18
  Sequel::Model.db = @db
15
19
  migrate
@@ -43,6 +47,14 @@ module Tasku
43
47
  db.alter_table(:tasks) { add_column :code, String }
44
48
  end
45
49
 
50
+ if db.table_exists?(:tasks) && !db.schema(:tasks).map(&:first).include?(:uuid)
51
+ db.alter_table(:tasks) { add_column :uuid, String }
52
+ # Backfill existing tasks with UUIDs
53
+ db[:tasks].where(uuid: nil).each do |row|
54
+ db[:tasks].where(id: row[:id]).update(uuid: SecureRandom.uuid)
55
+ end
56
+ end
57
+
46
58
  db.create_table? :projects do
47
59
  String :name, primary_key: true
48
60
  String :colour
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "io/console"
4
5
 
5
6
  module Tasku
6
7
  module Output
@@ -39,27 +40,30 @@ module Tasku
39
40
  "archived" => { color: :dim, symbol: "⊘" }
40
41
  }.freeze
41
42
 
42
- COL_SEP = " "
43
+ COL_SEP = " │ "
44
+ # Maximum visible width of a due_cell value:
45
+ # "Mmm DD (NNNd ago)" = 17 chars.
46
+ DUE_COL_MIN_WIDTH = 17
43
47
 
44
48
  def initialize
45
49
  @pastel = Pastel.new
46
50
  @term_width = terminal_width
47
51
  end
48
52
 
49
- def render_list(tasks, colour_map: {}, spacing: "compact", bar: {})
53
+ def render_list(tasks, colour_map: {}, spacing: "compact", bar: {}, cols_cfg: {})
50
54
  if tasks.empty?
51
55
  puts @pastel.yellow(" No tasks found.")
52
56
  return
53
57
  end
54
58
 
55
- rows = tasks.map { |t| build_columns(t, colour_map, bar) }
56
- col_widths = compute_widths(rows)
59
+ rows = tasks.map { |t| build_columns(t, colour_map, bar, cols_cfg) }
60
+ col_widths = compute_widths(rows, cols_cfg)
57
61
  total = col_widths.sum + (COL_SEP.length * (col_widths.length - 1))
58
62
 
59
63
  puts ""
60
64
  puts @pastel.dim(" #{"─" * total}")
61
65
  rows.each do |cols|
62
- line = cols.each_with_index.map { |c, i| c.to_s.ljust(col_widths[i]) }.join(COL_SEP)
66
+ line = cols.each_with_index.map { |c, i| ansi_ljust(c.to_s, col_widths[i]) }.join(COL_SEP)
63
67
  puts " #{line}"
64
68
  puts "" if spacing == "spacious"
65
69
  end
@@ -154,26 +158,55 @@ module Tasku
154
158
  end
155
159
 
156
160
  # Returns an array of pre-formatted row strings for MadoList.
157
- # Accounts for the "[>] " prefix (4 extra chars) when computing widths.
158
- def build_mado_rows(tasks, colour_map = {}, bar = {})
159
- return [] if tasks.empty?
161
+ # Accounts for the " [>] " prefix (6 extra chars) when computing widths.
162
+ # Optional columns (project → priority → status, never due) are dropped
163
+ # until ≥15 chars are available for task names. Names are only truncated
164
+ # if they still overflow after all optional columns have been tried.
165
+ def build_mado_rows(tasks, colour_map = {}, bar = {}, cols_cfg = {})
166
+ return { rows: [], row_width: 0 } if tasks.empty?
160
167
 
161
168
  orig_width = @term_width
162
- @term_width = [@term_width - 4, 40].max
169
+ @term_width = [orig_width - 6, 40].max
163
170
 
164
- col_rows = tasks.map { |t| build_columns(t, colour_map, bar) }
165
- col_widths = compute_widths(col_rows)
166
- result = col_rows.map do |cols|
167
- cols.each_with_index.map { |c, i| ansi_ljust(c.to_s, col_widths[i]) }.join(COL_SEP)
171
+ cfg = cols_cfg.dup
172
+
173
+ # Drop optional columns (project → category → priority → status) until names have room.
174
+ %i[project category priority status].each do |drop_col|
175
+ probe = tasks.map { |t| build_columns(t, colour_map, bar, cfg) }
176
+ ws = mado_max_col_widths(probe, cfg)
177
+ break if mado_name_available(ws) >= 15
178
+ next if cfg["col_#{drop_col}"] == "off"
179
+ cfg["col_#{drop_col}"] = "off"
180
+ end
181
+
182
+ col_rows = tasks.map { |t| build_columns(t, colour_map, bar, cfg) }
183
+ col_widths = mado_max_col_widths(col_rows, cfg)
184
+ col_widths = mado_apply_truncation(col_rows, col_widths)
185
+
186
+ dim_sep = @pastel.dim(COL_SEP)
187
+ result = col_rows.map do |cols|
188
+ cols.each_with_index.map { |c, i| ansi_ljust(c.to_s, col_widths[i]) }.join(dim_sep)
189
+ end
190
+
191
+ # Build header row aligned to col_widths.
192
+ labels = ["ID", "Name"]
193
+ %i[project category priority status due].each do |col|
194
+ next if cfg["col_#{col}"] == "off"
195
+ labels << { project: "Project", category: "Category",
196
+ priority: "Priority", status: "Status", due: "Due" }[col]
168
197
  end
198
+ header = labels.each_with_index
199
+ .map { |lbl, i| ansi_ljust(@pastel.dim(lbl), col_widths[i]) }
200
+ .join(dim_sep)
169
201
 
202
+ row_width = col_widths.sum + COL_SEP.length * (col_widths.length - 1)
170
203
  @term_width = orig_width
171
- result
204
+ { rows: result, row_width: row_width, header: header }
172
205
  end
173
206
 
174
207
  private
175
208
 
176
- def build_columns(task, colour_map = {}, bar = {})
209
+ def build_columns(task, colour_map = {}, bar = {}, cols_cfg = {})
177
210
  id_val = task.code && !task.code.empty? ? "#{task.code}-#{task.id}" : task.id.to_s
178
211
 
179
212
  segments = []
@@ -193,36 +226,120 @@ module Tasku
193
226
  else
194
227
  @pastel.dim(id_val)
195
228
  end
196
- name_str = @pastel.bold(task.name)
197
- proj_str = project_str(task.project, colour_map)
198
- prio_str = priority_tag(task.priority)
199
- stat_str = status_tag(task.status)
200
- due_str = due_cell(task)
201
- [id_str, name_str, proj_str, prio_str, stat_str, due_str]
229
+
230
+ # ID (col 0) and Name (col 1) are always present.
231
+ result = [id_str, @pastel.bold(task.name)]
232
+ result << project_str(task.project, colour_map) unless cols_cfg["col_project"] == "off"
233
+ result << category_str(task.category) unless cols_cfg["col_category"] == "off"
234
+ result << priority_tag(task.priority) unless cols_cfg["col_priority"] == "off"
235
+ result << status_tag(task.status) unless cols_cfg["col_status"] == "off"
236
+ result << due_cell(task) unless cols_cfg["col_due"] == "off"
237
+ result
202
238
  end
203
239
 
204
- def compute_widths(rows)
240
+ def compute_widths(rows, cols_cfg = {})
205
241
  raw = rows.map { |cols| cols.map { |c| strip_ansi(c.to_s).length } }
206
242
  maxes = raw.transpose.map(&:max)
207
- sep_total = COL_SEP.length * (maxes.length - 1)
208
- fixed_cols = maxes[0] + maxes[2] + maxes[3] + maxes[4] + maxes[5]
209
- available_name = @term_width - fixed_cols - sep_total - 4
210
- min_name = 20
211
243
 
212
- if maxes[1] > available_name || maxes[1] < min_name
213
- name_width = [available_name, min_name].max
214
- rows.each_with_index do |cols, _ri|
244
+ # Track which array index each optional column landed at (after id=0, name=1).
245
+ col_idx = {}
246
+ i = 2
247
+ %i[project category priority status due].each do |col|
248
+ unless cols_cfg["col_#{col}"] == "off"
249
+ col_idx[col] = i
250
+ i += 1
251
+ end
252
+ end
253
+
254
+ # Apply user-configured minimum widths for optional fixed columns.
255
+ {
256
+ priority: [cols_cfg["col_priority_min"].to_i, 4].max,
257
+ status: [cols_cfg["col_status_min"].to_i, 4].max,
258
+ due: [[cols_cfg["col_due_min"].to_i, DUE_COL_MIN_WIDTH].max]
259
+ }.each do |col, mins|
260
+ if (idx = col_idx[col])
261
+ maxes[idx] = ([maxes[idx]] + Array(mins)).max
262
+ end
263
+ end
264
+
265
+ sep_total = COL_SEP.length * (maxes.length - 1)
266
+ # Fixed cols = everything except the name column (always at index 1).
267
+ fixed_cols = maxes.each_with_index.sum { |m, i| i == 1 ? 0 : m }
268
+ available_name = @term_width - fixed_cols - sep_total - 6
269
+
270
+ # Truncate names that would overflow; use count-based slice to avoid
271
+ # Ruby's negative-index wrapping when name_width is 0 or 1.
272
+ name_width = [available_name, 0].max
273
+ if maxes[1] > name_width
274
+ rows.each do |cols|
215
275
  raw_str = strip_ansi(cols[1].to_s)
216
276
  if raw_str.length > name_width
217
- cols[1] = "#{raw_str[0..name_width - 2]}#{@pastel.dim("…")}"
277
+ keep = [name_width - 1, 0].max
278
+ cols[1] = keep > 0 ? "#{raw_str[0, keep]}#{@pastel.dim("…")}" : ""
218
279
  end
219
280
  end
220
- maxes[1] = name_width
281
+ else
282
+ name_width = maxes[1]
283
+ end
284
+ maxes[1] = name_width
285
+
286
+ maxes
287
+ end
288
+
289
+ # Returns per-column max widths, applying configured minimums for
290
+ # priority (≥4), status (≥4), and due (≥DUE_COL_MIN_WIDTH).
291
+ # @term_width must already be reduced by 6 (the MadoList prefix).
292
+ def mado_max_col_widths(rows, cols_cfg = {})
293
+ raw = rows.map { |cols| cols.map { |c| strip_ansi(c.to_s).length } }
294
+ maxes = raw.transpose.map(&:max)
295
+
296
+ col_idx = {}
297
+ i = 2
298
+ %i[project category priority status due].each do |col|
299
+ next if cols_cfg["col_#{col}"] == "off"
300
+ col_idx[col] = i
301
+ i += 1
302
+ end
303
+
304
+ { priority: [cols_cfg["col_priority_min"].to_i, 4].max,
305
+ status: [cols_cfg["col_status_min"].to_i, 4].max,
306
+ due: [cols_cfg["col_due_min"].to_i, DUE_COL_MIN_WIDTH].max
307
+ }.each do |col, min|
308
+ maxes[col_idx[col]] = [maxes[col_idx[col]], min].max if col_idx[col]
221
309
  end
222
310
 
223
311
  maxes
224
312
  end
225
313
 
314
+ # How many chars are available for the name column given a widths array.
315
+ def mado_name_available(maxes)
316
+ sep_total = COL_SEP.length * (maxes.length - 1)
317
+ fixed_cols = maxes.each_with_index.sum { |m, i| i == 1 ? 0 : m }
318
+ @term_width - fixed_cols - sep_total
319
+ end
320
+
321
+ # Truncate name cells in-place if they exceed available space.
322
+ # Always expands the name column to fill available space so the table
323
+ # spans the full terminal width (short names are padded by ansi_ljust).
324
+ # Returns the (possibly updated) widths array.
325
+ def mado_apply_truncation(rows, maxes)
326
+ avail = mado_name_available(maxes)
327
+ name_w = [avail, 0].max
328
+ if maxes[1] > name_w
329
+ rows.each do |cols|
330
+ s = strip_ansi(cols[1].to_s)
331
+ if s.length > name_w
332
+ keep = [name_w - 1, 0].max
333
+ cols[1] = keep > 0 ? "#{s[0, keep]}#{@pastel.dim("…")}" : ""
334
+ end
335
+ end
336
+ end
337
+ # Always set name column to full available width so the table fills
338
+ # the terminal — short names will be space-padded by ansi_ljust.
339
+ maxes[1] = name_w
340
+ maxes
341
+ end
342
+
226
343
  def strip_ansi(str)
227
344
  str.gsub(/\e\[[0-9;]*m/, "")
228
345
  end
@@ -236,6 +353,13 @@ module Tasku
236
353
  end
237
354
 
238
355
  def terminal_width
356
+ # Mado writes the exact PTY column count to this file before running
357
+ # tasku list, bypassing unreliable ioctl/winsize methods inside the PTY.
358
+ cols_file = ENV["TASKU_COLS_FILE"] || "/tmp/tasku_mado_cols"
359
+ cols = File.read(cols_file).strip.to_i rescue 0
360
+ return cols if cols > 0
361
+
362
+ `stty size 2>/dev/null`.split.last.to_i.tap { |w| return w if w > 0 }
239
363
  IO.console&.winsize&.[](1) || 80
240
364
  rescue
241
365
  80
@@ -257,6 +381,10 @@ module Tasku
257
381
  "\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
258
382
  end
259
383
 
384
+ def category_str(category)
385
+ category && !category.empty? ? @pastel.cyan(category) : @pastel.dim("—")
386
+ end
387
+
260
388
  def project_str(project, colour_map)
261
389
  return @pastel.dim("—") unless project
262
390
 
@@ -274,6 +402,9 @@ module Tasku
274
402
  day = task.due_day.strftime("%b %d")
275
403
  if task.overdue?
276
404
  @pastel.red("#{day} (#{diff.abs}d ago)")
405
+ elsif diff < 0
406
+ # Past due but task is done/cancelled — show dimly, no alarm
407
+ @pastel.dim("#{day} (#{diff.abs}d ago)")
277
408
  elsif diff.zero?
278
409
  @pastel.yellow("#{day} (today)")
279
410
  elsif diff <= 7
data/lib/tasku/task.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "database"
4
+ require "securerandom"
4
5
 
5
6
  module Tasku
6
7
  class Task < Sequel::Model(:tasks)
@@ -9,6 +10,11 @@ module Tasku
9
10
  VALID_PRIORITIES = %w[none low medium high urgent].freeze
10
11
  VALID_STATUSES = %w[backlog todo in_progress done cancelled archived].freeze
11
12
 
13
+ def before_create
14
+ super
15
+ self.uuid ||= SecureRandom.uuid
16
+ end
17
+
12
18
  def before_validation
13
19
  super
14
20
  self.code = code.to_s.upcase[0, 3] if code
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require "shellwords"
4
5
  require "pastel"
6
+ require_relative "mado_log"
5
7
 
6
8
  module Tasku
7
9
  module TUI
@@ -10,37 +12,45 @@ module Tasku
10
12
  # selected task's ID is written to ENV["TASKU_SEL_FILE"] so that Mado
11
13
  # button handlers can inject it into commands like `tasku edit <id>`.
12
14
  class MadoList
13
- HINT = " ↑↓ navigate a add e/↵ edit d delete q quit"
15
+ HINT = " ↑↓ navigate a add e/↵ edit d delete s stats q quit"
14
16
 
15
- def initialize(tasks, colour_map: {}, bar: {})
16
- @tasks = tasks
17
- @cursor = 0
18
- @pastel = Pastel.new
19
- @sel_file = ENV["TASKU_SEL_FILE"]
20
- @running = true
17
+ def initialize(tasks, colour_map: {}, bar: {}, cols_cfg: {}, project: nil, list_cmd: nil)
18
+ @tasks = tasks
19
+ @cursor = 0
20
+ @pastel = Pastel.new
21
+ @sel_file = ENV["TASKU_SEL_FILE"]
22
+ @running = true
23
+ @inline_cmd = nil
24
+ @list_cmd = list_cmd || (project ? "tasku list --project #{Shellwords.shellescape(project)}" : "tasku list")
21
25
 
22
26
  # Pre-render row content once so key-press redraws are cheap.
23
- @rows = Output::Terminal.new.build_mado_rows(tasks, colour_map, bar)
27
+ table = Output::Terminal.new.build_mado_rows(tasks, colour_map, bar, cols_cfg)
28
+ @rows = table[:rows]
29
+ @row_width = table[:row_width]
30
+ @header = table[:header]
31
+
32
+ MadoLog.log("init tasks=#{tasks.length} cols_file=#{ENV["TASKU_COLS_FILE"]&.then { |f| File.read(f).strip rescue "?" }}")
24
33
  end
25
34
 
26
35
  def run
27
- return if @tasks.empty?
28
-
29
- write_selection
36
+ MadoLog.log("run tasks=#{@tasks.length}")
37
+ write_selection unless @tasks.empty?
30
38
 
31
39
  $stdout.print "\e[?25l" # hide cursor
32
40
  at_exit { restore_terminal }
33
- trap("INT") { @running = false }
41
+ trap("INT") { MadoLog.log("SIGINT received"); @running = false }
34
42
 
35
43
  $stdin.raw do |io|
36
44
  full_render
37
45
  while @running
38
46
  prev = @cursor
39
47
  key = read_key(io)
48
+ MadoLog.log("key=#{key.inspect} cursor=#{@cursor} tasks=#{@tasks.length}") unless key.nil?
40
49
  handle_key(key)
41
50
  full_render if @running && @cursor != prev
42
51
  end
43
52
  end
53
+ MadoLog.log("run exited normally")
44
54
  end
45
55
 
46
56
  private
@@ -54,18 +64,50 @@ module Tasku
54
64
 
55
65
  # Full clear + repaint (initial draw or after a command).
56
66
  def full_render
57
- $stdout.print "\e[H\e[J" # cursor home, clear to end of screen
67
+ $stdout.print "\e[3J\e[H\e[2J"
68
+ if @tasks.empty?
69
+ $stdout.print "\r\n #{@pastel.dim("No tasks found.")}\r\n"
70
+ $stdout.print "\r\n#{@pastel.dim(" Press any key to return to list…")}\r\n"
71
+ $stdout.flush
72
+ return
73
+ end
74
+ border = @pastel.dim(" " + "─" * (@row_width + 4))
75
+ $stdout.print "#{border}\r\n"
76
+ $stdout.print " #{@pastel.dim(" ")} #{@header}\r\n" if @header
77
+ $stdout.print "#{border}\r\n"
58
78
  @rows.each_with_index do |row, i|
59
- marker = i == @cursor ? @pastel.bright_blue("[>]") : @pastel.dim("[ ]")
60
- $stdout.print " #{marker} #{row}\r\n"
79
+ if i == @cursor
80
+ marker = @pastel.bright_blue("[>]")
81
+ # Re-apply the highlight background after every ANSI reset so it
82
+ # persists through pre-rendered colour codes embedded in the row.
83
+ content = " #{marker} #{row}".gsub("\e[0m", "\e[0m\e[48;5;236m")
84
+ $stdout.print "\e[48;5;236m#{content}\e[48;5;236m\e[K\e[0m\r\n"
85
+ else
86
+ marker = @pastel.dim("[ ]")
87
+ $stdout.print " #{marker} #{row}\r\n"
88
+ end
61
89
  end
90
+ $stdout.print "#{border}\r\n"
91
+ $stdout.print "#{@pastel.dim(" #{@tasks.length} task(s) found")}\r\n"
62
92
  $stdout.print "\r\n#{@pastel.dim(HINT)}\r\n"
63
93
  $stdout.flush
64
94
  end
65
95
 
66
- def read_key(io)
96
+ def read_key(io)
67
97
  byte = io.getbyte
68
98
  case byte
99
+ when 0x02 # STX — inline exec protocol from Mado buttons.
100
+ # Read the command string until newline; store it and
101
+ # return :inline_exec so handle_key can exec it directly
102
+ # without relying on the shell to pick up buffered input.
103
+ cmd = "".b
104
+ loop do
105
+ b = io.getbyte
106
+ break if b.nil? || b == 0x0A
107
+ cmd << b
108
+ end
109
+ @inline_cmd = cmd.force_encoding("UTF-8").scrub
110
+ :inline_exec
69
111
  when 0x1B
70
112
  second = io.getbyte
71
113
  if second == 0x5B
@@ -83,11 +125,21 @@ def read_key(io)
83
125
  when 0x64 then :delete # d
84
126
  when 0x65 then :edit # e
85
127
  when 0x71 then :q # q
128
+ when 0x73 then :stats # s
86
129
  else nil
87
130
  end
88
131
  end
89
132
 
90
133
  def handle_key(key)
134
+ if @tasks.empty?
135
+ case key
136
+ when :ctrl_c, :escape
137
+ @running = false
138
+ else
139
+ run_command("true") # any other key → exec tasku list
140
+ end
141
+ return
142
+ end
91
143
  case key
92
144
  when :up
93
145
  @cursor = [@cursor - 1, 0].max
@@ -105,16 +157,77 @@ def read_key(io)
105
157
  when :delete
106
158
  return if @tasks.empty?
107
159
  run_command("tasku delete #{@tasks[@cursor].id} -f")
160
+ when :stats
161
+ run_and_show("tasku stats")
162
+ when :inline_exec
163
+ cmd = @inline_cmd.to_s
164
+ MadoLog.log("inline_exec cmd=#{cmd.inspect}")
165
+ if cmd == "SQL"
166
+ run_sql
167
+ elsif cmd.start_with?("tasku list")
168
+ # exec directly so the new MadoList initialises with the correct
169
+ # @list_cmd (project filter, status filter, etc.). Using
170
+ # run_command would nest a subprocess and then re-exec @list_cmd
171
+ # (no filter) when the subprocess exits — losing the filter.
172
+ # Must restore terminal + cooked mode before exec so the new
173
+ # process doesn't inherit raw-mode terminal attributes.
174
+ @running = false
175
+ restore_terminal
176
+ $stdin.cooked!
177
+ MadoLog.log("inline_exec tasku list → exec #{cmd.inspect}")
178
+ exec(cmd)
179
+ else
180
+ run_command(cmd)
181
+ end
108
182
  end
109
183
  end
110
184
 
111
185
  # Restore the terminal, run a tasku command interactively, then re-exec
112
186
  # `tasku list` so the MadoList restarts fresh with updated data.
113
187
  def run_command(cmd)
188
+ MadoLog.log("run_command cmd=#{cmd.inspect}")
114
189
  @running = false
115
190
  restore_terminal
191
+ $stdin.cooked!
116
192
  system(cmd)
117
- exec("tasku list")
193
+ MadoLog.log("run_command finished cmd=#{cmd.inspect} → exec #{@list_cmd}")
194
+ exec(@list_cmd)
195
+ end
196
+
197
+ # Run a non-interactive display command, wait for any keypress via
198
+ # getch (works in cooked mode — no raw-mode setup needed), then
199
+ # re-exec `tasku list`.
200
+ def run_and_show(cmd)
201
+ MadoLog.log("run_and_show cmd=#{cmd.inspect}")
202
+ @running = false
203
+ restore_terminal
204
+ $stdin.cooked!
205
+ system(cmd)
206
+ $stdout.print "\r\n\e[2m Press any key to return to list…\e[0m\r\n"
207
+ $stdout.flush
208
+ $stdin.getch
209
+ MadoLog.log("run_and_show keypress received → exec #{@list_cmd}")
210
+ exec(@list_cmd)
211
+ end
212
+
213
+ # Show a SQL prompt, run the query, wait for keypress, then re-exec list.
214
+ def run_sql
215
+ MadoLog.log("run_sql")
216
+ @running = false
217
+ restore_terminal
218
+ $stdin.cooked!
219
+ $stdout.print "\r\n\e[36m SELECT * FROM tasks WHERE status = 'todo'\e[0m\r\n\r\n"
220
+ $stdout.print "\e[2m SQL> \e[0m"
221
+ $stdout.flush
222
+ q = $stdin.gets&.chomp
223
+ if q && !q.empty?
224
+ system("tasku", "sql", q)
225
+ $stdout.print "\r\n\e[2m Press any key to return to list…\e[0m\r\n"
226
+ $stdout.flush
227
+ $stdin.getch
228
+ end
229
+ MadoLog.log("run_sql done → exec #{@list_cmd}")
230
+ exec(@list_cmd)
118
231
  end
119
232
 
120
233
  def restore_terminal