chamba 0.5.0 → 0.6.0

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.
@@ -252,6 +252,38 @@ body {
252
252
  }
253
253
  }
254
254
 
255
+ /* ---- A page is waiting in this session's pane ---- */
256
+ /*
257
+ Published while this window was looking at another session. Quieter than the alert above, and about the
258
+ pane rather than the terminal, so it is not the .unread state either: the tab takes the session's colour on
259
+ its border and grows a small page beside its age, and holds that until the session is visited and the page
260
+ opens itself. One flash on arrival, by the same negative-delay trick, since the bar is rebuilt from scratch
261
+ on every frame. PAGE_ARRIVAL_MS in app/tabs.js must match the duration.
262
+ */
263
+ .tab.pages {
264
+ color: var(--text);
265
+ border-color: color-mix(in srgb, var(--sc) 55%, var(--border));
266
+ }
267
+
268
+ .tab .pagemark {
269
+ display: flex;
270
+ align-items: center;
271
+ color: var(--sc);
272
+ }
273
+
274
+ .tab.pages.page-arriving {
275
+ animation: pageFlash 1.4s ease-out 1;
276
+ }
277
+
278
+ @keyframes pageFlash {
279
+ from {
280
+ background: color-mix(in srgb, var(--sc) 34%, #10151c);
281
+ }
282
+ to {
283
+ background: #10151c;
284
+ }
285
+ }
286
+
255
287
  /* Someone who has asked for less movement still gets both states, told without it: the working light holds
256
288
  still on the bottom edge, and the alert simply appears already lit. */
257
289
  @media (prefers-reduced-motion: reduce) {
@@ -270,6 +302,12 @@ body {
270
302
  .tab.attention.arriving {
271
303
  animation: none;
272
304
  }
305
+
306
+ /* The border and the page beside the age say it already; the flash was only ever the part that carried
307
+ across the room. */
308
+ .tab.pages.page-arriving {
309
+ animation: none;
310
+ }
273
311
  }
274
312
 
275
313
  .tab .age {
@@ -599,6 +637,33 @@ body.dragging {
599
637
  background: var(--bg);
600
638
  }
601
639
 
640
+ /* A page arrived and opened itself: one pulse of the workspace colour along the pane's own edge, and nothing
641
+ moves. Inset, so it is drawn over the pane rather than pushing the layout about, and over the frame's edge
642
+ rather than under it. ARRIVAL_MS in app/pane.js must match the duration - it is what takes the class off
643
+ again, since this element, unlike a tab, is not rebuilt from one frame to the next. */
644
+ #pane.arriving {
645
+ animation: paneArrival 1s ease-out 1;
646
+ }
647
+
648
+ @keyframes paneArrival {
649
+ 0%,
650
+ 60% {
651
+ box-shadow: inset 0 0 0 2px var(--ws);
652
+ }
653
+ 100% {
654
+ box-shadow: inset 0 0 0 2px transparent;
655
+ }
656
+ }
657
+
658
+ /* The same moment, held instead of pulsed. Without this the highlight would never appear at all for someone
659
+ who asked for less movement - the class is on the pane for exactly as long either way. */
660
+ @media (prefers-reduced-motion: reduce) {
661
+ #pane.arriving {
662
+ box-shadow: inset 0 0 0 2px var(--ws);
663
+ animation: none;
664
+ }
665
+ }
666
+
602
667
  /* The pagination bar: this session's pages as chips, the arrows for a bar too narrow to hold them, and where
603
668
  in the history the open page sits. Drawn by the interface, outside the page's frame, so it always says the
604
669
  truth about which page is open. */
@@ -696,6 +761,55 @@ body.dragging {
696
761
  font-size: 11.5px;
697
762
  }
698
763
 
764
+ /* How big a page is drawn in this browser. One bordered group - minus, readout, plus - so the pair reads as a
765
+ single control and not as two loose buttons that happen to sit together. */
766
+ #page-size {
767
+ display: flex;
768
+ flex: none;
769
+ align-items: center;
770
+ border: 1px solid var(--border);
771
+ border-radius: 6px;
772
+ background: #1c222b;
773
+ overflow: hidden;
774
+ }
775
+
776
+ #page-size button {
777
+ display: flex;
778
+ width: 24px;
779
+ height: 24px;
780
+ align-items: center;
781
+ justify-content: center;
782
+ border: 0;
783
+ background: transparent;
784
+ color: var(--muted);
785
+ font: inherit;
786
+ font-size: 14px;
787
+ line-height: 1;
788
+ cursor: pointer;
789
+ }
790
+
791
+ #page-size button:hover:not(:disabled) {
792
+ color: var(--text);
793
+ }
794
+
795
+ /* A spent end of the range: the button keeps its place, and says plainly that it has nowhere left to go. */
796
+ #page-size button:disabled {
797
+ opacity: 0.3;
798
+ cursor: default;
799
+ }
800
+
801
+ #page-size-val {
802
+ display: flex;
803
+ height: 24px;
804
+ align-items: center;
805
+ padding: 0 6px;
806
+ border-right: 1px solid var(--border);
807
+ border-left: 1px solid var(--border);
808
+ color: var(--muted);
809
+ font-size: 10.5px;
810
+ letter-spacing: 0.02em;
811
+ }
812
+
699
813
  /* Out of the way, at the end of the bar: the pane is put away far more often than the chips are paged. */
