openclacky 1.5.11 → 1.5.13
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 +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
|
@@ -96,7 +96,7 @@ module Clacky
|
|
|
96
96
|
forward_to_subscribers { |sub| sub.show_user_message(content) if sub.respond_to?(:show_user_message) }
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
def show_assistant_message(content, files:, interim: false)
|
|
99
|
+
def show_assistant_message(content, files:, interim: false, created_at: nil)
|
|
100
100
|
return if (content.nil? || content.to_s.strip.empty?) && files.empty?
|
|
101
101
|
|
|
102
102
|
# Rewrite local image paths (file:// and bare absolute) to /api/local-image
|
|
@@ -105,7 +105,7 @@ module Clacky
|
|
|
105
105
|
# Channel subscribers receive the original content so they can deliver
|
|
106
106
|
# local images as native attachments via send_file().
|
|
107
107
|
web_content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
108
|
-
emit("assistant_message", content: web_content, files: files)
|
|
108
|
+
emit("assistant_message", content: web_content, files: files, created_at: created_at)
|
|
109
109
|
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files, interim: interim) }
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -128,7 +128,8 @@ module Clacky
|
|
|
128
128
|
context: context,
|
|
129
129
|
options: first[:options] || [],
|
|
130
130
|
questions: questions)
|
|
131
|
-
#
|
|
131
|
+
# IM subscribers render the same questions as plain text — they have no card.
|
|
132
|
+
forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
|
|
132
133
|
return
|
|
133
134
|
end
|
|
134
135
|
|
|
@@ -189,8 +190,9 @@ module Clacky
|
|
|
189
190
|
# Token usage is internal detail — intentionally not forwarded
|
|
190
191
|
end
|
|
191
192
|
|
|
192
|
-
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
|
|
193
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
|
|
193
194
|
data = { iterations: iterations, cost: cost }
|
|
195
|
+
data[:task_id] = task_id if task_id
|
|
194
196
|
data[:duration] = duration if duration
|
|
195
197
|
data[:cache_stats] = cache_stats if cache_stats
|
|
196
198
|
data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
|
|
@@ -199,7 +201,7 @@ module Clacky
|
|
|
199
201
|
forward_to_subscribers do |sub|
|
|
200
202
|
sub.show_complete(iterations: iterations, cost: cost, duration: duration,
|
|
201
203
|
cache_stats: cache_stats, awaiting_user_feedback: awaiting_user_feedback,
|
|
202
|
-
cost_source: cost_source)
|
|
204
|
+
cost_source: cost_source, task_id: task_id)
|
|
203
205
|
end
|
|
204
206
|
end
|
|
205
207
|
|
|
@@ -34,18 +34,32 @@ module Clacky
|
|
|
34
34
|
|
|
35
35
|
@last_saved_path = filepath
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
begin
|
|
39
|
-
cleanup_by_count(keep: 200, grouped_keep: 200)
|
|
40
|
-
cleanup_trash(days: 8)
|
|
41
|
-
cleanup_file_trash(days: 8)
|
|
42
|
-
rescue StandardError
|
|
43
|
-
# Cleanup is non-critical; swallow file errors but never AgentInterrupted
|
|
44
|
-
end
|
|
37
|
+
run_cleanup_async
|
|
45
38
|
|
|
46
39
|
filepath
|
|
47
40
|
end
|
|
48
41
|
|
|
42
|
+
# Run the best-effort maintenance chores off the save path. File-trash pruning
|
|
43
|
+
# walks every project's trash dir and can take hundreds of ms once trash grows
|
|
44
|
+
# large; there is no reason to make a session save wait for it. A single flag
|
|
45
|
+
# prevents overlapping sweeps from piling up when saves happen back-to-back.
|
|
46
|
+
private def run_cleanup_async
|
|
47
|
+
return if @cleanup_running
|
|
48
|
+
@cleanup_running = true
|
|
49
|
+
|
|
50
|
+
Clacky::ThreadRegistry.spawn(name: "session-cleanup", daemon: true) do
|
|
51
|
+
begin
|
|
52
|
+
cleanup_by_count(keep: 200, grouped_keep: 200)
|
|
53
|
+
cleanup_trash(days: 8)
|
|
54
|
+
cleanup_file_trash(days: 8)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
# Cleanup is non-critical; swallow file errors but never AgentInterrupted
|
|
57
|
+
ensure
|
|
58
|
+
@cleanup_running = false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
49
63
|
# Path of the last saved session file.
|
|
50
64
|
def last_saved_path
|
|
51
65
|
@last_saved_path
|
|
@@ -337,17 +351,20 @@ module Clacky
|
|
|
337
351
|
# Returns count of soft-deleted sessions.
|
|
338
352
|
def cleanup_by_count(keep:, grouped_keep: 200)
|
|
339
353
|
# Fast path: if total session files can't exceed any single group's cap,
|
|
340
|
-
# skip the
|
|
341
|
-
#
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return 0 if file_count <= [keep, grouped_keep].min
|
|
354
|
+
# skip the scan entirely. min() is correct because groups are mutually
|
|
355
|
+
# exclusive — any single group's count ≤ non_pinned ≤ file_count.
|
|
356
|
+
files = Dir.glob(File.join(@sessions_dir, "*.json"))
|
|
357
|
+
return 0 if files.size <= [keep, grouped_keep].min
|
|
345
358
|
|
|
346
|
-
|
|
359
|
+
# Read only the header of each file (cheap) instead of parsing the whole
|
|
360
|
+
# multi-MB payload. The header carries everything we need to rank and
|
|
361
|
+
# group sessions except project_id, which is resolved lazily below.
|
|
362
|
+
metas = files.filter_map { |f| m = load_session_metadata(f); m && m.merge(_path: f) }
|
|
363
|
+
.sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
|
|
347
364
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
365
|
+
non_pinned = metas.reject { |s| s[:pinned] }
|
|
366
|
+
|
|
367
|
+
groups = non_pinned.group_by { |s| group_key_for(s) }
|
|
351
368
|
|
|
352
369
|
victims = groups.flat_map do |source, sessions|
|
|
353
370
|
sessions[(source == "regular" ? keep : grouped_keep)..] || []
|
|
@@ -357,6 +374,18 @@ module Clacky
|
|
|
357
374
|
victims.size
|
|
358
375
|
end
|
|
359
376
|
|
|
377
|
+
# A session belongs to the "regular" pool unless it is a folded grouped
|
|
378
|
+
# source (cron/ext) with no project. project_id only matters for cron/ext
|
|
379
|
+
# sessions, and it lives deep in the file, so we pay for a full parse only
|
|
380
|
+
# for those — everything else is decided from the cheap header alone.
|
|
381
|
+
private def group_key_for(meta)
|
|
382
|
+
source = meta[:source].to_s
|
|
383
|
+
return "regular" unless GROUPED_SOURCES.include?(source)
|
|
384
|
+
|
|
385
|
+
full = load_session_file(meta[:_path])
|
|
386
|
+
(full && full[:project_id]) ? "regular" : source
|
|
387
|
+
end
|
|
388
|
+
|
|
360
389
|
# ── Session trash (delegates to Tools::TrashManager) ──────────────
|
|
361
390
|
# All business logic lives in Clacky::Tools::TrashManager; SessionManager
|
|
362
391
|
# only provides the sessions_dir context and filesystem helpers used there.
|
|
@@ -464,6 +493,34 @@ module Clacky
|
|
|
464
493
|
nil
|
|
465
494
|
end
|
|
466
495
|
|
|
496
|
+
# Read only the small scalar header of a session file without parsing the
|
|
497
|
+
# (potentially multi-MB) messages / time_machine payload. Session JSON is
|
|
498
|
+
# written with a stable field order, so the fields we care about here
|
|
499
|
+
# (session_id, pinned, created_at, updated_at, source) always sit in the
|
|
500
|
+
# first few hundred bytes, before any large array/object. `project_id`
|
|
501
|
+
# lives much later and is intentionally NOT read — callers that need it
|
|
502
|
+
# fall back to load_session_file for the rare candidate that requires it.
|
|
503
|
+
#
|
|
504
|
+
# Returns a Hash with symbol keys, or nil if the header can't be read.
|
|
505
|
+
METADATA_HEADER_BYTES = 16 * 1024
|
|
506
|
+
def load_session_metadata(filepath)
|
|
507
|
+
head = File.open(filepath, "r") { |f| f.read(METADATA_HEADER_BYTES) }
|
|
508
|
+
return nil unless head
|
|
509
|
+
|
|
510
|
+
sid = head[/"session_id"\s*:\s*"([^"]*)"/, 1]
|
|
511
|
+
return nil unless sid
|
|
512
|
+
|
|
513
|
+
{
|
|
514
|
+
session_id: sid,
|
|
515
|
+
pinned: head[/"pinned"\s*:\s*(true|false)/, 1] == "true",
|
|
516
|
+
created_at: head[/"created_at"\s*:\s*"([^"]*)"/, 1],
|
|
517
|
+
updated_at: head[/"updated_at"\s*:\s*"([^"]*)"/, 1],
|
|
518
|
+
source: head[/"source"\s*:\s*"([^"]*)"/, 1]
|
|
519
|
+
}
|
|
520
|
+
rescue Errno::ENOENT
|
|
521
|
+
nil
|
|
522
|
+
end
|
|
523
|
+
|
|
467
524
|
# Remove Time Machine snapshots that no longer belong to any known session.
|
|
468
525
|
# Snapshots are keyed by full session_id; session files are named by the
|
|
469
526
|
# 8-char id prefix, so a snapshot dir is an orphan when its prefix matches
|
|
@@ -6,6 +6,7 @@ module Clacky
|
|
|
6
6
|
self.tool_name = "ask_user"
|
|
7
7
|
self.tool_description = <<~DESC
|
|
8
8
|
Ask the user to choose when you cannot infer the answer and guessing would waste work.
|
|
9
|
+
Keep every question concise: one sentence, no more than 80 characters.
|
|
9
10
|
|
|
10
11
|
Every question must have `options` — this tool is for choices, not open prompts.
|
|
11
12
|
Need freeform input (a path, a name)? Just ask in your reply instead.
|
|
@@ -27,7 +28,7 @@ module Clacky
|
|
|
27
28
|
properties: {
|
|
28
29
|
question: {
|
|
29
30
|
type: "string",
|
|
30
|
-
description: "
|
|
31
|
+
description: "A concise question in one sentence, no more than 80 characters."
|
|
31
32
|
},
|
|
32
33
|
description: {
|
|
33
34
|
type: "string",
|
|
@@ -56,7 +57,7 @@ module Clacky
|
|
|
56
57
|
},
|
|
57
58
|
question: {
|
|
58
59
|
type: "string",
|
|
59
|
-
description: "Single-question shorthand."
|
|
60
|
+
description: "Single-question shorthand: one sentence, no more than 80 characters."
|
|
60
61
|
},
|
|
61
62
|
context: {
|
|
62
63
|
type: "string",
|
|
@@ -813,6 +813,9 @@ module Clacky
|
|
|
813
813
|
spawn_env = {
|
|
814
814
|
"TERM" => "xterm-256color",
|
|
815
815
|
"PS1" => " ",
|
|
816
|
+
# PTY makes git/less page interactively; force cat, scoped to our shell only.
|
|
817
|
+
"GIT_PAGER" => "cat",
|
|
818
|
+
"PAGER" => "cat",
|
|
816
819
|
# Prevent our sub-shell from polluting the user's ~/.zsh_history
|
|
817
820
|
# (or ~/.bash_history). We fork a full interactive login shell to
|
|
818
821
|
# get rbenv/nvm/brew-shellenv/mise loaded, but every command we
|
|
@@ -873,7 +876,8 @@ module Clacky
|
|
|
873
876
|
end
|
|
874
877
|
|
|
875
878
|
marker_token = SecureRandom.hex(8)
|
|
876
|
-
|
|
879
|
+
session_box = []
|
|
880
|
+
reader_thread = start_reader_thread(reader, log_io, session_box)
|
|
877
881
|
|
|
878
882
|
session = SessionManager.register(
|
|
879
883
|
pid: pid, command: command, cwd: cwd || Dir.pwd,
|
|
@@ -883,6 +887,7 @@ module Clacky
|
|
|
883
887
|
mode: "shell", marker_token: marker_token,
|
|
884
888
|
shell_name: shell_name
|
|
885
889
|
)
|
|
890
|
+
session_box << session
|
|
886
891
|
|
|
887
892
|
# Give the shell a moment to print its startup banner (zsh -l -i
|
|
888
893
|
# loads a lot of stuff), then drain whatever noise it wrote so the
|
|
@@ -902,8 +907,21 @@ module Clacky
|
|
|
902
907
|
session
|
|
903
908
|
end
|
|
904
909
|
|
|
905
|
-
# Background thread: drain PTY → log file.
|
|
906
|
-
|
|
910
|
+
# Background thread: drain PTY → log file. It doubles as the session's
|
|
911
|
+
# death watcher: when the child exits, read_nonblock raises EIO and the
|
|
912
|
+
# thread ends up in the ensure block below. Releasing the PTY fds and
|
|
913
|
+
# forgetting the session there means a dead session's master fd is
|
|
914
|
+
# reclaimed the moment its child dies — even when the calling agent
|
|
915
|
+
# never comes back to poll or kill it, which used to leak the fd until
|
|
916
|
+
# the process hit EMFILE ("Too many open files").
|
|
917
|
+
#
|
|
918
|
+
# `session_box` is a one-element array filled by spawn_shell right
|
|
919
|
+
# after SessionManager.register (the session cannot exist before the
|
|
920
|
+
# thread starts); an empty box means registration never happened and
|
|
921
|
+
# spawn_shell itself owns the cleanup. Every close is rescue-guarded
|
|
922
|
+
# and SessionManager.forget is a plain Hash#delete, so this whole
|
|
923
|
+
# block is a no-op when the main thread's cleanup_session ran first.
|
|
924
|
+
private def start_reader_thread(reader, log_io, session_box)
|
|
907
925
|
Clacky::ThreadRegistry.spawn(name: "terminal-pty-reader") do
|
|
908
926
|
loop do
|
|
909
927
|
break if reader.closed? || log_io.closed?
|
|
@@ -923,6 +941,15 @@ module Clacky
|
|
|
923
941
|
end
|
|
924
942
|
ensure
|
|
925
943
|
log_io.close rescue nil
|
|
944
|
+
if (session = session_box[0])
|
|
945
|
+
# Flip the status first so a main thread parked in
|
|
946
|
+
# read_until_marker's poll loop notices the death immediately
|
|
947
|
+
# (its refresh() no longer finds the session in the registry).
|
|
948
|
+
session.status = "exited" unless %w[exited killed].include?(session.status)
|
|
949
|
+
session.writer.close rescue nil
|
|
950
|
+
session.reader.close rescue nil
|
|
951
|
+
SessionManager.forget(session.id)
|
|
952
|
+
end
|
|
926
953
|
end
|
|
927
954
|
end
|
|
928
955
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "tty-screen"
|
|
4
4
|
require_relative "theme_manager"
|
|
5
|
+
require_relative "strings_cjk_patch"
|
|
5
6
|
|
|
6
7
|
module Clacky
|
|
7
8
|
module UI2
|
|
@@ -21,14 +22,24 @@ module Clacky
|
|
|
21
22
|
# Get current theme colors
|
|
22
23
|
theme = ThemeManager.current_theme
|
|
23
24
|
|
|
25
|
+
width = TTY::Screen.width - 4 # Leave some margin
|
|
26
|
+
|
|
24
27
|
# Configure tty-markdown with custom theme and symbols
|
|
25
28
|
parsed = TTY::Markdown.parse(content,
|
|
26
29
|
theme: theme_colors,
|
|
27
30
|
symbols: custom_symbols,
|
|
28
|
-
width:
|
|
31
|
+
width: width
|
|
29
32
|
)
|
|
30
33
|
|
|
31
34
|
parsed
|
|
35
|
+
rescue IndexError => e
|
|
36
|
+
# Safety net behind strings_cjk_patch; retry uncolored if wrap still fails.
|
|
37
|
+
warn "[markdown] color render failed (#{e.class}), retrying without color" if ENV["CLACKY_DEBUG"]
|
|
38
|
+
begin
|
|
39
|
+
TTY::Markdown.parse(content, color: :never, symbols: custom_symbols, width: width)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
content
|
|
42
|
+
end
|
|
32
43
|
rescue StandardError => e
|
|
33
44
|
warn "[markdown] render failed: #{e.class}: #{e.message}" if ENV["CLACKY_DEBUG"]
|
|
34
45
|
content
|
|
@@ -59,20 +70,12 @@ module Clacky
|
|
|
59
70
|
def theme_colors
|
|
60
71
|
theme = ThemeManager.current_theme
|
|
61
72
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
73
|
+
# tty-markdown accepts a single :header key for all header levels
|
|
74
|
+
# (h1 additionally underlines). Code block colors come from rouge.
|
|
64
75
|
{
|
|
65
|
-
|
|
66
|
-
h1: Array(theme.colors[:info]) + [:bold],
|
|
67
|
-
h2: Array(theme.colors[:info]) + [:bold],
|
|
68
|
-
h3: Array(theme.colors[:info]),
|
|
69
|
-
h4: Array(theme.colors[:info]),
|
|
70
|
-
h5: Array(theme.colors[:info]),
|
|
71
|
-
h6: Array(theme.colors[:info]),
|
|
76
|
+
header: [:bright_cyan, :bold],
|
|
72
77
|
# Horizontal rule - make it subtle (dim gray)
|
|
73
78
|
hr: [:bright_black],
|
|
74
|
-
# Code blocks use dim color
|
|
75
|
-
code: Array(theme.colors[:thinking]),
|
|
76
79
|
# Links use success color (green)
|
|
77
80
|
link: Array(theme.colors[:success]),
|
|
78
81
|
# Lists use default text color
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strings"
|
|
4
|
+
|
|
5
|
+
# strings 0.2.1 (latest release, 2021) tracks ANSI reinsertion offsets in
|
|
6
|
+
# display width (CJK chars count as 2) but applies them as String character
|
|
7
|
+
# indexes via String#insert, so any colored CJK content that needs wrapping
|
|
8
|
+
# raises IndexError. These methods mirror upstream with offsets tracked in
|
|
9
|
+
# character count instead of display width.
|
|
10
|
+
module Strings
|
|
11
|
+
module Wrap
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def format_line(text_line, wrap_at, ansi_stack)
|
|
15
|
+
lines = []
|
|
16
|
+
line = []
|
|
17
|
+
word = []
|
|
18
|
+
ansi = []
|
|
19
|
+
ansi_matched = false
|
|
20
|
+
word_length = 0
|
|
21
|
+
line_length = 0
|
|
22
|
+
word_chars = 0
|
|
23
|
+
line_chars = 0
|
|
24
|
+
char_length = 0
|
|
25
|
+
text_length = display_width(text_line)
|
|
26
|
+
total_length = 0
|
|
27
|
+
|
|
28
|
+
UnicodeUtils.each_grapheme(text_line) do |char|
|
|
29
|
+
if char == Strings::ANSI::CSI || ansi.length > 0
|
|
30
|
+
ansi << char
|
|
31
|
+
if Strings::ANSI.only_ansi?(ansi.join)
|
|
32
|
+
ansi_matched = true
|
|
33
|
+
elsif ansi_matched
|
|
34
|
+
ansi_stack << [ansi[0...-1].join, line_chars + word_chars]
|
|
35
|
+
ansi_matched = false
|
|
36
|
+
|
|
37
|
+
if ansi.last == Strings::ANSI::CSI
|
|
38
|
+
ansi = [ansi.last]
|
|
39
|
+
else
|
|
40
|
+
ansi = []
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
next if ansi.length > 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
char_length = display_width(char)
|
|
47
|
+
total_length += char_length
|
|
48
|
+
if line_length + word_length + char_length <= wrap_at
|
|
49
|
+
if char == SPACE || total_length == text_length
|
|
50
|
+
line << word.join + char
|
|
51
|
+
line_length += word_length + char_length
|
|
52
|
+
line_chars += word_chars + char.size
|
|
53
|
+
word = []
|
|
54
|
+
word_length = 0
|
|
55
|
+
word_chars = 0
|
|
56
|
+
else
|
|
57
|
+
word << char
|
|
58
|
+
word_length += char_length
|
|
59
|
+
word_chars += char.size
|
|
60
|
+
end
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if char == SPACE
|
|
65
|
+
lines << insert_ansi(line.join, ansi_stack)
|
|
66
|
+
line = []
|
|
67
|
+
line_length = 0
|
|
68
|
+
line_chars = 0
|
|
69
|
+
word << char
|
|
70
|
+
word_length += char_length
|
|
71
|
+
word_chars += char.size
|
|
72
|
+
elsif word_length + char_length <= wrap_at
|
|
73
|
+
lines << insert_ansi(line.join, ansi_stack)
|
|
74
|
+
line = [word.join + char]
|
|
75
|
+
line_length = word_length + char_length
|
|
76
|
+
line_chars = word_chars + char.size
|
|
77
|
+
word = []
|
|
78
|
+
word_length = 0
|
|
79
|
+
word_chars = 0
|
|
80
|
+
else # hyphenate word - too long to fit a line
|
|
81
|
+
lines << insert_ansi(word.join, ansi_stack)
|
|
82
|
+
line_length = 0
|
|
83
|
+
line_chars = 0
|
|
84
|
+
word = [char]
|
|
85
|
+
word_length = char_length
|
|
86
|
+
word_chars = char.size
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
lines << insert_ansi(line.join, ansi_stack) unless line.empty?
|
|
90
|
+
lines << insert_ansi(word.join, ansi_stack) unless word.empty?
|
|
91
|
+
lines
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def insert_ansi(string, ansi_stack = [])
|
|
95
|
+
return string if ansi_stack.empty?
|
|
96
|
+
return string if string.empty?
|
|
97
|
+
|
|
98
|
+
new_stack = []
|
|
99
|
+
output = string.dup
|
|
100
|
+
length = string.size
|
|
101
|
+
matched_reset = false
|
|
102
|
+
ansi_reset = Strings::ANSI::RESET
|
|
103
|
+
|
|
104
|
+
ansi_stack.reverse_each do |ansi|
|
|
105
|
+
pos = ansi[1]
|
|
106
|
+
# Offsets carried over from a previous (longer) line can exceed this
|
|
107
|
+
# line; such codes belong before the wrapped content.
|
|
108
|
+
pos = 0 if pos > length
|
|
109
|
+
if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
|
|
110
|
+
matched_reset = true
|
|
111
|
+
output.insert(pos, ansi_reset)
|
|
112
|
+
next
|
|
113
|
+
elsif !matched_reset # ansi without reset
|
|
114
|
+
matched_reset = false
|
|
115
|
+
new_stack << ansi # keep the ansi
|
|
116
|
+
next if pos == length
|
|
117
|
+
if output.end_with?(NEWLINE)
|
|
118
|
+
output.insert(-2, ansi_reset)
|
|
119
|
+
else
|
|
120
|
+
output.insert(-1, ansi_reset)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
output.insert(pos, ansi[0])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ansi_stack.replace(new_stack)
|
|
128
|
+
|
|
129
|
+
output
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -31,7 +31,7 @@ module Clacky
|
|
|
31
31
|
COLORS = {
|
|
32
32
|
# Format: [symbol_color, dark_bg_text, light_bg_text]
|
|
33
33
|
user: [:bright_black, :white, :black], # User prompt and input
|
|
34
|
-
assistant: [:bright_green, :
|
|
34
|
+
assistant: [:bright_green, :bright_white, :black], # AI response
|
|
35
35
|
tool_call: [:bright_cyan, :cyan, :cyan], # Tool execution
|
|
36
36
|
tool_result: [:bright_cyan, :bright_black, :bright_black], # Tool output
|
|
37
37
|
tool_denied: [:bright_yellow, :yellow, :yellow], # Denied actions
|
|
@@ -28,7 +28,7 @@ module Clacky
|
|
|
28
28
|
COLORS = {
|
|
29
29
|
# Format: [symbol_color, dark_bg_text, light_bg_text]
|
|
30
30
|
user: [:bright_black, :bright_black, :black], # User prompt and input
|
|
31
|
-
assistant: [:green, :
|
|
31
|
+
assistant: [:green, :bright_white, :black], # AI response
|
|
32
32
|
tool_call: [:cyan, :cyan, :cyan], # Tool execution
|
|
33
33
|
tool_result: [:cyan, :bright_black, :bright_black], # Tool output
|
|
34
34
|
tool_denied: [:yellow, :yellow, :yellow], # Denied actions
|
|
@@ -448,7 +448,7 @@ module Clacky
|
|
|
448
448
|
|
|
449
449
|
# Show assistant message
|
|
450
450
|
# @param content [String] Message content
|
|
451
|
-
def show_assistant_message(content, files:, interim: false)
|
|
451
|
+
def show_assistant_message(content, files:, interim: false, created_at: nil)
|
|
452
452
|
# Filter out thinking tags from models like MiniMax M2.1 that use <think>...</think>
|
|
453
453
|
filtered_content = filter_thinking_tags(content)
|
|
454
454
|
return if filtered_content.nil? || filtered_content.strip.empty?
|
|
@@ -629,7 +629,7 @@ module Clacky
|
|
|
629
629
|
# @param duration [Float] Duration in seconds
|
|
630
630
|
# @param cache_stats [Hash] Cache statistics
|
|
631
631
|
# @param awaiting_user_feedback [Boolean] Whether agent is waiting for user feedback
|
|
632
|
-
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
|
|
632
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
|
|
633
633
|
# Update status back to 'idle' when task is complete
|
|
634
634
|
update_sessionbar(status: 'idle')
|
|
635
635
|
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Clacky
|
|
|
9
9
|
# === Output display ===
|
|
10
10
|
# @param content [String] text portion of the assistant reply (file:// links stripped)
|
|
11
11
|
# @param files [Array<Hash>] extracted file refs: [{ name:, path:, inline: }]
|
|
12
|
-
def show_assistant_message(content, files:, interim: false); end
|
|
12
|
+
def show_assistant_message(content, files:, interim: false, created_at: nil); end
|
|
13
13
|
def show_feedback_request(question, context, options, questions: nil); end
|
|
14
14
|
def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil); end
|
|
15
15
|
def show_subagent_end; end
|
|
@@ -33,7 +33,7 @@ module Clacky
|
|
|
33
33
|
# (e.g. /goal auto-switches to auto_approve). UIs that render the mode
|
|
34
34
|
# in a status bar override this.
|
|
35
35
|
def update_permission_mode(mode); end
|
|
36
|
-
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil); end
|
|
36
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil); end
|
|
37
37
|
def append_output(content); end
|
|
38
38
|
|
|
39
39
|
# === Status messages ===
|
|
@@ -7,6 +7,23 @@ module Clacky
|
|
|
7
7
|
# Pricing per 1M tokens (MTok) in USD
|
|
8
8
|
# All pricing is based on official API documentation
|
|
9
9
|
PRICING_TABLE = {
|
|
10
|
+
# Claude Fable 5.1 — same input/output as Fable 5, but cache read is
|
|
11
|
+
# $0.25/MTok (vs $1.00 for Fable 5). Source: Anthropic pricing table.
|
|
12
|
+
"claude-fable-5-1" => {
|
|
13
|
+
input: {
|
|
14
|
+
default: 10.00, # $10/MTok for prompts ≤ 200K tokens
|
|
15
|
+
over_200k: 10.00
|
|
16
|
+
},
|
|
17
|
+
output: {
|
|
18
|
+
default: 50.00, # $50/MTok for prompts ≤ 200K tokens
|
|
19
|
+
over_200k: 50.00
|
|
20
|
+
},
|
|
21
|
+
cache: {
|
|
22
|
+
write: 12.50, # $12.50/MTok cache write (5-min tier)
|
|
23
|
+
read: 0.25 # $0.25/MTok cache read
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
10
27
|
# Claude 4.5 models - tiered pricing based on prompt length
|
|
11
28
|
"claude-fable-5" => {
|
|
12
29
|
input: {
|
|
@@ -408,6 +425,46 @@ module Clacky
|
|
|
408
425
|
}
|
|
409
426
|
},
|
|
410
427
|
|
|
428
|
+
# Gemini 3.8 Flash. Flat pricing, 1M context, 64K max output.
|
|
429
|
+
# Same list price as 3.7/3.6 Flash; intro rate $0.75/$3.75 through
|
|
430
|
+
# 2026-12-31 before settling at $1.50/$7.50 (list price shown).
|
|
431
|
+
# Cache write billed at input rate (Vertex doesn't expose a separate
|
|
432
|
+
# cache-write charge in the OpenAI shim usage response).
|
|
433
|
+
"gemini-3.8-flash" => {
|
|
434
|
+
input: {
|
|
435
|
+
default: 1.50,
|
|
436
|
+
over_200k: 1.50
|
|
437
|
+
},
|
|
438
|
+
output: {
|
|
439
|
+
default: 7.50,
|
|
440
|
+
over_200k: 7.50
|
|
441
|
+
},
|
|
442
|
+
cache: {
|
|
443
|
+
write: 1.50,
|
|
444
|
+
read: 0.15
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
# Gemini 3.7 Flash. Flat pricing, 1M context, 64K max output.
|
|
449
|
+
# Same list price as 3.6 Flash; both carry an intro rate of $0.75/$3.75
|
|
450
|
+
# through 2026-12-31 before settling at $1.50/$7.50 (list price shown).
|
|
451
|
+
# Cache write billed at input rate (Vertex doesn't expose a separate
|
|
452
|
+
# cache-write charge in the OpenAI shim usage response).
|
|
453
|
+
"gemini-3.7-flash" => {
|
|
454
|
+
input: {
|
|
455
|
+
default: 1.50,
|
|
456
|
+
over_200k: 1.50
|
|
457
|
+
},
|
|
458
|
+
output: {
|
|
459
|
+
default: 7.50,
|
|
460
|
+
over_200k: 7.50
|
|
461
|
+
},
|
|
462
|
+
cache: {
|
|
463
|
+
write: 1.50,
|
|
464
|
+
read: 0.15
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
|
|
411
468
|
# Gemini 3.6 Flash (GA 2026-07-21). Flat pricing, 1M context, 64K max output.
|
|
412
469
|
# Source: https://deepmind.google/models/gemini/flash/
|
|
413
470
|
# Cache write billed at input rate (Vertex doesn't expose a separate
|
|
@@ -597,6 +654,18 @@ module Clacky
|
|
|
597
654
|
# GLM-5.3 shares GLM-5.2's base model (all gains are post-training per
|
|
598
655
|
# Z.ai's release notes) and Z.ai's pricing page does not list a separate
|
|
599
656
|
# GLM-5.3 row yet, so it is billed at the GLM-5.2 flat rate.
|
|
657
|
+
# GLM-5.3-Flash: GLM-5's first natively-multimodal model (image/video/
|
|
658
|
+
# file input). Listed at the non-promotional Z.ai rates — input $0.15,
|
|
659
|
+
# output $0.50, cache read $0.03 per 1M tokens; the 50%-off launch promo
|
|
660
|
+
# ($0.075 / $0.25 / $0.015, ends 2026-09-09) is ignored per the
|
|
661
|
+
# "displayed ≤ actual" rule. Cache write bills at the input miss rate
|
|
662
|
+
# (storage is "Limited-time Free").
|
|
663
|
+
"glm-5.3-flash" => {
|
|
664
|
+
input: { default: 0.15, over_200k: 0.15 },
|
|
665
|
+
output: { default: 0.50, over_200k: 0.50 },
|
|
666
|
+
cache: { write: 0.15, read: 0.03 }
|
|
667
|
+
},
|
|
668
|
+
|
|
600
669
|
"glm-5.3" => {
|
|
601
670
|
input: { default: 1.40, over_200k: 1.40 },
|
|
602
671
|
output: { default: 4.40, over_200k: 4.40 },
|
|
@@ -704,6 +773,15 @@ module Clacky
|
|
|
704
773
|
# - cache.read = official explicit-cache-hit price.
|
|
705
774
|
# - When a model has NO published explicit-cache price (e.g. qwen3.6-27b,
|
|
706
775
|
# qwen-plus-latest), cache.write/read fall back to the input rate.
|
|
776
|
+
# qwen3.8-max: NOT tiered (single flat tier). Reuses qwen3.7-max's
|
|
777
|
+
# international list rates (2.5 / 7.5 / 3.125 / 0.25) — the billing
|
|
778
|
+
# page hasn't listed qwen3.8-max yet, so this is a safe upper bound.
|
|
779
|
+
"qwen3.8-max" => {
|
|
780
|
+
input: { default: 2.5, over_200k: 2.5 },
|
|
781
|
+
output: { default: 7.5, over_200k: 7.5 },
|
|
782
|
+
cache: { write: 3.125, read: 0.25 }
|
|
783
|
+
},
|
|
784
|
+
|
|
707
785
|
# qwen3.7-max: NOT tiered (single flat tier per Alibaba's definition).
|
|
708
786
|
# List price: input 2.5, output 7.5, explicit write 3.125, explicit read 0.25.
|
|
709
787
|
"qwen3.7-max" => {
|
|
@@ -888,6 +966,8 @@ module Clacky
|
|
|
888
966
|
# Support both dot and dash separators (e.g., "4.5", "4-5", "4-6")
|
|
889
967
|
# Also handles Bedrock cross-region prefixes (e.g. "jp.anthropic.claude-sonnet-4-6")
|
|
890
968
|
case model
|
|
969
|
+
when /claude.*fable.*5[.-]1/i
|
|
970
|
+
"claude-fable-5-1"
|
|
891
971
|
when /claude.*fable.*5/i
|
|
892
972
|
"claude-fable-5"
|
|
893
973
|
# Claude Sonnet 5 / Opus 5 (2026) — anchored on the literal "sonnet-5"
|
|
@@ -955,6 +1035,8 @@ module Clacky
|
|
|
955
1035
|
# (mainland bigmodel.cn vs intl z.ai) the user configured.
|
|
956
1036
|
# Strict anchored match so unrelated strings like "glm-5-x-foo"
|
|
957
1037
|
# don't silently borrow a nearby model's rate.
|
|
1038
|
+
when /^glm-5\.3-flash$/i
|
|
1039
|
+
"glm-5.3-flash"
|
|
958
1040
|
when /^glm-5\.3$/i
|
|
959
1041
|
"glm-5.3"
|
|
960
1042
|
when /^glm-5\.2$/i
|
|
@@ -978,10 +1060,12 @@ module Clacky
|
|
|
978
1060
|
"minimax-m2.5"
|
|
979
1061
|
|
|
980
1062
|
# Qwen (Alibaba DashScope) — strict anchored match per registered
|
|
981
|
-
# model id in providers.rb. qwen3.
|
|
982
|
-
# qwen3.6-* are
|
|
983
|
-
# rolling alias for the latest Qwen-Plus release; qwen3-vl-plus
|
|
984
|
-
# the multimodal SKU (replaces the retired qwen-vl-plus/max).
|
|
1063
|
+
# model id in providers.rb. qwen3.8-max is the latest flagship;
|
|
1064
|
+
# qwen3.7-* / qwen3.6-* are previous generations; qwen-plus-latest
|
|
1065
|
+
# is the rolling alias for the latest Qwen-Plus release; qwen3-vl-plus
|
|
1066
|
+
# is the multimodal SKU (replaces the retired qwen-vl-plus/max).
|
|
1067
|
+
when /^qwen3\.8-max$/i
|
|
1068
|
+
"qwen3.8-max"
|
|
985
1069
|
when /^qwen3\.7-max$/i
|
|
986
1070
|
"qwen3.7-max"
|
|
987
1071
|
when /^qwen3\.7-plus$/i
|
|
@@ -1007,6 +1091,10 @@ module Clacky
|
|
|
1007
1091
|
"gemini-3-flash"
|
|
1008
1092
|
when /^or-gemini-3-6-flash$/i, /^gemini-3\.6-flash$/i
|
|
1009
1093
|
"gemini-3.6-flash"
|
|
1094
|
+
when /^or-gemini-3-7-flash$/i, /^gemini-3\.7-flash$/i
|
|
1095
|
+
"gemini-3.7-flash"
|
|
1096
|
+
when /^or-gemini-3-8-flash$/i, /^gemini-3\.8-flash$/i
|
|
1097
|
+
"gemini-3.8-flash"
|
|
1010
1098
|
|
|
1011
1099
|
# OpenAI GPT-5.x models - match various dashed/dotted/compact forms
|
|
1012
1100
|
# (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
|
data/lib/clacky/version.rb
CHANGED