openclacky 1.2.7 → 1.2.8

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.
@@ -15,6 +15,10 @@ module Clacky
15
15
  @registry[platform.to_sym]
16
16
  end
17
17
 
18
+ def self.unregister(platform)
19
+ @registry.delete(platform.to_sym)
20
+ end
21
+
18
22
  def self.all
19
23
  @registry.values
20
24
  end
@@ -511,7 +511,7 @@ module Clacky
511
511
  platform = event[:platform].to_s
512
512
  count = @mutex.synchronize { @session_counters[platform] += 1 }
513
513
  name = "#{platform}-#{count}"
514
- session_id = @session_builder.call(name: name, working_dir: Dir.home, source: :channel)
514
+ session_id = @session_builder.call(name: name, source: :channel)
515
515
  bind_key_to_session(key, session_id)
516
516
 
517
517
  # Create a long-lived ChannelUIController for this session and subscribe it
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ module Channel
7
+ module Adapters
8
+ # Loads user-defined channel adapters from ~/.clacky/channels/<name>/adapter.rb.
9
+ #
10
+ # Each adapter file is plain Ruby that defines a subclass of
11
+ # Clacky::Channel::Adapters::Base and self-registers via Adapters.register,
12
+ # exactly like the bundled adapters. This loader only discovers and requires
13
+ # those files after the built-in adapters are loaded — the existing
14
+ # self-registration mechanism then takes over with no further wiring.
15
+ #
16
+ # A broken adapter (syntax error, missing interface methods) is isolated:
17
+ # it is skipped with a logged warning and never aborts the load of others.
18
+ module UserAdapterLoader
19
+ DEFAULT_DIR = File.expand_path("~/.clacky/channels")
20
+
21
+ # Required class/instance methods a user adapter must implement to be usable.
22
+ REQUIRED_CLASS_METHODS = %i[platform_id platform_config].freeze
23
+ REQUIRED_INSTANCE_METHODS = %i[start stop send_text].freeze
24
+
25
+ Result = Struct.new(:loaded, :skipped, keyword_init: true)
26
+
27
+ # @param dir [String] directory to scan (override for tests)
28
+ # @return [Result] names loaded and skipped (with reasons)
29
+ def self.load_all(dir: DEFAULT_DIR)
30
+ result = Result.new(loaded: [], skipped: [])
31
+ if Dir.exist?(dir)
32
+ Dir.glob(File.join(dir, "*", "adapter.rb")).sort.each do |path|
33
+ name = File.basename(File.dirname(path))
34
+ load_one(path, name, result)
35
+ end
36
+ end
37
+ @last_result = result
38
+ result
39
+ end
40
+
41
+ # The result of the most recent load_all (set at startup). Lets `channel_verify`
42
+ # report status without re-requiring files (require is idempotent and would
43
+ # otherwise report already-loaded adapters as "did not register").
44
+ def self.last_result
45
+ @last_result || load_all
46
+ end
47
+
48
+ def self.load_one(path, name, result)
49
+ before = Adapters.all.dup
50
+
51
+ require path
52
+
53
+ newly = Adapters.all - before
54
+ klass = newly.last
55
+
56
+ unless klass
57
+ result.skipped << [name, "did not register an adapter (missing Adapters.register?)"]
58
+ log_skip(name, result.skipped.last[1])
59
+ return
60
+ end
61
+
62
+ if (missing = interface_gaps(klass)).any?
63
+ unregister(klass)
64
+ result.skipped << [name, "missing required methods: #{missing.join(", ")}"]
65
+ log_skip(name, result.skipped.last[1])
66
+ return
67
+ end
68
+
69
+ result.loaded << name
70
+ Clacky::Logger.info("[UserAdapterLoader] Loaded channel adapter '#{name}' → :#{klass.platform_id}")
71
+ rescue StandardError, ScriptError => e
72
+ result.skipped << [name, e.message]
73
+ log_skip(name, e.message)
74
+ end
75
+
76
+ def self.interface_gaps(klass)
77
+ missing = REQUIRED_CLASS_METHODS.reject { |m| klass.respond_to?(m) }
78
+ # Base defines stub instance methods that only raise NotImplementedError,
79
+ # so method_defined? alone passes via inheritance. Require the subclass to
80
+ # actually override them — i.e. the method's owner must not be Base.
81
+ missing += REQUIRED_INSTANCE_METHODS.reject do |m|
82
+ klass.method_defined?(m) && klass.instance_method(m).owner != Base
83
+ end
84
+ missing
85
+ end
86
+
87
+ def self.unregister(klass)
88
+ platform = (klass.platform_id if klass.respond_to?(:platform_id))
89
+ return unless platform
90
+
91
+ Adapters.unregister(platform)
92
+ end
93
+
94
+ def self.log_skip(name, reason)
95
+ Clacky::Logger.warn("[UserAdapterLoader] Skipped channel adapter '#{name}': #{reason}")
96
+ end
97
+
98
+ # Generate a ready-to-edit adapter skeleton at ~/.clacky/channels/<name>/adapter.rb.
99
+ # The skeleton already self-registers and implements the full interface with
100
+ # TODO markers — the author only fills in the method bodies.
101
+ # @return [String] path to the generated adapter.rb
102
+ def self.scaffold(name, dir: DEFAULT_DIR)
103
+ slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
104
+ raise ArgumentError, "invalid channel name: #{name.inspect}" if slug.empty?
105
+
106
+ target_dir = File.join(dir, slug)
107
+ path = File.join(target_dir, "adapter.rb")
108
+ raise ArgumentError, "adapter already exists: #{path}" if File.exist?(path)
109
+
110
+ FileUtils.mkdir_p(target_dir)
111
+ File.write(path, skeleton(slug))
112
+ path
113
+ end
114
+
115
+ def self.skeleton(slug)
116
+ const = slug.split("_").map(&:capitalize).join
117
+ <<~RUBY
118
+ # frozen_string_literal: true
119
+
120
+ # User-defined channel adapter for ":#{slug}".
121
+ # Edit the TODO sections, then it loads automatically on next start.
122
+ # Verify with: clacky channel verify
123
+
124
+ module Clacky
125
+ module Channel
126
+ module Adapters
127
+ class #{const}Adapter < Base
128
+ def self.platform_id
129
+ :#{slug}
130
+ end
131
+
132
+ # Map raw config (channels.yml `#{slug}` section) to a symbol-keyed hash.
133
+ def self.platform_config(data)
134
+ {
135
+ # TODO: pull your credentials out of `data`
136
+ # token: data["IM_#{slug.upcase}_TOKEN"] || data["token"]
137
+ }.compact
138
+ end
139
+
140
+ def initialize(config)
141
+ @config = config
142
+ end
143
+
144
+ # Begin receiving messages. Blocks until #stop — runs inside a Thread.
145
+ # Yield one standardized event Hash per inbound message.
146
+ def start(&on_message)
147
+ # TODO: connect to your platform and loop, calling on_message.call(event)
148
+ raise NotImplementedError
149
+ end
150
+
151
+ def stop
152
+ # TODO: close connections / stop the read loop
153
+ end
154
+
155
+ # Send a plain text (or Markdown) message to a chat.
156
+ # @return [Hash] { message_id: String }
157
+ def send_text(chat_id, text, reply_to: nil)
158
+ # TODO: call your platform's send API
159
+ raise NotImplementedError
160
+ end
161
+
162
+ # Optional: validate config; return array of error strings (empty = ok).
163
+ def validate_config(config)
164
+ []
165
+ end
166
+
167
+ Adapters.register(platform_id, self)
168
+ end
169
+ end
170
+ end
171
+ end
172
+ RUBY
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -31,3 +31,8 @@ require_relative "channel/adapters/dingtalk/adapter"
31
31
  require_relative "channel/channel_config"