700
814
  #pane-collapse {
701
815
  display: flex;
@@ -712,13 +826,19 @@ body.dragging {
712
826
  }
713
827
 
714
828
  /* The page itself: an agent's document, in a frame with an opaque origin (see app/pane-frame.js). Nothing
715
- here styles its contents - the page brings its own. */
829
+ here styles its contents - the page brings its own.
830
+
831
+ The one exception is how big it is drawn, and it is applied from out here on purpose: the frame's document
832
+ has an opaque origin and the shell never reaches into it. `zoom` rather than a transform, because zoom lays
833
+ the document out again at the new size, so a page reflows instead of growing a horizontal scrollbar. The
834
+ property itself sits on #pane, which survives the frame being torn down and rebuilt on every chip click. */
716
835
  .page-frame {
717
836
  display: block;
718
837
  width: 100%;
719
838
  height: 100%;
720
839
  border: none;
721
840
  background: var(--bg);
841
+ zoom: var(--page-zoom, 1);
722
842
  }
723
843
 
724
844
  /* A page is a document with an event loop of its own, and a pointer that crosses into it during a drag is a
@@ -1045,6 +1165,19 @@ body.offline #tabbar {
1045
1165
  font-weight: 600;
1046
1166
  }
1047
1167
 
1168
+ /* Which window a meter is for - "5h", "7d". Two quota meters side by side are the same shape and the same
1169
+ colours, so the tag is what tells them apart before the percentage is read. */
1170
+ .seg .tag {
1171
+ color: var(--muted);
1172
+ font-size: 10.5px;
1173
+ font-weight: 600;
1174
+ letter-spacing: 0.04em;
1175
+ padding: 1px 4px;
1176
+ border: 1px solid var(--border);
1177
+ border-radius: 4px;
1178
+ background: #12161d;
1179
+ }
1180
+
1048
1181
  .sep {
1049
1182
  color: #3a4250;
1050
1183
  }
@@ -1057,6 +1190,12 @@ body.offline #tabbar {
1057
1190
  background: #262c36;
1058
1191
  }
1059
1192
 
