openclacky 1.5.13 → 1.5.15
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 +96 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +161 -4
- data/lib/clacky.rb +2 -0
- metadata +13 -1
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -34,7 +34,9 @@ module Clacky
|
|
|
34
34
|
Actions: open | navigate | snapshot | act | screenshot | tabs | focus | close | status
|
|
35
35
|
Workflow: open → snapshot(interactive:true) → act(ref=...). New tab from `open` is auto-selected; only use `focus` to switch back to a previously-opened tab.
|
|
36
36
|
snapshot: returns hierarchical a11y tree truncated to ~8KB. Use query="text" to seek, or offset=N to page.
|
|
37
|
-
act kinds: click | dblclick | type | fill | press | hover | scroll | drag | select | wait | evaluate | click_at
|
|
37
|
+
act kinds: click | dblclick | type | fill | fill_form | press | hover | scroll | drag | select | wait | evaluate | click_at
|
|
38
|
+
act auto-waits for network/DOM to settle and returns the post-action page state, so do NOT follow an act with a snapshot — read the result instead. Pass include_snapshot:false to skip it on trivial acts.
|
|
39
|
+
fill_form: ALWAYS prefer this over several individual fill/click calls on a form — one call, fewer turns. fill/type accept submit_key:"Enter" to submit in the same call.
|
|
38
40
|
evaluate: `js` is a JS expression, e.g. "document.title". For multi-line / async logic use an IIFE: "(async () => { const r = await fetch(...); return r.status })()". Result is JSON-encoded.
|
|
39
41
|
screenshot: expensive — pass `ref` to capture one element instead of the whole page.
|
|
40
42
|
DESC
|
|
@@ -48,11 +50,24 @@ module Clacky
|
|
|
48
50
|
},
|
|
49
51
|
kind: {
|
|
50
52
|
type: "string",
|
|
51
|
-
enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
|
|
53
|
+
enum: %w[click dblclick type fill fill_form press hover drag select scroll wait evaluate click_at],
|
|
52
54
|
description: "act: interaction kind"
|
|
53
55
|
},
|
|
54
56
|
ref: { type: "string", description: "element ref from snapshot (e.g. 'e1')" },
|
|
55
57
|
text: { type: "string", description: "act type/fill text" },
|
|
58
|
+
submit_key: { type: "string", description: "act fill/type: key to press after filling (e.g. 'Enter')" },
|
|
59
|
+
elements: {
|
|
60
|
+
type: "array",
|
|
61
|
+
description: "act fill_form: [{ref, value}, ...] — fill a whole form in one call",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
ref: { type: "string", description: "element ref from snapshot" },
|
|
66
|
+
value: { type: "string", description: "value; 'true'/'false' for checkboxes and toggles" }
|
|
67
|
+
},
|
|
68
|
+
required: ["ref", "value"]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
56
71
|
key: { type: "string", description: "act press key (e.g. 'Enter')" },
|
|
57
72
|
direction: { type: "string", enum: %w[up down left right], description: "act scroll" },
|
|
58
73
|
amount: { type: "integer", description: "act scroll pixels (default 300)" },
|
|
@@ -70,7 +85,11 @@ module Clacky
|
|
|
70
85
|
depth: { type: "integer", description: "snapshot: max tree depth" },
|
|
71
86
|
query: { type: "string", description: "snapshot: return window around first match" },
|
|
72
87
|
offset: { type: "integer", description: "snapshot: skip N lines (paging)" },
|
|
73
|
-
full_page: { type: "boolean", description: "screenshot: full page" }
|
|
88
|
+
full_page: { type: "boolean", description: "screenshot: full page" },
|
|
89
|
+
include_snapshot: {
|
|
90
|
+
type: "boolean",
|
|
91
|
+
description: "act: return the post-action page snapshot (default true). Set false for trivial acts."
|
|
92
|
+
}
|
|
74
93
|
},
|
|
75
94
|
required: ["action"]
|
|
76
95
|
}
|
|
@@ -90,6 +109,14 @@ module Clacky
|
|
|
90
109
|
"Target closed",
|
|
91
110
|
"page is detached"
|
|
92
111
|
].freeze
|
|
112
|
+
# chrome-devtools-mcp resolves refs against the last snapshot it took for a
|
|
113
|
+
# page, so a page that has never been snapshotted rejects every ref call.
|
|
114
|
+
MISSING_SNAPSHOT_ERROR = "No snapshot found for page"
|
|
115
|
+
# Raised when a ref exists in no/older snapshot — the AI needs fresh refs.
|
|
116
|
+
STALE_REF_ERRORS = [
|
|
117
|
+
"not found on page",
|
|
118
|
+
"no longer exists on the page"
|
|
119
|
+
].freeze
|
|
93
120
|
|
|
94
121
|
def execute(action:, profile: nil, working_dir: nil, **opts)
|
|
95
122
|
bypass = action.to_s == "status" ||
|
|
@@ -103,6 +130,23 @@ module Clacky
|
|
|
103
130
|
{ error: classify_browser_error(e) }
|
|
104
131
|
end
|
|
105
132
|
|
|
133
|
+
# Hidden once the user turns the browser off, so the model never sees an
|
|
134
|
+
# action it would only get rejected for. An unconfigured browser stays
|
|
135
|
+
# visible — that path guides the user into browser-setup.
|
|
136
|
+
#
|
|
137
|
+
# Checked once at registration: the tools array carries the prompt-cache
|
|
138
|
+
# breakpoint, so it must stay byte-identical for the whole process.
|
|
139
|
+
# Toggling the browser takes effect on the next restart.
|
|
140
|
+
def self.available?
|
|
141
|
+
return true unless File.exist?(BROWSER_CONFIG_PATH)
|
|
142
|
+
|
|
143
|
+
config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH),
|
|
144
|
+
permitted_classes: [Date, Time, Symbol])
|
|
145
|
+
config.is_a?(Hash) && config["enabled"] == true
|
|
146
|
+
rescue StandardError
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
106
150
|
def format_call(args)
|
|
107
151
|
action = args[:action] || args["action"] || "browser"
|
|
108
152
|
"browser(#{action})"
|
|
@@ -142,13 +186,14 @@ module Clacky
|
|
|
142
186
|
end
|
|
143
187
|
|
|
144
188
|
output = result[:output].to_s
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
has_snapshot = action == "snapshot" || result[:snapshot_included]
|
|
190
|
+
output = compress_snapshot(output) if has_snapshot
|
|
191
|
+
max_chars = has_snapshot ? MAX_SNAPSHOT_CHARS : MAX_LLM_OUTPUT_CHARS
|
|
147
192
|
|
|
148
193
|
{
|
|
149
194
|
action: action,
|
|
150
195
|
success: result[:success],
|
|
151
|
-
stdout: truncate_output(output, max_chars),
|
|
196
|
+
stdout: truncate_output(output, max_chars, paged: action == "snapshot"),
|
|
152
197
|
profile: result[:profile]
|
|
153
198
|
}.compact
|
|
154
199
|
end
|
|
@@ -302,6 +347,7 @@ module Clacky
|
|
|
302
347
|
private def do_user_act(opts)
|
|
303
348
|
kind = (opts[:kind] || opts["kind"] || "click").to_s
|
|
304
349
|
ref = opts[:ref] || opts["ref"]
|
|
350
|
+
snap = include_snapshot?(opts)
|
|
305
351
|
|
|
306
352
|
# Page-scoped MCP calls all benefit from an explicit pageId. We pass it
|
|
307
353
|
# transparently via with_page so the AI never has to think about it.
|
|
@@ -311,31 +357,48 @@ module Clacky
|
|
|
311
357
|
return uid if uid.is_a?(Hash)
|
|
312
358
|
args = { uid: uid }
|
|
313
359
|
args[:dblClick] = true if kind == "dblclick"
|
|
314
|
-
mcp_call("click", with_page(args))
|
|
360
|
+
return act_result(kind, mcp_call("click", with_page(with_snapshot(args, snap))), opts, snap)
|
|
315
361
|
|
|
316
362
|
when "fill", "type"
|
|
317
363
|
uid = require_ref(ref)
|
|
318
364
|
return uid if uid.is_a?(Hash)
|
|
319
|
-
|
|
365
|
+
submit = opts[:submit_key] || opts["submit_key"]
|
|
366
|
+
fill_args = { uid: uid, value: (opts[:text] || opts["text"] || "").to_s }
|
|
367
|
+
if submit && !submit.to_s.empty?
|
|
368
|
+
mcp_call("fill", with_page(fill_args))
|
|
369
|
+
result = mcp_call("press_key", with_page(with_snapshot({ key: submit.to_s }, snap)))
|
|
370
|
+
else
|
|
371
|
+
result = mcp_call("fill", with_page(with_snapshot(fill_args, snap)))
|
|
372
|
+
end
|
|
373
|
+
return act_result(kind, result, opts, snap)
|
|
374
|
+
|
|
375
|
+
when "fill_form"
|
|
376
|
+
elements = normalize_form_elements(opts[:elements] || opts["elements"])
|
|
377
|
+
return elements if elements.is_a?(Hash)
|
|
378
|
+
result = mcp_call("fill_form", with_page(with_snapshot({ elements: elements }, snap)))
|
|
379
|
+
return act_result(kind, result, opts, snap)
|
|
320
380
|
|
|
321
381
|
when "press"
|
|
322
|
-
|
|
382
|
+
args = { key: (opts[:key] || opts["key"] || "Enter").to_s }
|
|
383
|
+
return act_result(kind, mcp_call("press_key", with_page(with_snapshot(args, snap))), opts, snap)
|
|
323
384
|
|
|
324
385
|
when "hover"
|
|
325
386
|
uid = require_ref(ref)
|
|
326
387
|
return uid if uid.is_a?(Hash)
|
|
327
|
-
mcp_call("hover", with_page({ uid: uid }))
|
|
388
|
+
return act_result(kind, mcp_call("hover", with_page(with_snapshot({ uid: uid }, snap))), opts, snap)
|
|
328
389
|
|
|
329
390
|
when "drag"
|
|
330
391
|
uid = require_ref(ref)
|
|
331
392
|
return uid if uid.is_a?(Hash)
|
|
332
|
-
|
|
393
|
+
args = { from_uid: uid, to_uid: (opts[:target_ref] || opts["target_ref"] || "").to_s }
|
|
394
|
+
return act_result(kind, mcp_call("drag", with_page(with_snapshot(args, snap))), opts, snap)
|
|
333
395
|
|
|
334
396
|
when "select"
|
|
335
397
|
uid = require_ref(ref)
|
|
336
398
|
return uid if uid.is_a?(Hash)
|
|
337
399
|
values = Array(opts[:values] || opts["values"] || [])
|
|
338
|
-
|
|
400
|
+
args = { uid: uid, value: values.first.to_s }
|
|
401
|
+
return act_result(kind, mcp_call("fill", with_page(with_snapshot(args, snap))), opts, snap)
|
|
339
402
|
|
|
340
403
|
when "scroll"
|
|
341
404
|
direction = opts[:direction] || opts["direction"] || "down"
|
|
@@ -352,7 +415,8 @@ module Clacky
|
|
|
352
415
|
sleep(ms.to_i / 1000.0)
|
|
353
416
|
return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
|
|
354
417
|
elsif sel
|
|
355
|
-
|
|
418
|
+
# wait_for always attaches a snapshot server-side.
|
|
419
|
+
return act_result(kind, mcp_call("wait_for", with_page({ text: [sel] })), opts, true)
|
|
356
420
|
else
|
|
357
421
|
sleep(1)
|
|
358
422
|
end
|
|
@@ -367,8 +431,8 @@ module Clacky
|
|
|
367
431
|
x = opts[:x] || opts["x"]
|
|
368
432
|
y = opts[:y] || opts["y"]
|
|
369
433
|
return { error: "click_at requires x and y coordinates" } unless x && y
|
|
370
|
-
|
|
371
|
-
return
|
|
434
|
+
args = { x: x.to_f, y: y.to_f }
|
|
435
|
+
return act_result(kind, mcp_call("click_at", with_page(with_snapshot(args, snap))), opts, snap)
|
|
372
436
|
|
|
373
437
|
else
|
|
374
438
|
return { error: "Unknown act kind: #{kind}" }
|
|
@@ -377,6 +441,72 @@ module Clacky
|
|
|
377
441
|
{ action: "act", success: true, profile: "user", output: "#{kind} completed." }
|
|
378
442
|
end
|
|
379
443
|
|
|
444
|
+
# act defaults to returning the post-action snapshot: chrome-devtools-mcp
|
|
445
|
+
# renders it in the same response, so a follow-up snapshot call is waste.
|
|
446
|
+
private def include_snapshot?(opts)
|
|
447
|
+
val = opts.key?(:include_snapshot) ? opts[:include_snapshot] : opts["include_snapshot"]
|
|
448
|
+
return true if val.nil?
|
|
449
|
+
!(val == false || val.to_s == "false")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
private def with_snapshot(args, enabled)
|
|
453
|
+
enabled ? args.merge(includeSnapshot: true) : args
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
private def normalize_form_elements(raw)
|
|
457
|
+
list = Array(raw)
|
|
458
|
+
return { error: "fill_form requires a non-empty `elements` array of {ref, value}" } if list.empty?
|
|
459
|
+
|
|
460
|
+
normalized = list.map do |entry|
|
|
461
|
+
next nil unless entry.is_a?(Hash)
|
|
462
|
+
uid = entry[:ref] || entry["ref"] || entry[:uid] || entry["uid"]
|
|
463
|
+
value = entry[:value] || entry["value"] || entry[:text] || entry["text"]
|
|
464
|
+
next nil if uid.nil? || uid.to_s.empty?
|
|
465
|
+
{ uid: uid.to_s, value: value.to_s }
|
|
466
|
+
end.compact
|
|
467
|
+
|
|
468
|
+
return { error: "fill_form `elements` entries must each have a `ref`" } if normalized.empty?
|
|
469
|
+
normalized
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Render an act response: the MCP status line plus the post-action
|
|
473
|
+
# snapshot when one was attached.
|
|
474
|
+
private def act_result(kind, result, opts, snapshot_requested)
|
|
475
|
+
message = extract_act_message(result).strip
|
|
476
|
+
message = "#{kind} completed." if message.empty?
|
|
477
|
+
|
|
478
|
+
parts = [message]
|
|
479
|
+
included = false
|
|
480
|
+
|
|
481
|
+
if snapshot_requested
|
|
482
|
+
node = extract_snapshot(result)
|
|
483
|
+
if node.is_a?(Hash) && !node.empty?
|
|
484
|
+
text = build_ai_snapshot(node,
|
|
485
|
+
interactive: opts[:interactive] || opts["interactive"],
|
|
486
|
+
compact: opts[:compact] || opts["compact"],
|
|
487
|
+
max_depth: opts[:depth] || opts["depth"])
|
|
488
|
+
unless text.empty?
|
|
489
|
+
parts << "## Page snapshot after #{kind}\n#{text}"
|
|
490
|
+
included = true
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
{ action: "act", success: true, profile: "user",
|
|
496
|
+
output: parts.join("\n\n"), snapshot_included: included }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Like extract_message, but drops the MCP-rendered snapshot block from the
|
|
500
|
+
# text fallback so it is not duplicated alongside our own rendering.
|
|
501
|
+
private def extract_act_message(result)
|
|
502
|
+
return "" unless result.is_a?(Hash)
|
|
503
|
+
|
|
504
|
+
structured = result["structuredContent"]
|
|
505
|
+
return structured["message"].to_s if structured.is_a?(Hash) && structured["message"]
|
|
506
|
+
|
|
507
|
+
extract_text_content(result).split("## Latest page snapshot").first.to_s
|
|
508
|
+
end
|
|
509
|
+
|
|
380
510
|
# Merge pageId into MCP args when we know the current page. Safe no-op
|
|
381
511
|
# if the cache is empty — the MCP server then falls back to its own
|
|
382
512
|
# selected-page state, matching the old behaviour.
|
|
@@ -460,11 +590,19 @@ module Clacky
|
|
|
460
590
|
# -----------------------------------------------------------------------
|
|
461
591
|
|
|
462
592
|
# Delegate to BrowserManager. Auto-retries once on transient page-context errors
|
|
463
|
-
# (closed/detached selected page, or "no active page" right after open)
|
|
593
|
+
# (closed/detached selected page, or "no active page" right after open) and on
|
|
594
|
+
# a missing page snapshot (fresh tab that ref-based calls cannot resolve yet).
|
|
464
595
|
private def mcp_call(tool_name, arguments = {})
|
|
465
596
|
Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
|
|
466
597
|
rescue RuntimeError => e
|
|
467
598
|
msg = e.message.to_s
|
|
599
|
+
|
|
600
|
+
if msg.include?(MISSING_SNAPSHOT_ERROR)
|
|
601
|
+
# Prime the page snapshot the MCP server resolves refs against, then retry once.
|
|
602
|
+
return Clacky::BrowserManager.instance.mcp_call(tool_name, arguments) if prime_page_snapshot
|
|
603
|
+
raise RuntimeError, "The page has no snapshot yet. Use action=snapshot first, then retry."
|
|
604
|
+
end
|
|
605
|
+
|
|
468
606
|
if RETRYABLE_PAGE_ERRORS.any? { |frag| msg.include?(frag) }
|
|
469
607
|
# Try to recover by re-selecting the most recent page, then retry once.
|
|
470
608
|
recovered = recover_selected_page
|
|
@@ -474,9 +612,23 @@ module Clacky
|
|
|
474
612
|
end
|
|
475
613
|
raise RuntimeError, "The browser tab is no longer available. Use action=open to open a new tab, then retry."
|
|
476
614
|
end
|
|
615
|
+
|
|
616
|
+
if STALE_REF_ERRORS.any? { |frag| msg.include?(frag) }
|
|
617
|
+
raise RuntimeError, "#{msg} The page changed — use action=snapshot to get fresh refs, then retry."
|
|
618
|
+
end
|
|
619
|
+
|
|
477
620
|
raise
|
|
478
621
|
end
|
|
479
622
|
|
|
623
|
+
# Take a snapshot purely so the MCP server has refs to resolve against.
|
|
624
|
+
# Returns true when the page now has one.
|
|
625
|
+
private def prime_page_snapshot
|
|
626
|
+
Clacky::BrowserManager.instance.mcp_call("take_snapshot", with_page({}))
|
|
627
|
+
true
|
|
628
|
+
rescue StandardError
|
|
629
|
+
false
|
|
630
|
+
end
|
|
631
|
+
|
|
480
632
|
# Pick the currently selected page, or fall back to the most recent one,
|
|
481
633
|
# and re-issue select_page so subsequent calls have a valid context.
|
|
482
634
|
# Returns the chosen pageId, or nil if there are no pages at all.
|
|
@@ -762,7 +914,7 @@ module Clacky
|
|
|
762
914
|
collapsed.join
|
|
763
915
|
end
|
|
764
916
|
|
|
765
|
-
private def truncate_output(output, max_chars)
|
|
917
|
+
private def truncate_output(output, max_chars, paged: true)
|
|
766
918
|
return output if output.length <= max_chars
|
|
767
919
|
|
|
768
920
|
lines = output.lines
|
|
@@ -774,8 +926,13 @@ module Clacky
|
|
|
774
926
|
first_part << line
|
|
775
927
|
acc += line.length
|
|
776
928
|
end
|
|
777
|
-
hint =
|
|
778
|
-
|
|
929
|
+
hint = if paged
|
|
930
|
+
"\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
|
|
931
|
+
"rerun with query=\"keyword\" or offset=#{first_part.size} to see more] ...\n"
|
|
932
|
+
else
|
|
933
|
+
"\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
|
|
934
|
+
"use action=snapshot with query=\"keyword\" if you need the rest] ...\n"
|
|
935
|
+
end
|
|
779
936
|
first_part.join + hint
|
|
780
937
|
end
|
|
781
938
|
end
|
|
@@ -30,10 +30,14 @@ module Clacky
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
# Ordered list of search providers to try in sequence.
|
|
33
|
-
#
|
|
34
|
-
|
|
33
|
+
# Parallel is the primary no-key provider; the HTML scrapers remain as
|
|
34
|
+
# fallbacks, with cn.bing.com accessible in mainland China without VPN.
|
|
35
|
+
PROVIDERS = %i[parallel duckduckgo bing].freeze
|
|
35
36
|
|
|
36
37
|
SEARCHER_TIMEOUT = 30
|
|
38
|
+
PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp"
|
|
39
|
+
PARALLEL_SNIPPET_MAX_CHARS = 400
|
|
40
|
+
PROVIDER_COOLDOWN_SECONDS = 600
|
|
37
41
|
|
|
38
42
|
def execute(query:, max_results: 10, working_dir: nil)
|
|
39
43
|
if query.nil? || query.strip.empty?
|
|
@@ -57,8 +61,13 @@ module Clacky
|
|
|
57
61
|
error: nil
|
|
58
62
|
}
|
|
59
63
|
rescue StandardError => e
|
|
60
|
-
#
|
|
61
|
-
|
|
64
|
+
# Avoid paying the same network/handshake failure on every search.
|
|
65
|
+
if provider == :parallel
|
|
66
|
+
reset_parallel_client
|
|
67
|
+
@parallel_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
|
|
68
|
+
elsif provider == :duckduckgo
|
|
69
|
+
@ddg_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
|
|
70
|
+
end
|
|
62
71
|
last_error = e
|
|
63
72
|
next
|
|
64
73
|
end
|
|
@@ -93,11 +102,73 @@ module Clacky
|
|
|
93
102
|
# broken key or script look like a low-quality result set.
|
|
94
103
|
return [:custom] if Clacky::SearchConfig.script_path
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
PROVIDERS.reject do |provider|
|
|
106
|
+
unavailable_until = case provider
|
|
107
|
+
when :parallel then @parallel_unavailable_until
|
|
108
|
+
when :duckduckgo then @ddg_unavailable_until
|
|
109
|
+
end
|
|
110
|
+
unavailable_until && Time.now < unavailable_until
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ── Parallel Free MCP ──────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
private def search_parallel(query, max_results)
|
|
117
|
+
result = parallel_client.call_tool(
|
|
118
|
+
"web_search",
|
|
119
|
+
{
|
|
120
|
+
"objective" => query[0, 5000],
|
|
121
|
+
"search_queries" => [query[0, 200]],
|
|
122
|
+
"session_id" => parallel_logical_session_id
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if result["isError"]
|
|
127
|
+
message = extract_parallel_content_text(result)
|
|
128
|
+
raise(message.empty? ? "Parallel search failed" : message)
|
|
100
129
|
end
|
|
130
|
+
|
|
131
|
+
payload = result["structuredContent"]
|
|
132
|
+
payload ||= JSON.parse(extract_parallel_content_text(result))
|
|
133
|
+
raise "Parallel returned malformed search results" unless payload.is_a?(Hash) && payload["results"].is_a?(Array)
|
|
134
|
+
|
|
135
|
+
payload["results"].filter_map do |item|
|
|
136
|
+
next unless item.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
url = item["url"].to_s
|
|
139
|
+
next if url.empty?
|
|
140
|
+
|
|
141
|
+
snippet = Array(item["excerpts"]).join(" ").gsub(/\s+/, " ").strip
|
|
142
|
+
{
|
|
143
|
+
title: item["title"].to_s,
|
|
144
|
+
url: url,
|
|
145
|
+
snippet: snippet[0, PARALLEL_SNIPPET_MAX_CHARS]
|
|
146
|
+
}
|
|
147
|
+
end.first(max_results)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
raise "Parallel returned malformed JSON"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private def parallel_client
|
|
153
|
+
@parallel_client ||= Clacky::Mcp::Client.from_spec(
|
|
154
|
+
"parallel-free",
|
|
155
|
+
{ "type" => "http", "url" => PARALLEL_ENDPOINT }
|
|
156
|
+
).tap(&:start)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private def reset_parallel_client
|
|
160
|
+
@parallel_client&.stop
|
|
161
|
+
@parallel_client = nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private def parallel_logical_session_id
|
|
165
|
+
@parallel_logical_session_id ||= SecureRandom.uuid
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private def extract_parallel_content_text(result)
|
|
169
|
+
Array(result["content"]).filter_map do |item|
|
|
170
|
+
item["text"] if item.is_a?(Hash) && item["type"] == "text"
|
|
171
|
+
end.join("\n")
|
|
101
172
|
end
|
|
102
173
|
|
|
103
174
|
# ── Configured searcher (~/.clacky/searchers/<provider>.rb) ────────────
|
|
@@ -346,6 +417,18 @@ module Clacky
|
|
|
346
417
|
"[OK] Found #{count} results#{provider}"
|
|
347
418
|
end
|
|
348
419
|
end
|
|
420
|
+
|
|
421
|
+
def ui_result(result)
|
|
422
|
+
{
|
|
423
|
+
type: "web_search",
|
|
424
|
+
query: result[:query],
|
|
425
|
+
count: result[:count],
|
|
426
|
+
provider: result[:provider],
|
|
427
|
+
results: result[:results],
|
|
428
|
+
error: result[:error]
|
|
429
|
+
}
|
|
430
|
+
end
|
|
431
|
+
|
|
349
432
|
end
|
|
350
433
|
end
|
|
351
434
|
end
|
|
@@ -14,9 +14,11 @@ module Clacky
|
|
|
14
14
|
# System commands available by default
|
|
15
15
|
SYSTEM_COMMANDS = [
|
|
16
16
|
{ command: "/clear", description: "Clear chat history and restart session" },
|
|
17
|
+
{ command: "/input-mode", description: "Input behavior: steer or interrupt" },
|
|
17
18
|
{ command: "/config", description: "Open configuration (models, API keys, settings)" },
|
|
18
19
|
{ command: "/model", description: "Quickly switch the current model" },
|
|
19
|
-
{ command: "/
|
|
20
|
+
{ command: "/think", description: "Set the thinking (reasoning) effort level" },
|
|
21
|
+
{ command: "/goal", description: "Set a standing goal for autonomous multi-turn work", argument_hint: "<goal text>" },
|
|
20
22
|
{ command: "/undo", description: "Undo the last task and restore previous state" },
|
|
21
23
|
{ command: "/help", description: "Show help information" },
|
|
22
24
|
{ command: "/exit", description: "Exit the chat session" },
|
|
@@ -62,6 +62,7 @@ module Clacky
|
|
|
62
62
|
working_dir: nil,
|
|
63
63
|
mode: nil,
|
|
64
64
|
model: nil,
|
|
65
|
+
reasoning_effort: nil, # nil = provider default; appended to model name when set
|
|
65
66
|
tasks: 0,
|
|
66
67
|
cost: 0.0,
|
|
67
68
|
cost_source: nil, # nil / :api / :price / :default — :default means pricing unknown, show N/A
|
|
@@ -89,6 +90,10 @@ module Clacky
|
|
|
89
90
|
"#{theme.symbol(:user)} "
|
|
90
91
|
end
|
|
91
92
|
|
|
93
|
+
def guidance_lines
|
|
94
|
+
@guidance_lines_provider&.call || []
|
|
95
|
+
end
|
|
96
|
+
|
|
92
97
|
def required_height
|
|
93
98
|
# When paused (InlineInput active), don't take up any space
|
|
94
99
|
return 0 if @paused
|
|
@@ -103,6 +108,7 @@ module Clacky
|
|
|
103
108
|
|
|
104
109
|
# Images
|
|
105
110
|
height += @files.size
|
|
111
|
+
height += guidance_lines.size
|
|
106
112
|
|
|
107
113
|
# Calculate height considering wrapped lines
|
|
108
114
|
# Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
|
|
@@ -145,15 +151,20 @@ module Clacky
|
|
|
145
151
|
# @param working_dir [String] Working directory
|
|
146
152
|
# @param mode [String] Permission mode
|
|
147
153
|
# @param model [String] AI model name
|
|
154
|
+
# @param reasoning_effort [String, nil] Thinking level; nil (explicit) appends nothing,
|
|
155
|
+
# :_unset (default) leaves the previous value untouched
|
|
148
156
|
# @param tasks [Integer] Number of completed tasks
|
|
149
157
|
# @param cost [Float] Total cost
|
|
150
158
|
# @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
|
|
151
159
|
# @param status [String] Workspace status ('idle' or 'working')
|
|
152
|
-
|
|
160
|
+
attr_accessor :guidance_lines_provider
|
|
161
|
+
|
|
162
|
+
def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, reasoning_effort: :_unset, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
153
163
|
@sessionbar_info[:session_id] = session_id if session_id
|
|
154
164
|
@sessionbar_info[:working_dir] = working_dir if working_dir
|
|
155
165
|
@sessionbar_info[:mode] = mode if mode
|
|
156
166
|
@sessionbar_info[:model] = model if model
|
|
167
|
+
@sessionbar_info[:reasoning_effort] = reasoning_effort unless reasoning_effort == :_unset
|
|
157
168
|
@sessionbar_info[:tasks] = tasks if tasks
|
|
158
169
|
@sessionbar_info[:cost] = cost if cost
|
|
159
170
|
@sessionbar_info[:cost_source] = cost_source if cost_source
|
|
@@ -184,10 +195,23 @@ module Clacky
|
|
|
184
195
|
@command_suggestions.select_next
|
|
185
196
|
return { action: nil }
|
|
186
197
|
when :enter
|
|
187
|
-
# Accept selected command
|
|
198
|
+
# Accept selected command; submit immediately — unless the
|
|
199
|
+
# command takes arguments (argument_hint), in which case only
|
|
200
|
+
# complete the name so the user can type the arguments first
|
|
201
|
+
# (same behavior as Tab).
|
|
188
202
|
if @command_suggestions.has_suggestions?
|
|
189
203
|
selected = @command_suggestions.selected_command_text
|
|
190
204
|
if selected
|
|
205
|
+
hint = @command_suggestions.selected_argument_hint
|
|
206
|
+
if hint && !hint.empty?
|
|
207
|
+
completed = "#{selected} "
|
|
208
|
+
@lines = [completed]
|
|
209
|
+
@line_index = 0
|
|
210
|
+
@cursor_position = completed.length
|
|
211
|
+
@command_suggestions.hide
|
|
212
|
+
set_tips("Usage: #{selected} #{hint}", type: :info)
|
|
213
|
+
return { action: nil }
|
|
214
|
+
end
|
|
191
215
|
# Replace current input with selected command
|
|
192
216
|
@lines = [selected]
|
|
193
217
|
@line_index = 0
|
|
@@ -311,6 +335,12 @@ module Clacky
|
|
|
311
335
|
current_row += 1
|
|
312
336
|
end
|
|
313
337
|
|
|
338
|
+
guidance_lines.each do |line|
|
|
339
|
+
move_cursor(current_row, 0)
|
|
340
|
+
print_with_padding(@pastel.dim(line))
|
|
341
|
+
current_row += 1
|
|
342
|
+
end
|
|
343
|
+
|
|
314
344
|
# Input lines with auto-wrap support
|
|
315
345
|
current_row = render_input_lines(current_row)
|
|
316
346
|
|
|
@@ -348,7 +378,7 @@ module Clacky
|
|
|
348
378
|
|
|
349
379
|
def position_cursor(start_row)
|
|
350
380
|
# Calculate which wrapped line the cursor is on
|
|
351
|
-
cursor_row = start_row + 2 + @files.size # session_bar + separator + images
|
|
381
|
+
cursor_row = start_row + 2 + guidance_lines.size + @files.size # session_bar + separator + images
|
|
352
382
|
# Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
|
|
353
383
|
content_width = effective_content_width(@width)
|
|
354
384
|
|
|
@@ -420,7 +450,7 @@ module Clacky
|
|
|
420
450
|
# Clear tips from state and screen
|
|
421
451
|
@tips_message = nil
|
|
422
452
|
# Tips row: start_row + session_bar(1) + separator(1) + images + lines + separator(1)
|
|
423
|
-
tips_row = @last_render_row + 2 + @files.size + @lines.size + 1
|
|
453
|
+
tips_row = @last_render_row + 2 + guidance_lines.size + @files.size + @lines.size + 1
|
|
424
454
|
move_cursor(tips_row, 0)
|
|
425
455
|
clear_line
|
|
426
456
|
flush
|
|
@@ -1187,6 +1217,9 @@ module Clacky
|
|
|
1187
1217
|
parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
|
|
1188
1218
|
end
|
|
1189
1219
|
|
|
1220
|
+
# Thinking level (nil renders as off = provider default)
|
|
1221
|
+
parts << theme.format_text((@sessionbar_info[:reasoning_effort] || 'off').to_s, :statusbar_secondary)
|
|
1222
|
+
|
|
1190
1223
|
# Tasks count
|
|
1191
1224
|
parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
|
|
1192
1225
|
|
|
@@ -59,8 +59,18 @@ module Clacky
|
|
|
59
59
|
|
|
60
60
|
# Adjust height based on mode
|
|
61
61
|
if @mode == :menu
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# Choices marked sticky: true (e.g. /config action buttons) are
|
|
63
|
+
# always rendered below the scrollable area, so they never scroll
|
|
64
|
+
# out of view no matter how many models precede them.
|
|
65
|
+
@scroll_count = @choices.count { |c| !c[:sticky] }
|
|
66
|
+
@sticky_count = @choices.length - @scroll_count
|
|
67
|
+
@visible_items = [@scroll_count, 15].min
|
|
68
|
+
@height = @visible_items + @sticky_count + 4 # +4 for title, borders, and instructions
|
|
69
|
+
# Scroll window: draw_menu_choices only renders this slice, so a
|
|
70
|
+
# menu with more choices than fit (e.g. /config with many models)
|
|
71
|
+
# scrolls instead of painting over the bottom border.
|
|
72
|
+
@window_start = 0
|
|
73
|
+
clamp_scroll_window
|
|
64
74
|
else
|
|
65
75
|
# Form mode - adjust height based on number of fields
|
|
66
76
|
# Each field takes 2 rows (label + input)
|
|
@@ -369,11 +379,17 @@ module Clacky
|
|
|
369
379
|
end
|
|
370
380
|
end
|
|
371
381
|
|
|
372
|
-
# Draw menu choices
|
|
382
|
+
# Draw menu choices (only the visible scroll window, then sticky tail)
|
|
373
383
|
private def draw_menu_choices(start_row, start_col)
|
|
374
|
-
@
|
|
375
|
-
|
|
376
|
-
draw_menu_choice(
|
|
384
|
+
window_end = [@window_start + @visible_items, @scroll_count].min
|
|
385
|
+
(@window_start...window_end).each do |index|
|
|
386
|
+
draw_menu_choice(@choices[index], index, start_row + index - @window_start, start_col)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
sticky_row = start_row + (window_end - @window_start)
|
|
390
|
+
(@scroll_count...@choices.length).each do |index|
|
|
391
|
+
draw_menu_choice(@choices[index], index, sticky_row, start_col)
|
|
392
|
+
sticky_row += 1
|
|
377
393
|
end
|
|
378
394
|
end
|
|
379
395
|
|
|
@@ -438,6 +454,19 @@ module Clacky
|
|
|
438
454
|
# Skip disabled choices
|
|
439
455
|
break unless @choices[@selected_index][:disabled]
|
|
440
456
|
end
|
|
457
|
+
clamp_scroll_window
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Keep the cursor inside the visible window, scrolling when needed.
|
|
461
|
+
# Sticky choices are always on screen, so they never move the window.
|
|
462
|
+
private def clamp_scroll_window
|
|
463
|
+
return if @selected_index >= @scroll_count
|
|
464
|
+
|
|
465
|
+
if @selected_index < @window_start
|
|
466
|
+
@window_start = @selected_index
|
|
467
|
+
elsif @selected_index >= @window_start + @visible_items
|
|
468
|
+
@window_start = @selected_index - @visible_items + 1
|
|
469
|
+
end
|
|
441
470
|
end
|
|
442
471
|
end
|
|
443
472
|
end
|