32
32
  require_relative "channel/channel_ui_controller"
33
33
  require_relative "channel/channel_manager"
34
+
35
+ # Discover and load user-defined adapters from ~/.clacky/channels/.
36
+ # Must run after the bundled adapters so user adapters can extend or override.
37
+ require_relative "channel/user_adapter_loader"
38
+ Clacky::Channel::Adapters::UserAdapterLoader.load_all
@@ -436,8 +436,8 @@ module Clacky
436
436
  when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
437
437
  when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
438
438
  when ["GET", "/api/billing/records"] then api_billing_records(req, res)
439
- when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
440
- when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
439
+ when ["GET", "/api/billing/sessions"] then api_billing_sessions(req, res)
440
+ when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res) when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
441
441
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
442
442
  else
443
443
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
@@ -1170,8 +1170,27 @@ module Clacky
1170
1170
  })
1171
1171
  end
1172
1172
 
1173
- # DELETE /api/billing/clear
1174
- # Clears billing records
1173
+ # GET /api/billing/sessions
1174
+ # Returns session-level billing summary
1175
+ # Query params: period (day|week|month|year|all, default: month), model, limit
1176
+ def api_billing_sessions(req, res)
1177
+ require_relative "../billing/billing_store"
1178
+
1179
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1180
+ period = (query["period"] || "month").to_sym
1181
+ model = query["model"]
1182
+ limit = [(query["limit"] || "50").to_i, 200].min
1183
+
1184
+ store = Clacky::Billing::BillingStore.new
1185
+ sessions = store.session_summary(period: period, model: model, limit: limit)
1186
+
1187
+ json_response(res, 200, {
1188
+ sessions: sessions,
1189
+ count: sessions.size
1190
+ })
1191
+ end
1192
+
1193
+ # DELETE /api/billing/clear # Clears billing records
1175
1194
  # Query params: scope (today|all, default: today)