1193
+ /* The quota meters, of which there can be two. They give up the width so the strip stays one line; the context
1194
+ meter keeps the full 74px, because it is the one read constantly. */
1195
+ .bar.narrow {
1196
+ width: 52px;
1197
+ }
1198
+
1060
1199
  .bar .fill {
1061
1200
  display: block;
1062
1201
  height: 100%;
@@ -1088,18 +1227,23 @@ body.offline #tabbar {
1088
1227
  #input {
1089
1228
  display: block;
1090
1229
  width: 100%;
1091
- /* Tall enough by default to show the three stacked buttons (3 x 28px + 2 x 6px gap, plus 8px
1092
- top and bottom insets = 112px), which also gives the box a comfortable typing height. */
1093
- min-height: 112px;
1094
- max-height: 200px;
1230
+ /* Tall enough by default to clear the 2x2 button block (2 x 28px + 6px gap, plus 8px top and bottom
1231
+ insets = 78px), which is about three lines of typing. It grows with what is typed up to seven lines
1232
+ (7 x 21px = 147px, plus the 9px insets and the border = 167px, so 168px holds them with a pixel over)
1233
+ and scrolls past that.
1234
+ These two numbers live here and nowhere else: autoGrow() in composer.js reads them off the box. */
1235
+ min-height: 78px;
1236
+ max-height: 168px;
1095
1237
  resize: none;
1096
1238
  background: #0d1117;
1097
1239
  color: var(--text);
1098
1240
  border: 1px solid var(--border);
1099
1241
  border-radius: 10px;
1100
- /* Right padding reserves the button gutter so typed text wraps to the next line instead of
1101
- sliding behind the buttons (button column is 28px wide at right: 8px, so ~44px clears it). */
1102
- padding: 9px 44px 9px 12px;
1242
+ /* Right padding reserves the button gutter so typed text wraps to the next line instead of sliding
1243
+ behind the buttons (the block is two 28px columns and a 6px gap at right: 8px, so 78px clears it).
1244
+ A textarea's padding is uniform, so this width comes off every line, not only the ones beside the
1245
+ buttons - the cost of the second column, accepted at the mocks gate. */
1246
+ padding: 9px 78px 9px 12px;
1103
1247
  font:
1104
1248
  14px / 1.5 ui-monospace,
1105
1249
  "SF Mono",
@@ -1133,21 +1277,22 @@ body.offline #tabbar {
1133
1277
  opacity: 0.5;
1134
1278
  }
1135
1279
 
1136
- /* Action cluster floats inside the textbox as a vertical column pinned to the right edge. The
1137
- textarea's right padding keeps typed text out from behind it. */
1280
+ /* Action cluster floats inside the textbox as a 2x2 block pinned to the bottom-right corner. The textarea's
1281
+ right padding keeps typed text out from behind it. The block fills row by row, so the order in index.html
1282
+ is the layout: attach and dictate down the left, the pane button above Send on the right - the two buttons
1283
+ that send together, on the side the pane itself is on, with Send in the corner the hand already knows. */
1138
1284
  #composer-actions {
1139
1285
  position: absolute;
1140
1286
  right: 8px;
1141
1287
  bottom: 8px;
1142
- display: flex;
1143
- flex-direction: column;
1144
- align-items: center;
1288
+ display: grid;
1289
+ grid-template-columns: 28px 28px;
1145
1290
  gap: 6px;
1146
1291
  }
1147
1292
 
