openclacky 0.9.35 → 0.9.37
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 +25 -0
- data/lib/clacky/agent/session_serializer.rb +6 -1
- data/lib/clacky/agent/skill_manager.rb +20 -8
- data/lib/clacky/agent/tool_executor.rb +1 -0
- data/lib/clacky/agent.rb +22 -17
- data/lib/clacky/agent_config.rb +166 -40
- data/lib/clacky/cli.rb +32 -13
- data/lib/clacky/message_history.rb +43 -2
- data/lib/clacky/providers.rb +21 -0
- data/lib/clacky/server/http_server.rb +208 -83
- data/lib/clacky/server/session_registry.rb +32 -5
- data/lib/clacky/tools/edit.rb +11 -1
- data/lib/clacky/tools/file_reader.rb +19 -3
- data/lib/clacky/tools/glob.rb +1 -0
- data/lib/clacky/tools/grep.rb +12 -3
- data/lib/clacky/tools/terminal.rb +154 -15
- data/lib/clacky/ui2/ui_controller.rb +16 -7
- data/lib/clacky/utils/model_pricing.rb +45 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +75 -149
- data/lib/clacky/web/app.js +23 -17
- data/lib/clacky/web/i18n.js +10 -0
- data/lib/clacky/web/index.html +8 -18
- data/lib/clacky/web/sessions.js +92 -57
- metadata +1 -1
|
@@ -147,6 +147,71 @@ module Clacky
|
|
|
147
147
|
Clacky::Tools::Security.command_safe_for_auto_execution?(command)
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# ---------------------------------------------------------------------
|
|
151
|
+
# Internal Ruby API — synchronous capture
|
|
152
|
+
# ---------------------------------------------------------------------
|
|
153
|
+
#
|
|
154
|
+
# Run a shell command and BLOCK until it terminates, returning
|
|
155
|
+
# [output, exit_code]. Drop-in replacement for Open3.capture2e that
|
|
156
|
+
# goes through the same PTY + login-shell + Security pipeline used by
|
|
157
|
+
# the AI-facing tool (so rbenv/mise shims and gem mirrors work).
|
|
158
|
+
#
|
|
159
|
+
# Why this exists separately from #execute:
|
|
160
|
+
#
|
|
161
|
+
# `execute` may return early with a :session_id the moment output
|
|
162
|
+
# goes idle for DEFAULT_IDLE_MS (3s) — this is intentional for AI
|
|
163
|
+
# agents (they can inspect progress, inject input, decide to kill).
|
|
164
|
+
# Ruby callers like the HTTP server's upgrade flow only care about
|
|
165
|
+
# "did it finish, with what output, what exit code" — they need
|
|
166
|
+
# synchronous semantics. Previously each caller re-implemented the
|
|
167
|
+
# poll loop (and 0.9.36's run_shell forgot to, causing the upgrade
|
|
168
|
+
# failure bug).
|
|
169
|
+
#
|
|
170
|
+
# NOT exposed in tool_parameters — AI agents cannot invoke this.
|
|
171
|
+
#
|
|
172
|
+
# @param command [String] the shell command to run
|
|
173
|
+
# @param timeout [Integer] per-poll timeout AND the basis for the
|
|
174
|
+
# overall deadline (deadline = timeout + 60s)
|
|
175
|
+
# @param cwd [String] optional working directory
|
|
176
|
+
# @param env [Hash] optional env overrides
|
|
177
|
+
# @return [Array(String, Integer|nil)] [output, exit_code].
|
|
178
|
+
# exit_code is nil only if the overall deadline was hit and
|
|
179
|
+
# the session had to be force-killed.
|
|
180
|
+
def self.run_sync(command, timeout: 120, cwd: nil, env: nil)
|
|
181
|
+
terminal = new
|
|
182
|
+
result = terminal.execute(
|
|
183
|
+
command: command,
|
|
184
|
+
timeout: timeout,
|
|
185
|
+
cwd: cwd,
|
|
186
|
+
env: env,
|
|
187
|
+
)
|
|
188
|
+
output = result[:output].to_s
|
|
189
|
+
|
|
190
|
+
# Hard deadline in wall-clock terms — a genuinely stuck command
|
|
191
|
+
# must terminate. Each individual poll still carries `timeout`.
|
|
192
|
+
deadline = Time.now + timeout.to_i + 60
|
|
193
|
+
|
|
194
|
+
while result[:exit_code].nil? && result[:session_id] && Time.now < deadline
|
|
195
|
+
result = terminal.execute(
|
|
196
|
+
session_id: result[:session_id],
|
|
197
|
+
input: "",
|
|
198
|
+
timeout: timeout,
|
|
199
|
+
)
|
|
200
|
+
output += result[:output].to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Deadline exceeded — best-effort cleanup so the session doesn't leak.
|
|
204
|
+
if result[:exit_code].nil? && result[:session_id]
|
|
205
|
+
begin
|
|
206
|
+
terminal.execute(session_id: result[:session_id], kill: true)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
# swallow — cleanup is best-effort
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
[output, result[:exit_code]]
|
|
213
|
+
end
|
|
214
|
+
|
|
150
215
|
# ---------------------------------------------------------------------
|
|
151
216
|
# 1) Start a new command
|
|
152
217
|
# ---------------------------------------------------------------------
|
|
@@ -274,7 +339,7 @@ module Clacky
|
|
|
274
339
|
raw = read_log_slice(session.log_file, start_offset, new_offset)
|
|
275
340
|
cleaned = OutputCleaner.clean(raw)
|
|
276
341
|
cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
|
|
277
|
-
cleaned = strip_command_echo(cleaned)
|
|
342
|
+
cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
|
|
278
343
|
truncated = false
|
|
279
344
|
if cleaned.bytesize > MAX_LLM_OUTPUT_CHARS
|
|
280
345
|
cleaned = cleaned.byteslice(0, MAX_LLM_OUTPUT_CHARS) + "\n...[output truncated]"
|
|
@@ -336,23 +401,97 @@ module Clacky
|
|
|
336
401
|
# The shell may echo the wrapper line we injected (`{ USER_CMD; }; ...;
|
|
337
402
|
# printf "__CLACKY_DONE_..."`) before running it. When stty -echo is
|
|
338
403
|
# honoured (bash/fresh pty) this is a no-op; when it isn't (zsh ZLE
|
|
339
|
-
# sometimes re-enables echo on reuse
|
|
404
|
+
# sometimes re-enables echo on reuse, or the user sent input to a
|
|
405
|
+
# running session) we strip the wrapper echo wherever it appears.
|
|
406
|
+
#
|
|
407
|
+
# Observed variants of the echoed wrapper:
|
|
408
|
+
#
|
|
409
|
+
# 1) Multi-line, starting the buffer (PTY in cooked mode, expanded
|
|
410
|
+
# \n escapes inside printf's double-quoted format string):
|
|
411
|
+
# { USER_CMD
|
|
412
|
+
# }; __clacky_ec=$?; printf "
|
|
413
|
+
# __CLACKY_DONE_<token>_%s__
|
|
414
|
+
# " "$__clacky_ec"
|
|
415
|
+
#
|
|
416
|
+
# 2) Single-line / partially-truncated (PTY width wrap or partial
|
|
417
|
+
# char drop ate the leading `{` or first chars of the command):
|
|
418
|
+
# ails runner foo.rb ... }; __clacky_ec=$?; printf " __CLACKY_DONE_<token>_%s__ " "$__clacky_ec"
|
|
340
419
|
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
|
|
420
|
+
# 3) Embedded mid-stream when re-echoed (e.g. after session re-use
|
|
421
|
+
# or after a user input: call landed in a shell that re-enabled
|
|
422
|
+
# echo). Same shape as (1) or (2) but not anchored to the start.
|
|
423
|
+
#
|
|
424
|
+
# We handle all three by running two passes:
|
|
425
|
+
# * an anchored multi-line strip (keeps the legacy behaviour and is
|
|
426
|
+
# cheapest when stty -echo silently failed);
|
|
427
|
+
# * a token-aware global strip that removes any remaining echoed
|
|
428
|
+
# wrapper fragment anywhere in the buffer. The token makes this
|
|
429
|
+
# safe: the real completion marker was already removed via
|
|
430
|
+
# session.marker_regex above, so any surviving occurrence of
|
|
431
|
+
# __CLACKY_DONE_<token>_ is by definition an echoed wrapper.
|
|
432
|
+
private def strip_command_echo(text, marker_token: nil)
|
|
347
433
|
return text if text.nil? || text.empty?
|
|
348
|
-
|
|
349
|
-
#
|
|
350
|
-
#
|
|
351
|
-
# }; __clacky_ec=$?; printf "
|
|
352
|
-
# __CLACKY_DONE_<token>_%s__
|
|
353
|
-
# " "$__clacky_ec"
|
|
354
|
-
# Anchored at the start; non-greedy across newlines via /m.
|
|
434
|
+
|
|
435
|
+
# Pass 1: anchored strip — the full wrapper echoed at the start,
|
|
436
|
+
# possibly spanning multiple real newlines.
|
|
355
437
|
text = text.sub(/\A\{.*?"\$__clacky_ec"\s*\n?/m, "")
|
|
438
|
+
|
|
439
|
+
# Pass 2: token-aware global strip — remove any leftover wrapper
|
|
440
|
+
# echo fragment, wherever it sits. Requires the session token so
|
|
441
|
+
# we never touch unrelated user output that happens to mention
|
|
442
|
+
# `__clacky_ec`.
|
|
443
|
+
if marker_token && !marker_token.empty?
|
|
444
|
+
token_re = Regexp.escape(marker_token)
|
|
445
|
+
|
|
446
|
+
# 2a. Multi-line shape: walk back from __CLACKY_DONE_<token> to
|
|
447
|
+
# the opening `{` of the wrapper (start of line or start of
|
|
448
|
+
# buffer) and forward to the closing `"$__clacky_ec"`.
|
|
449
|
+
text = text.gsub(
|
|
450
|
+
/(?:^|(?<=\n))\{[^\n]*\n(?:[^\n]*\n)*?[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*\n[^\n]*"\$__clacky_ec"[^\n]*\n?/,
|
|
451
|
+
""
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# 2b. Single-line shape: everything collapsed onto one line.
|
|
455
|
+
# Strip from the wrapper's `}; __clacky_ec=$?` pivot (or the
|
|
456
|
+
# opening `{` if still present on that line) through the end of
|
|
457
|
+
# the printf invocation (`"$__clacky_ec"`).
|
|
458
|
+
text = text.gsub(
|
|
459
|
+
/[^\n]*\}; *__clacky_ec=\$\?; *printf[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*"\$__clacky_ec"[^\n]*\n?/,
|
|
460
|
+
""
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# 2c. Last-resort: a bare marker-format fragment on its own,
|
|
464
|
+
# without the `}; printf ...` prefix (e.g. terminal wrapped the
|
|
465
|
+
# echo such that only the tail survived). Drop lines that
|
|
466
|
+
# contain the literal `__CLACKY_DONE_<token>_%s__` format —
|
|
467
|
+
# the real marker has `\d+` in place of `%s` so this only hits
|
|
468
|
+
# echoed wrappers.
|
|
469
|
+
text = text.gsub(/^.*__CLACKY_DONE_#{token_re}_%s__.*\n?/, "")
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Pass 3: token-INDEPENDENT fingerprint strip — PTY width-wrap
|
|
473
|
+
# can chop the `__CLACKY_DONE_<token>_%s__` format string out of
|
|
474
|
+
# printf entirely, leaving e.g. `}; __clacky_ec=$?; printf " " "$__clacky_ec"`.
|
|
475
|
+
# None of the token-aware patterns above catch that. The pair
|
|
476
|
+
# `}; __clacky_ec=$?` (opening pivot) and `"$__clacky_ec"` (printf
|
|
477
|
+
# tail) are our wrapper's unique fingerprints — `__clacky_ec` is a
|
|
478
|
+
# private double-underscore var name that user code effectively
|
|
479
|
+
# never emits — so we strip anything between them (non-greedy,
|
|
480
|
+
# multiline-aware) to also handle width-wrap that inserted
|
|
481
|
+
# real \n breaks inside the echo.
|
|
482
|
+
text = text.gsub(
|
|
483
|
+
/[^\n]*\}; *__clacky_ec=\$\?.*?"\$__clacky_ec"[^\n]*\n?/m,
|
|
484
|
+
""
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Pass 4: bare pivot with no printf tail at all (extreme
|
|
488
|
+
# truncation cut off everything after `__clacky_ec=$?`). Still a
|
|
489
|
+
# reliable fingerprint thanks to the `__clacky_ec` var name.
|
|
490
|
+
text = text.gsub(
|
|
491
|
+
/[^\n]*\}; *__clacky_ec=\$\?;?[^\n]*\n?/,
|
|
492
|
+
""
|
|
493
|
+
)
|
|
494
|
+
|
|
356
495
|
text
|
|
357
496
|
end
|
|
358
497
|
|
|
@@ -1188,7 +1188,7 @@ module Clacky
|
|
|
1188
1188
|
display_name = "#{type_badge}#{model_name} (#{masked_key})"
|
|
1189
1189
|
choices << {
|
|
1190
1190
|
name: display_name,
|
|
1191
|
-
value: { action: :switch,
|
|
1191
|
+
value: { action: :switch, model_id: model["id"] }
|
|
1192
1192
|
}
|
|
1193
1193
|
end
|
|
1194
1194
|
|
|
@@ -1210,8 +1210,15 @@ module Clacky
|
|
|
1210
1210
|
|
|
1211
1211
|
case result[:action]
|
|
1212
1212
|
when :switch
|
|
1213
|
-
|
|
1214
|
-
#
|
|
1213
|
+
# CLI is a single-session context: when the user picks a model
|
|
1214
|
+
# we treat it as "make this my default from now on". So:
|
|
1215
|
+
# 1. switch this session's current model
|
|
1216
|
+
# 2. move the global `type: "default"` marker to it
|
|
1217
|
+
# 3. persist to config.yml so next CLI launch uses it
|
|
1218
|
+
# (Web UI's per-session switch is different — it must NOT do
|
|
1219
|
+
# steps 2 and 3, and uses switch_model_by_id directly.)
|
|
1220
|
+
current_config.switch_model_by_id(result[:model_id])
|
|
1221
|
+
current_config.set_default_model_by_id(result[:model_id])
|
|
1215
1222
|
current_config.save
|
|
1216
1223
|
# Return to indicate config changed (need to update client)
|
|
1217
1224
|
return { action: :switch }
|
|
@@ -1228,10 +1235,12 @@ module Clacky
|
|
|
1228
1235
|
base_url: new_model[:base_url],
|
|
1229
1236
|
anthropic_format: anthropic_format
|
|
1230
1237
|
)
|
|
1231
|
-
#
|
|
1232
|
-
|
|
1233
|
-
#
|
|
1234
|
-
current_config.
|
|
1238
|
+
# CLI: adding a model implies the user wants to use it now and
|
|
1239
|
+
# next launch. Switch this session to it AND set it as the
|
|
1240
|
+
# global default, then persist.
|
|
1241
|
+
new_id = current_config.models.last["id"]
|
|
1242
|
+
current_config.switch_model_by_id(new_id)
|
|
1243
|
+
current_config.set_default_model_by_id(new_id)
|
|
1235
1244
|
current_config.save
|
|
1236
1245
|
# Return to exit the menu
|
|
1237
1246
|
return { action: :switch }
|
|
@@ -105,6 +105,42 @@ module Clacky
|
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
|
|
108
|
+
# DeepSeek V4 models
|
|
109
|
+
# Source: https://api-docs.deepseek.com/quick_start/pricing (USD / 1M tokens)
|
|
110
|
+
# DeepSeek billing model:
|
|
111
|
+
# - "cache miss input" = regular prompt_tokens rate
|
|
112
|
+
# - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
|
|
113
|
+
# - No tiered pricing (single rate regardless of context length)
|
|
114
|
+
"deepseek-v4-flash" => {
|
|
115
|
+
input: {
|
|
116
|
+
default: 0.14, # $0.14/MTok cache miss
|
|
117
|
+
over_200k: 0.14 # no tiered pricing
|
|
118
|
+
},
|
|
119
|
+
output: {
|
|
120
|
+
default: 0.28, # $0.28/MTok
|
|
121
|
+
over_200k: 0.28
|
|
122
|
+
},
|
|
123
|
+
cache: {
|
|
124
|
+
write: 0.14, # DeepSeek doesn't charge extra for writes; bill at miss rate
|
|
125
|
+
read: 0.028 # $0.028/MTok cache hit
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
"deepseek-v4-pro" => {
|
|
130
|
+
input: {
|
|
131
|
+
default: 1.74, # $1.74/MTok cache miss
|
|
132
|
+
over_200k: 1.74
|
|
133
|
+
},
|
|
134
|
+
output: {
|
|
135
|
+
default: 3.48, # $3.48/MTok
|
|
136
|
+
over_200k: 3.48
|
|
137
|
+
},
|
|
138
|
+
cache: {
|
|
139
|
+
write: 1.74, # no separate write charge; bill at miss rate
|
|
140
|
+
read: 0.145 # $0.145/MTok cache hit
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
108
144
|
# Default fallback pricing (conservative estimates)
|
|
109
145
|
"default" => {
|
|
110
146
|
input: {
|
|
@@ -238,6 +274,15 @@ module Clacky
|
|
|
238
274
|
"claude-3-5-sonnet-20240620"
|
|
239
275
|
when /claude-3-5-haiku-20241022/i
|
|
240
276
|
"claude-3-5-haiku-20241022"
|
|
277
|
+
when /deepseek-v4-pro/i, /deepseek.*v4.*pro/i
|
|
278
|
+
"deepseek-v4-pro"
|
|
279
|
+
when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
|
|
280
|
+
"deepseek-v4-flash"
|
|
281
|
+
# Legacy aliases: deepseek-chat and deepseek-reasoner are being
|
|
282
|
+
# deprecated on 2026-07-24 and map to deepseek-v4-flash's
|
|
283
|
+
# non-thinking / thinking modes respectively. Bill at flash rates.
|
|
284
|
+
when /^deepseek-chat$/i, /^deepseek-reasoner$/i
|
|
285
|
+
"deepseek-v4-flash"
|
|
241
286
|
else
|
|
242
287
|
"default"
|
|
243
288
|
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -1359,144 +1359,36 @@ body {
|
|
|
1359
1359
|
|
|
1360
1360
|
/* ── Chat panel ──────────────────────────────────────────────────────────── */
|
|
1361
1361
|
#chat-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
|
|
1362
|
-
#chat-header {
|
|
1363
|
-
padding: 10px 20px;
|
|
1364
|
-
display: flex;
|
|
1365
|
-
align-items: center;
|
|
1366
|
-
gap: 10px;
|
|
1367
|
-
min-height: 44px;
|
|
1368
|
-
}
|
|
1369
|
-
/* Title block — stacks the name + subtitle vertically so the header has
|
|
1370
|
-
two lines of information (like Linear / Arc) without growing wider. */
|
|
1371
|
-
#chat-title-block {
|
|
1372
|
-
display: flex;
|
|
1373
|
-
flex-direction: column;
|
|
1374
|
-
min-width: 0; /* allow ellipsis inside */
|
|
1375
|
-
flex: 1;
|
|
1376
|
-
line-height: 1.25;
|
|
1377
|
-
gap: 2px;
|
|
1378
|
-
}
|
|
1379
|
-
#chat-title {
|
|
1380
|
-
font-weight: 600;
|
|
1381
|
-
font-size: 15px;
|
|
1382
|
-
color: var(--color-text-primary);
|
|
1383
|
-
white-space: nowrap;
|
|
1384
|
-
overflow: hidden;
|
|
1385
|
-
text-overflow: ellipsis;
|
|
1386
|
-
}
|
|
1387
|
-
#chat-subtitle {
|
|
1388
|
-
display: flex;
|
|
1389
|
-
align-items: center;
|
|
1390
|
-
gap: 6px;
|
|
1391
|
-
font-size: 11px;
|
|
1392
|
-
color: var(--color-text-secondary);
|
|
1393
|
-
opacity: 0.75;
|
|
1394
|
-
overflow: hidden;
|
|
1395
|
-
white-space: nowrap;
|
|
1396
|
-
text-overflow: ellipsis;
|
|
1397
|
-
min-width: 0;
|
|
1398
|
-
}
|
|
1399
|
-
#chat-subtitle .chat-sub-source {
|
|
1400
|
-
font-weight: 500;
|
|
1401
|
-
color: var(--color-text-secondary);
|
|
1402
|
-
flex-shrink: 0;
|
|
1403
|
-
}
|
|
1404
|
-
#chat-subtitle .chat-sub-dir {
|
|
1405
|
-
font-family: var(--font-mono, monospace);
|
|
1406
|
-
overflow: hidden;
|
|
1407
|
-
text-overflow: ellipsis;
|
|
1408
|
-
min-width: 0;
|
|
1409
|
-
opacity: 0.85;
|
|
1410
|
-
}
|
|
1411
|
-
#chat-subtitle .chat-sub-sep {
|
|
1412
|
-
opacity: 0.35;
|
|
1413
|
-
flex-shrink: 0;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
/* Status badge styles - softed design */
|
|
1417
|
-
.status-idle,
|
|
1418
|
-
.status-running,
|
|
1419
|
-
.status-error {
|
|
1420
|
-
display: inline-flex;
|
|
1421
|
-
align-items: center;
|
|
1422
|
-
padding: 2px 8px;
|
|
1423
|
-
border-radius: 4px;
|
|
1424
|
-
font-size: 11px;
|
|
1425
|
-
font-weight: 500;
|
|
1426
|
-
text-transform: lowercase;
|
|
1427
|
-
}
|
|
1428
1362
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
border: 1px solid var(--color-border-secondary);
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
[data-theme="light"] .status-idle {
|
|
1436
|
-
background: rgba(0, 0, 0, 0.04);
|
|
1437
|
-
color: var(--color-text-secondary);
|
|
1438
|
-
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
.status-running {
|
|
1442
|
-
background: rgba(34, 197, 94, 0.1);
|
|
1443
|
-
color: var(--color-success);
|
|
1444
|
-
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
[data-theme="light"] .status-running {
|
|
1448
|
-
background: rgba(34, 197, 94, 0.08);
|
|
1449
|
-
color: #16a34a;
|
|
1450
|
-
border: 1px solid rgba(34, 197, 94, 0.15);
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
.status-error {
|
|
1454
|
-
background: rgba(239, 68, 68, 0.1);
|
|
1455
|
-
color: var(--color-error);
|
|
1456
|
-
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
[data-theme="light"] .status-error {
|
|
1460
|
-
background: rgba(239, 68, 68, 0.08);
|
|
1461
|
-
color: #dc2626;
|
|
1462
|
-
border: 1px solid rgba(239, 68, 68, 0.15);
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
/* Delete session button in chat header */
|
|
1466
|
-
/* Back button in chat header — hidden on desktop, shown on mobile via media query */
|
|
1363
|
+
/* Mobile-only floating back button — replaces the old in-header back button.
|
|
1364
|
+
Hidden on desktop; mobile media query enables it. Positioned absolutely so
|
|
1365
|
+
it doesn't take layout space or add visual chrome on desktop. */
|
|
1467
1366
|
.chat-back-btn {
|
|
1468
1367
|
display: none;
|
|
1368
|
+
}
|
|
1369
|
+
.chat-back-floating {
|
|
1370
|
+
position: absolute;
|
|
1371
|
+
top: 8px;
|
|
1372
|
+
left: 8px;
|
|
1373
|
+
z-index: 5;
|
|
1469
1374
|
align-items: center;
|
|
1470
1375
|
justify-content: center;
|
|
1471
1376
|
width: 32px;
|
|
1472
1377
|
height: 32px;
|
|
1473
|
-
background:
|
|
1474
|
-
border:
|
|
1378
|
+
background: var(--color-bg-primary);
|
|
1379
|
+
border: 1px solid var(--color-border-secondary);
|
|
1475
1380
|
border-radius: 6px;
|
|
1476
1381
|
color: var(--color-text-secondary);
|
|
1477
1382
|
cursor: pointer;
|
|
1478
1383
|
padding: 0;
|
|
1479
|
-
flex-shrink: 0;
|
|
1480
1384
|
transition: background .15s, color .15s;
|
|
1385
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
1481
1386
|
}
|
|
1482
|
-
.chat-back-
|
|
1387
|
+
.chat-back-floating:hover { background: var(--color-bg-hover); }
|
|
1483
1388
|
|
|
1484
|
-
.
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
align-items: center;
|
|
1488
|
-
justify-content: center;
|
|
1489
|
-
width: 28px;
|
|
1490
|
-
height: 28px;
|
|
1491
|
-
background: transparent;
|
|
1492
|
-
border: none;
|
|
1493
|
-
border-radius: 6px;
|
|
1494
|
-
color: var(--color-text-tertiary);
|
|
1495
|
-
cursor: pointer;
|
|
1496
|
-
padding: 0;
|
|
1497
|
-
transition: background .15s, color .15s;
|
|
1498
|
-
}
|
|
1499
|
-
.btn-delete-session:hover { background: var(--color-error-bg); color: var(--color-error); }
|
|
1389
|
+
/* Status badges (.status-idle/running/error) used to live in #chat-header.
|
|
1390
|
+
That header has been removed; the bottom #session-info-bar uses its own
|
|
1391
|
+
.sib-status-* classes instead. Legacy .status-* styles removed. */
|
|
1500
1392
|
|
|
1501
1393
|
/* ── Messages ────────────────────────────────────────────────────────────── */
|
|
1502
1394
|
#messages {
|
|
@@ -1508,6 +1400,61 @@ body {
|
|
|
1508
1400
|
gap: 12px;
|
|
1509
1401
|
}
|
|
1510
1402
|
|
|
1403
|
+
/* Empty-state hint: shown inside #messages when a session has no messages yet.
|
|
1404
|
+
Designed to be quiet and low-contrast so it guides without distracting. */
|
|
1405
|
+
.chat-empty-hint {
|
|
1406
|
+
margin: auto; /* vertical + horizontal centering inside flex column */
|
|
1407
|
+
max-width: 420px;
|
|
1408
|
+
padding: 24px 16px;
|
|
1409
|
+
display: flex;
|
|
1410
|
+
flex-direction: column;
|
|
1411
|
+
align-items: center;
|
|
1412
|
+
text-align: center;
|
|
1413
|
+
color: var(--color-text-secondary);
|
|
1414
|
+
user-select: none;
|
|
1415
|
+
pointer-events: none; /* purely decorative — don't block clicks/scroll */
|
|
1416
|
+
opacity: 0.85;
|
|
1417
|
+
}
|
|
1418
|
+
.chat-empty-hint .chat-empty-icon {
|
|
1419
|
+
color: var(--color-text-tertiary);
|
|
1420
|
+
margin-bottom: 14px;
|
|
1421
|
+
opacity: 0.55;
|
|
1422
|
+
}
|
|
1423
|
+
.chat-empty-hint .chat-empty-title {
|
|
1424
|
+
color: var(--color-text-primary);
|
|
1425
|
+
font-size: 15px;
|
|
1426
|
+
font-weight: 600;
|
|
1427
|
+
margin-bottom: 4px;
|
|
1428
|
+
}
|
|
1429
|
+
.chat-empty-hint .chat-empty-subtitle {
|
|
1430
|
+
color: var(--color-text-secondary);
|
|
1431
|
+
font-size: 13px;
|
|
1432
|
+
margin-bottom: 18px;
|
|
1433
|
+
opacity: 0.85;
|
|
1434
|
+
}
|
|
1435
|
+
.chat-empty-hint .chat-empty-tips {
|
|
1436
|
+
list-style: none;
|
|
1437
|
+
padding: 0;
|
|
1438
|
+
margin: 0;
|
|
1439
|
+
display: flex;
|
|
1440
|
+
flex-direction: column;
|
|
1441
|
+
gap: 6px;
|
|
1442
|
+
font-size: 12px;
|
|
1443
|
+
color: var(--color-text-tertiary);
|
|
1444
|
+
}
|
|
1445
|
+
.chat-empty-hint .chat-empty-tips li {
|
|
1446
|
+
position: relative;
|
|
1447
|
+
padding-left: 14px;
|
|
1448
|
+
line-height: 1.5;
|
|
1449
|
+
}
|
|
1450
|
+
.chat-empty-hint .chat-empty-tips li::before {
|
|
1451
|
+
content: "·";
|
|
1452
|
+
position: absolute;
|
|
1453
|
+
left: 4px;
|
|
1454
|
+
top: -1px;
|
|
1455
|
+
opacity: 0.6;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1511
1458
|
/* New message notification banner */
|
|
1512
1459
|
.new-message-banner {
|
|
1513
1460
|
position: absolute;
|
|
@@ -5970,31 +5917,6 @@ body.setup-mode[data-theme="dark"] {
|
|
|
5970
5917
|
padding: 0 12px;
|
|
5971
5918
|
}
|
|
5972
5919
|
|
|
5973
|
-
/* Chat sub-header: tighten padding */
|
|
5974
|
-
#chat-header {
|
|
5975
|
-
padding: 8px 12px;
|
|
5976
|
-
gap: 8px;
|
|
5977
|
-
}
|
|
5978
|
-
|
|
5979
|
-
/* Truncate long session titles */
|
|
5980
|
-
#chat-title {
|
|
5981
|
-
font-size: 14px;
|
|
5982
|
-
overflow: hidden;
|
|
5983
|
-
text-overflow: ellipsis;
|
|
5984
|
-
white-space: nowrap;
|
|
5985
|
-
min-width: 0;
|
|
5986
|
-
flex: 1;
|
|
5987
|
-
}
|
|
5988
|
-
|
|
5989
|
-
/* Status badge: smaller on mobile */
|
|
5990
|
-
.status-idle,
|
|
5991
|
-
.status-running,
|
|
5992
|
-
.status-error {
|
|
5993
|
-
font-size: 10px;
|
|
5994
|
-
padding: 1px 6px;
|
|
5995
|
-
flex-shrink: 0;
|
|
5996
|
-
}
|
|
5997
|
-
|
|
5998
5920
|
/* Session info bar: single-line, no hover-expand, font smaller */
|
|
5999
5921
|
#session-info-bar {
|
|
6000
5922
|
padding: 3px 12px;
|
|
@@ -6050,6 +5972,10 @@ body.setup-mode[data-theme="dark"] {
|
|
|
6050
5972
|
.chat-back-btn {
|
|
6051
5973
|
display: flex;
|
|
6052
5974
|
}
|
|
5975
|
+
/* Reserve top space in messages so floating back button doesn't overlap content */
|
|
5976
|
+
#chat-panel #messages {
|
|
5977
|
+
padding-top: 52px;
|
|
5978
|
+
}
|
|
6053
5979
|
|
|
6054
5980
|
/* Welcome page: vertically centered but shifted up, add horizontal padding */
|
|
6055
5981
|
#welcome {
|
data/lib/clacky/web/app.js
CHANGED
|
@@ -437,9 +437,7 @@ WS.onEvent(ev => {
|
|
|
437
437
|
case "session_renamed": {
|
|
438
438
|
Sessions.patch(ev.session_id, { name: ev.name });
|
|
439
439
|
Sessions.renderList();
|
|
440
|
-
|
|
441
|
-
$("chat-title").textContent = ev.name;
|
|
442
|
-
}
|
|
440
|
+
// Title is now shown only in the sidebar; chat-header element was removed.
|
|
443
441
|
break;
|
|
444
442
|
}
|
|
445
443
|
|
|
@@ -847,7 +845,7 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
|
|
|
847
845
|
// On mobile: start with sidebar hidden
|
|
848
846
|
if (_isMobile()) _closeSidebar();
|
|
849
847
|
|
|
850
|
-
// Mobile back button
|
|
848
|
+
// Mobile floating back button (visible only on mobile via CSS): tap to go back to welcome.
|
|
851
849
|
const _btnBack = document.querySelector(".chat-back-btn");
|
|
852
850
|
if (_btnBack) _btnBack.addEventListener("click", () => Router.navigate("welcome"));
|
|
853
851
|
|
|
@@ -985,9 +983,14 @@ document.addEventListener("change", e => {
|
|
|
985
983
|
if ($("theme-toggle-header")) {
|
|
986
984
|
$("theme-toggle-header").addEventListener("click", () => Theme.toggle());
|
|
987
985
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
986
|
+
// btn-delete-session was removed with chat-header; deletion is now triggered from
|
|
987
|
+
// the bottom session-info-bar actions dropdown (see Sessions._showActionsMenu).
|
|
988
|
+
const _btnDeleteSession = $("btn-delete-session");
|
|
989
|
+
if (_btnDeleteSession) {
|
|
990
|
+
_btnDeleteSession.addEventListener("click", () => {
|
|
991
|
+
if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
|
|
992
|
+
});
|
|
993
|
+
}
|
|
991
994
|
|
|
992
995
|
// Load older history when the user scrolls to the top of the message list
|
|
993
996
|
$("messages").addEventListener("scroll", () => {
|
|
@@ -1617,7 +1620,7 @@ window.bootAfterBrand = async function() {
|
|
|
1617
1620
|
dropdown.innerHTML = "";
|
|
1618
1621
|
|
|
1619
1622
|
models.forEach(m => {
|
|
1620
|
-
console.log("[Model Switcher] Adding model:", m.model, "current:", currentModel);
|
|
1623
|
+
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModel);
|
|
1621
1624
|
const opt = document.createElement("div");
|
|
1622
1625
|
opt.className = "sib-model-option";
|
|
1623
1626
|
if (m.model === currentModel) opt.classList.add("current");
|
|
@@ -1633,7 +1636,8 @@ window.bootAfterBrand = async function() {
|
|
|
1633
1636
|
opt.appendChild(badge);
|
|
1634
1637
|
}
|
|
1635
1638
|
|
|
1636
|
-
|
|
1639
|
+
// Switch by id (stable across reorders/edits). Keep model name for UI update.
|
|
1640
|
+
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
1637
1641
|
dropdown.appendChild(opt);
|
|
1638
1642
|
});
|
|
1639
1643
|
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
@@ -1644,7 +1648,9 @@ window.bootAfterBrand = async function() {
|
|
|
1644
1648
|
}
|
|
1645
1649
|
|
|
1646
1650
|
// Switch session model via API
|
|
1647
|
-
|
|
1651
|
+
// modelId — stable runtime id (required by backend)
|
|
1652
|
+
// modelName — display name, used for optimistic UI update
|
|
1653
|
+
async function _switchModel(sessionId, modelId, modelName) {
|
|
1648
1654
|
const dropdown = $("sib-model-dropdown");
|
|
1649
1655
|
if (dropdown) {
|
|
1650
1656
|
dropdown.style.display = "none";
|
|
@@ -1655,20 +1661,20 @@ window.bootAfterBrand = async function() {
|
|
|
1655
1661
|
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
1656
1662
|
method: "PATCH",
|
|
1657
1663
|
headers: { "Content-Type": "application/json" },
|
|
1658
|
-
body: JSON.stringify({
|
|
1664
|
+
body: JSON.stringify({ model_id: modelId })
|
|
1659
1665
|
});
|
|
1660
|
-
|
|
1666
|
+
|
|
1661
1667
|
const data = await res.json();
|
|
1662
|
-
|
|
1668
|
+
|
|
1663
1669
|
if (!res.ok) {
|
|
1664
1670
|
throw new Error(data.error || "Unknown error");
|
|
1665
1671
|
}
|
|
1666
|
-
|
|
1672
|
+
|
|
1667
1673
|
// Update UI optimistically (will be confirmed by session_update broadcast)
|
|
1668
1674
|
const sibModel = $("sib-model");
|
|
1669
|
-
if (sibModel) sibModel.textContent =
|
|
1670
|
-
|
|
1671
|
-
console.log(`Switched session ${sessionId} to model ${
|
|
1675
|
+
if (sibModel) sibModel.textContent = modelName;
|
|
1676
|
+
|
|
1677
|
+
console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
|
|
1672
1678
|
} catch (e) {
|
|
1673
1679
|
console.error("Failed to switch model:", e);
|
|
1674
1680
|
alert("Failed to switch model: " + e.message);
|