1176
1195
  def api_billing_clear(req, res)
1177
1196
  require_relative "../billing/billing_store"
@@ -4386,7 +4405,9 @@ module Clacky
4386
4405
  # @param working_dir [String] working directory for the agent
4387
4406
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
4388
4407
  # :auto_approve (unattended — suppresses request_user_feedback waits)
4389
- def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
4408
+ def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
4409
+ working_dir ||= default_working_dir
4410
+ FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
4390
4411
  session_id = Clacky::SessionManager.generate_id
4391
4412
  @registry.create(session_id: session_id)
4392
4413
 
@@ -223,11 +223,8 @@ module Clacky
223
223
  prompt = read_task(task_name)
224
224
  name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
225
225
 
226
- working_dir = File.expand_path("~/clacky_workspace")
227
- FileUtils.mkdir_p(working_dir)
228
-
229
226
  # Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
230
- session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve, source: :cron)
227
+ session_id = @session_builder.call(name: name, permission_mode: :auto_approve, source: :cron)
231
228
 
232
229
  Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
233
230
 
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+ require "yaml"
7
+ require "fileutils"
8
+
9
+ module Clacky
10
+ # Loads declarative, shell-based hooks from ~/.clacky/hooks.yml and registers
11
+ # them on a HookManager. Each hook runs an external command rather than Ruby in
12
+ # the agent process, which keeps user-authored hooks sandboxed and safe.
13
+ #
14
+ # hooks.yml format:
15
+ # hooks:
16
+ # before_tool_use:
17
+ # - name: guard # optional label for logs
18
+ # command: "~/.clacky/hook-scripts/guard.sh"
19
+ # timeout: 10 # optional, seconds (default 10)
20
+ # on_complete:
21
+ # - command: "notify-send done"
22
+ #
23
+ # Runtime contract (per invocation):
24
+ # - The event payload is passed to the command as JSON on STDIN.
25
+ # - exit 0 → allow (default).
26
+ # - exit 2 → deny; STDOUT becomes the denial reason. Only meaningful for
27
+ # before_tool_use, which the agent checks for {action: :deny}.
28
+ # - any other exit / timeout / crash → logged, treated as allow (a broken
29
+ # hook must never wedge the agent).
30
+ class ShellHookLoader
31
+ DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
32
+ DEFAULT_TIMEOUT = 10
33
+ DENY_EXIT_CODE = 2
34
+
35
+ Result = Struct.new(:registered, :skipped, keyword_init: true)
36
+
37
+ def self.load_into(hook_manager, path: DEFAULT_PATH)
38
+ new(path: path).load_into(hook_manager)
39
+ end
40
+
41
+ # Create a starter hooks.yml plus an example guard script. Idempotent-ish:
42
+ # raises if hooks.yml already exists so we never clobber user config.
43
+ # @return [String] path to the created hooks.yml
44
+ def self.scaffold(path: DEFAULT_PATH)
45
+ raise ArgumentError, "hooks file already exists: #{path}" if File.exist?(path)
46
+
47
+ dir = File.dirname(path)
48
+ scripts_dir = File.join(dir, "hook-scripts")
49
+ FileUtils.mkdir_p(scripts_dir)
50
+
51
+ guard = File.join(scripts_dir, "deny-example.sh")
52
+ File.write(guard, <<~SH)
53
+ #!/usr/bin/env bash
54
+ # Example before_tool_use hook.
55
+ # Reads the event JSON on STDIN; exit 2 to DENY, exit 0 to ALLOW.
56
+ # STDOUT on exit 2 becomes the denial reason shown to the agent.
57
+ payload="$(cat)"
58
+ # Example: deny any terminal command containing "rm -rf /"
59
+ if echo "$payload" | grep -q 'rm -rf /'; then
60
+ echo "blocked dangerous command"
61
+ exit 2
62
+ fi
63
+ exit 0
64
+ SH
65
+ FileUtils.chmod("+x", guard)
66
+
67
+ File.write(path, <<~YAML)
68
+ # Declarative shell hooks. Each command receives the event payload as JSON
69
+ # on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
70
+ # Events: #{HookManager::HOOK_EVENTS.join(", ")}
71
+ hooks:
72
+ before_tool_use:
73
+ - name: deny-example
74
+ command: "#{guard}"
75
+ timeout: 10
76
+ # on_complete:
77
+ # - command: "echo task finished"
78
+ YAML
79
+
80
+ path
81
+ end
82
+
83
+ def initialize(path: DEFAULT_PATH)
84
+ @path = path
85
+ end
86
+
87
+ # @return [Result] counts of registered hooks and skipped (with reasons)
88
+ def load_into(hook_manager)
89
+ result = Result.new(registered: [], skipped: [])
90
+ return result unless File.exist?(@path)
91
+
92
+ doc = YAMLCompat.load_file(@path) || {}
93
+ events = doc["hooks"] || {}
94
+
95
+ events.each do |event_name, specs|
96
+ event = event_name.to_sym
97
+ Array(specs).each do |spec|
98
+ register_one(hook_manager, event, spec, result)
99
+ end
100
+ end
101
+
102
+ result
103
+ rescue StandardError => e
104
+ Clacky::Logger.error("[ShellHookLoader] Failed to load #{@path}: #{e.message}")
105
+ result
106
+ end
107
+
108
+ private def register_one(hook_manager, event, spec, result)
109
+ command = spec["command"].to_s.strip
110
+ name = spec["name"] || command
111
+ timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
112
+
113
+ if command.empty?
114
+ result.skipped << [name, "missing command"]
115
+ return
116
+ end
117
+
118
+ unless HookManager::HOOK_EVENTS.include?(event)
119
+ result.skipped << [name, "unknown event: #{event}"]
120
+ return
121
+ end
122
+
123
+ hook_manager.add(event) do |*args|
124
+ run_command(event, command, timeout, args)
125
+ end
126
+ result.registered << [event, name]
127
+ end
128
+
129
+ private def run_command(event, command, timeout, args)
130
+ payload = JSON.generate(build_payload(event, args))
131
+
132
+ out = +""
133
+ status = nil
134
+ Open3.popen3(command) do |stdin, stdout, _stderr, wait_thr|
135
+ stdin.write(payload)
136
+ stdin.close
137
+ if wait_thr.join(timeout)
138
+ out = stdout.read
139
+ status = wait_thr.value
140
+ else
141
+ Process.kill("TERM", wait_thr.pid) rescue nil
142
+ raise Timeout::Error
143
+ end
144
+ end
145
+
146
+ if status&.exitstatus == DENY_EXIT_CODE
147
+ { action: :deny, reason: out.strip.empty? ? "Denied by hook" : out.strip }
148
+ else
149
+ { action: :allow }
150
+ end
151
+ rescue Timeout::Error
152
+ Clacky::Logger.warn("[ShellHookLoader] Hook '#{command}' timed out after #{timeout}s — allowing")
153
+ { action: :allow }
154
+ rescue StandardError => e
155
+ Clacky::Logger.warn("[ShellHookLoader] Hook '#{command}' failed: #{e.message} — allowing")
156
+ { action: :allow }
157
+ end
158
+
159
+ # Normalize the positional trigger args of each event into a JSON-serializable hash.
160
+ private def build_payload(event, args)
161
+ base = { event: event.to_s }
162
+
163
+ case event
164
+ when :before_tool_use, :after_tool_use, :on_tool_error
165
+ base[:tool] = args[0]
166
+ base[:result] = args[1] if args.length > 1 && event == :after_tool_use
167
+ base[:error] = args[1].to_s if event == :on_tool_error && args[1]
168
+ when :on_start
169
+ base[:user_input] = args[0].to_s
170
+ when :on_iteration
171
+ base[:iteration] = args[0]
172
+ when :on_complete
173
+ base[:result] = args[0]
174
+ when :session_rollback
175
+ base[:info] = args[0]
176
+ end
177
+
178
+ base
179
+ end
180
+ end
181
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.7"
4
+ VERSION = "1.2.8"
5
5
  end