1148
1293
  /* Semi-transparent so they stay unobtrusive when floating over typed text; solidify on hover. The workspace's
1149
1294
  hue is in them from the start - a faint wash and a tinted edge - so the cluster belongs to the window it
1150
- sits in instead of being three grey squares. */
1295
+ sits in instead of being four grey squares. */
1151
1296
  .icon-btn {
1152
1297
  display: inline-flex;
1153
1298
  align-items: center;
@@ -17,7 +17,15 @@
17
17
 
18
18
  import { readFileSync } from "node:fs";
19
19
  import { basename, dirname } from "node:path";
20
- import { CLAUDE_TRANSCRIPT, CODEX_ROLLOUT, filesNewestFirst } from "./conversation.js";
20
+ import { CLAUDE_TRANSCRIPT, CODEX_ANY_ROLLOUT, filesNewestFirst, hasAnyFile } from "./conversation.js";
21
+
22
+ // How far below a store's root a conversation can sit, per store, because the shapes are not alike and a walk
23
+ // deep enough for the deepest is wasted on the others. codex files rollouts under a date tree
24
+ // (sessions/2026/08/24/); opencode nests everything under storage/session/{info,message,part}/, where part/
25
+ // holds one file per message part and is the reason a bound matters at all; claude's transcripts sit exactly
26
+ // one level down, in a dir named after the directory the session ran in, and anything deeper there is a
27
+ // session's own scratch rather than this workspace's history.
28
+ const STORE_DEPTH = { claudeProjects: 1, codexSessions: 3, opencodeSessions: 2 };
21
29
 
22
30
  // --- Claude ------------------------------------------------------------------------------------------------------------------------------
23
31
 
@@ -42,7 +50,7 @@ export function claudeProjectKey(containerPath) {
42
50
  * the fallback, and for those the dir name is the only evidence there is.
43
51
  */
44
52
  function claudeResumeId(cwd, stores) {
45
- const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT);
53
+ const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT, STORE_DEPTH.claudeProjects);
46
54
  const byRecordedDir = firstResumable(transcripts, cwd);
47
55
  if (byRecordedDir !== null) return byRecordedDir;
48
56
  const key = claudeProjectKey(cwd);
@@ -113,10 +121,11 @@ function transcriptFacts(transcriptPath) {
113
121
  //
114
122
  // These two are asked a shallower question than claude - does the store hold a conversation file at all -
115
123
  // and their own flag picks which one. Reading each store's format to find out whether the newest
116
- // conversation is really resumable would tie chamba to two more internal layouts for no gain.
124
+ // conversation is really resumable would tie chamba to two more internal layouts for no gain. codex is asked
125
+ // with the pattern that also matches a compressed rollout, because a cold history is still a history.
117
126
  export const CONTINUE = {
118
127
  opencode: { argv: ["opencode", "--continue"], store: "opencodeSessions", pattern: /\.json$/ },
119
- codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern: CODEX_ROLLOUT },
128
+ codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern: CODEX_ANY_ROLLOUT },
120
129
  };
121
130
 
122
131
  // --- The one entry point -----------------------------------------------------------------------------------------------------------------
@@ -136,7 +145,7 @@ export function resumeArgvFor(agent, cwd, stores) {
136
145
  }
137
146
  const known = CONTINUE[agent];
138
147
  if (!known) return null;
139
- return filesNewestFirst(stores[known.store], known.pattern).length > 0 ? [...known.argv] : null;
148
+ return hasAnyFile(stores[known.store], known.pattern, STORE_DEPTH[known.store]) ? [...known.argv] : null;
140
149
  } catch {
141
150
  // A store that changed shape under us must never take the session down with it - starting fresh is
142
151
  // a worse session than the user asked for, but it is a session.
@@ -418,7 +418,10 @@ function send(client, frame) {
418
418
  // The session bar, personalised per window: the list is the same for everyone, "which one am I
419
419
  // driving" is not.
420
420
  function broadcastSessions() {
421
- const list = registry.list();
421
+ // Each entry carries one thing the registry does not know: how many pages of that session's pane nobody
422
+ // has opened. The bar is the only frame that reaches a window about a session it is not driving, so this
423
+ // is the whole route by which a page published in a background session marks its tab.
424
+ const list = registry.list().map((entry) => ({ ...entry, unreadPages: pane.unreadCount(entry.id) }));
422
425
  for (const client of clients) {
423
426
  send(client, {
424
427
  t: "sessions",
@@ -495,9 +498,16 @@ const pane = createPane({
495
498
  store: paneStore,
496
499
  stores: AGENT_STORES,
497
500
  sessions: paneSessions,
498
- onChange: (sid) => sendPages(sid),
501
+ onChange: (sid) => paneChanged(sid),
499
502
  });
500
503
 
504
+ // A pane moved. The window driving that session gets the list itself; every window gets the session bar again,
505
+ // because the count of unopened pages rides on it and that is how a tab hears about a page at all.
506
+ function paneChanged(sid) {
507
+ sendPages(sid);
508
+ broadcastSessions();
509
+ }
510
+
501
511
  // The pane as one frame: the list, and the notice when this session is not showing its conversation's own
502
512
  // pages (a second window resumed a conversation another live session already holds).
503
513
  function pagesFrame(sid) {
@@ -1,8 +1,8 @@
1
1
  // snapshot.js - What claude wrote down about a session, and which session it belongs to.
2
2
  //
3
3
  // The status line script runs on every prompt render and writes one small file per conversation: the model,
4
- // how much context it is holding, what is left of the five-hour quota, and the pid of the claude process that
5
- // wrote it. That file is the only place any of this exists - nothing here asks claude anything, and there is
4
+ // how much context it is holding, what is left of each rate-limit window, and the pid of the claude process
5
+ // that wrote it. That file is the only place any of this exists - nothing here asks claude anything, and there is
6
6
  // nothing to ask - so it is what the strip above the composer is drawn from, and what tells the pane which
7
7
  // conversation a session is having.
8
8
  //
@@ -129,6 +129,8 @@ export function statusFor(session, dir) {
129
129
  contextPct: num(snapshot.context_used_pct),
130
130
  quotaPct: num(snapshot.quota_left_pct),
131
131
  quotaResetsAt: num(snapshot.quota_resets_at),
132
+ quotaWeekPct: num(snapshot.quota_week_left_pct),
133
+ quotaWeekResetsAt: num(snapshot.quota_week_resets_at),
132
134
  version: text(snapshot.version),
133
135
  updatedAt: num(snapshot.updated_at),
134
136
  };
@@ -1,53 +0,0 @@
1
- ---
2
- name: context-usage
3
- description: Report the current session's context-window and rate-limit (quota) usage. Use whenever the user asks how much context, tokens, or quota is used or left, how full the context window is, or when the rate limit resets.
4
- ---
5
-
6
- # context-usage: Report context and quota usage
7
-
8
- In a chamba container you CAN see your own context-window and rate-limit usage.
9
- The status line script chamba installs writes a per-session snapshot on every prompt render (it renders nothing in the terminal - the web interface draws a strip from the same file); a bundled helper reads it back.
10
-
11
- ## Procedure
12
-
13
- 1. Run:
14
-
15
- ```bash
16
- context-usage
17
- ```
18
-
19
- Example output:
20
-
21
- ```
22
- session: 78b4025b-... (this session, updated 3s ago)
23
- context: 70.8k tokens (7% of 1M window)
24
- quota: 84% remaining, resets in 21m
25
- model: Fable 5 (effort high)
26
- ```
27
-
28
- 2. Answer in ONE short line, in exactly this shape:
29
-
30
- > Context: 70.8k tokens (7%) - quota: 84% left, resets in 21m.
31
-
32
- If the user asked only about context or only about quota, answer with just that half.
33
- Expand beyond one line only when the user explicitly asks for more detail.
34
- Do not add commentary, interpretation, or advice.
35
-
36
- 3. Caveats are the exception, not the norm.
37
- The `(this session, ...)` marker on the session line means the snapshot is guaranteed to be this session's own - say nothing about it.
38
- Only when the marker is absent AND the output contains a `warning:` line, append one short sentence relaying that warning.
39
-
40
- ## Interpretation notes
41
-
42
- - The snapshot reflects usage as of the moment the current prompt was submitted; tokens consumed during the in-flight turn are not included yet.
43
- No need to mention this unless asked.
44
- - The `quota` line refers to the 5-hour rate-limit window and is absent when Claude Code did not report rate-limit data.
45
-
46
- ## Fallback
47
-
48
- If the `context-usage` command is not found, read the snapshot directly: list `~/.claude/context-usage/*.json`, pick the most recently modified file, and interpret its fields - `context_tokens`, `context_used_pct`, `context_window_size`, `model`, `effort`, `quota_left_pct`, `quota_resets_at` (epoch seconds), `updated_at` (epoch seconds), `version`, `session_id`.
49
-
50
- If the command does not exist AND there are no snapshot files either, the container image predates this feature.
51
- In that case tell the user to rebuild the image: start a new chamba session on the host and accept the rebuild prompt.
52
-
53
- If the command exists but no snapshot files do, no Claude session in this container has rendered a prompt yet; say so rather than guessing values.