@@ -9018,11 +9018,10 @@ body.setup-mode[data-theme="dark"] {
9018
9018
  transition: height 0.2s ease;
9019
9019
  }
9020
9020
  .billing-cache-miss {
9021
- background: #3b82f6;
9021
+ background: #f59e0b;
9022
9022
  width: 100%;
9023
9023
  transition: height 0.2s ease;
9024
- }
9025
- .billing-output-bar {
9024
+ }.billing-output-bar {
9026
9025
  width: 12px;
9027
9026
  background: #10b981;
9028
9027
  border-radius: 2px 2px 0 0;
@@ -9038,10 +9037,10 @@ body.setup-mode[data-theme="dark"] {
9038
9037
  }
9039
9038
 
9040
9039
  /* Legend colors */
9040
+ .billing-legend-total { background: #6366f1; }
9041
9041
  .billing-legend-cache-hit { background: #93c5fd; }
9042
- .billing-legend-cache-miss { background: #3b82f6; }
9042
+ .billing-legend-cache-miss { background: #f59e0b; }
9043
9043
  .billing-legend-output { background: #10b981; }
9044
-
9045
9044
  /* ── Chart Tooltip ───────────────────────────────────────────────────── */
9046
9045
  .billing-chart-tooltip {
9047
9046
  display: none;
@@ -9085,10 +9084,10 @@ body.setup-mode[data-theme="dark"] {
9085
9084
  border-radius: 50%;
9086
9085
  flex-shrink: 0;
9087
9086
  }
9087
+ .tooltip-total { background: #6366f1; }
9088
9088
  .tooltip-cache-hit { background: #93c5fd; }
9089
- .tooltip-cache-miss { background: #3b82f6; }
9090
- .tooltip-output { background: #10b981; }
9091
- .tooltip-label {
9089
+ .tooltip-cache-miss { background: #f59e0b; }
9090
+ .tooltip-output { background: #10b981; }.tooltip-label {
9092
9091
  flex: 1;
9093
9092
  color: var(--color-text-secondary);
9094
9093
  }
@@ -9155,8 +9154,8 @@ body.setup-mode[data-theme="dark"] {
9155
9154
  border-radius: 3px;
9156
9155
  transition: width 0.3s ease;
9157
9156
  }
9158
- .billing-bar-prompt {
9159
- background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%);
9157
+ .billing-bar-total {
9158
+ background: linear-gradient(90deg, #6366f1 0%, #818cf8 100%);
9160
9159
  }
9161
9160
  .billing-bar-completion {
9162
9161
  background: linear-gradient(90deg, #10b981 0%, #34d399 100%);
@@ -9164,10 +9163,9 @@ body.setup-mode[data-theme="dark"] {
9164
9163
  .billing-bar-cache-read {
9165
9164
  background: linear-gradient(90deg, #93c5fd 0%, #bfdbfe 100%);
9166
9165
  }
9167
- .billing-bar-cache-write {
9168
- background: linear-gradient(90deg, #6366f1 0%, #818cf8 100%);
9166
+ .billing-bar-cache-miss {
9167
+ background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%);
9169
9168
  }
9170
-
9171
9169
  /* ── Model List ──────────────────────────────────────────────────────── */
9172
9170
  .billing-model-list {
9173
9171
  display: flex;
@@ -9569,3 +9567,147 @@ body.setup-mode[data-theme="dark"] {
9569
9567
  color: var(--color-text-secondary);
9570
9568
  font-weight: 500;
9571
9569
  }
9570
+
9571
+ /* ── Sessions List ───────────────────────────────────────────────────── */
9572
+ .billing-sessions-row {
9573
+ margin-top: 1rem;
9574
+ }
9575
+ .billing-sessions-section {
9576
+ background: var(--color-bg-secondary);
9577
+ border: 1px solid var(--color-border-primary);
9578
+ border-radius: 12px;
9579
+ padding: 1.25rem;
9580
+ }
9581
+ .billing-sessions-section h3 {
9582
+ font-size: 0.9375rem;
9583
+ font-weight: 600;
9584
+ color: var(--color-text-primary);
9585
+ margin: 0 0 1rem 0;
9586
+ }
9587
+ .billing-sessions-empty {
9588
+ text-align: center;
9589
+ padding: 2rem;
9590
+ color: var(--color-text-secondary);
9591
+ font-size: 0.875rem;
9592
+ }
9593
+ .billing-sessions-header {
9594
+ display: grid;
9595
+ grid-template-columns: 36px 1.5fr 0.8fr 0.8fr 0.8fr 0.8fr 0.8fr 1fr;
9596
+ gap: 0.5rem;
9597
+ padding: 0.75rem 1rem;
9598
+ background: var(--color-bg-tertiary);
9599
+ border-radius: 8px;
9600
+ font-size: 0.75rem;
9601
+ font-weight: 600;
9602
+ color: var(--color-text-secondary);
9603
+ text-transform: uppercase;
9604
+ letter-spacing: 0.05em;
9605
+ margin-bottom: 0.5rem;
9606
+ }
9607
+ .billing-sessions-list {
9608
+ display: flex;
9609
+ flex-direction: column;
9610
+ gap: 0.25rem;
9611
+ }
9612
+ .billing-session-row {
9613
+ display: grid;
9614
+ grid-template-columns: 36px 1.5fr 0.8fr 0.8fr 0.8fr 0.8fr 0.8fr 1fr;
9615
+ gap: 0.5rem;
9616
+ padding: 0.75rem 1rem;
9617
+ border-radius: 8px;
9618
+ transition: background 0.15s;
9619
+ align-items: center;
9620
+ }
9621
+ .billing-session-row:hover {
9622
+ background: var(--color-bg-tertiary);
9623
+ }
9624
+ .billing-session-deleted {
9625
+ opacity: 0.7;
9626
+ border-left: 3px solid var(--color-warning);
9627
+ }
9628
+ .billing-session-deleted .billing-cell-main {
9629
+ color: var(--color-text-secondary);
9630
+ font-style: italic;
9631
+ }
9632
+ .billing-cell {
9633
+ font-size: 0.875rem;
9634
+ white-space: nowrap;
9635
+ overflow: hidden;
9636
+ text-overflow: ellipsis;
9637
+ }
9638
+ .billing-cell-index {
9639
+ font-size: 0.75rem;
9640
+ color: var(--color-text-secondary);
9641
+ text-align: center;
9642
+ }
9643
+ .billing-cell-session {
9644
+ display: flex;
9645
+ flex-direction: column;
9646
+ gap: 0.125rem;
9647
+ min-width: 0;
9648
+ }
9649
+ .billing-cell-main {
9650
+ font-weight: 500;
9651
+ color: var(--color-text-primary);
9652
+ font-family: var(--font-mono);
9653
+ white-space: nowrap;
9654
+ overflow: hidden;
9655
+ text-overflow: ellipsis;
9656
+ }
9657
+ .billing-cell-sub {
9658
+ font-size: 0.7rem;
9659
+ color: var(--color-text-secondary);
9660
+ white-space: nowrap;
9661
+ overflow: hidden;
9662
+ text-overflow: ellipsis;
9663
+ }
9664
+ .billing-cell-number {
9665
+ font-family: var(--font-mono);
9666
+ text-align: right;
9667
+ color: var(--color-text-primary);
9668
+ }
9669
+ .billing-cell-hit {
9670
+ color: #3b82f6;
9671
+ }
9672
+ .billing-cell-miss {
9673
+ color: #f59e0b;
9674
+ }
9675
+ .billing-cell-cost {
9676
+ font-family: var(--font-mono);
9677
+ font-weight: 600;
9678
+ color: var(--color-accent);
9679
+ text-align: right;
9680
+ }
9681
+ .billing-cell-time {
9682
+ font-size: 0.75rem;
9683
+ color: var(--color-text-secondary);
9684
+ }
9685
+ @media (max-width: 768px) {
9686
+ .billing-sessions-header {
9687
+ display: none;
9688
+ }
9689
+ .billing-session-row {
9690
+ grid-template-columns: 1fr 1fr;
9691
+ gap: 0.5rem;
9692
+ padding: 1rem;
9693
+ }
9694
+ .billing-cell-index {
9695
+ display: none;
9696
+ }
9697
+ .billing-cell-session {
9698
+ grid-column: 1 / -1;
9699
+ }
9700
+ .billing-cell-number {
9701
+ text-align: left;
9702
+ }
9703
+ .billing-cell-number::before {
9704
+ font-size: 0.625rem;
9705
+ color: var(--color-text-secondary);
9706
+ text-transform: uppercase;
9707
+ display: block;
9708
+ }
9709
+ .billing-cell-time {
9710
+ grid-column: 1 / -1;
9711
+ text-align: right;
9712
+ }
9713
+ }