claude-office 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e027095a406422081e3db847e481224a96d6e2289983b66cec9076553c3d477
4
- data.tar.gz: 98b602f6c16c80b79d146095500d2b38075685c6d7ed5e270a50bbcfea083ac0
3
+ metadata.gz: 38dc036ecf1211a0ddaf31b55de675406374bc638a15e5dd1d8637563cb12110
4
+ data.tar.gz: da153e1ebe077536daf3e50b6c9161085760d10ac7bff971d843dfb3d60d3bd2
5
5
  SHA512:
6
- metadata.gz: 2f43f8f32940f07f81c368eadcea57c788ec956177571d3e31609e92583b8e85f1a7d15dfb7978a8f6aba6868a4f4061f672a265dd3903777a143ebd41f08ef0
7
- data.tar.gz: c5fa2675865370aebd98520465677c616c858698464a00bc01191aa099bc37e2b6f8cae38b21b168e349996727590ce3475ca15baee6c2ea369bb27b4a5f8abb
6
+ metadata.gz: 98a4909b569b21a10633d4c053e05096ce556c8e419e747de9713a38d1ed9503314d95b6c4a85ff4c42d986897231336d1830b4f5049706fd48fbba9d446c1df
7
+ data.tar.gz: fdc2216861aae2e25f424ae9a5b0d5897206b7d27019da8bdfc57871146e356cb0a18a37a9b70d1adb995b03b60e2d420c8594b8c7f48c1c3e1b5c0561beb679
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # claude-office
2
+
3
+ A TUI companion for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — watch your AI coding sessions come alive as animated kaomoji characters in a virtual terminal office.
4
+
5
+ ```
6
+ ┌─ claude-office ─────────────────────────────────────────────┐
7
+ │ │
8
+ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
9
+ │ ░░ ┌─────┐ ┌─────┐ ┌─────┐ ░░ │
10
+ │ ░░ │ ▒▒▒ │ │ ▒▒▒ │ │ ▒▒▒ │ ░░ │
11
+ │ ░░ └──┬──┘ └──┬──┘ └──┬──┘ ░░ │
12
+ │ ░░ (o.o)~ (o.O) (-.-)zzZ ░░ │
13
+ │ ░░ "Edit app.rb" "Reading tests" ░░ │
14
+ │ ░░ └─ (o_o) ░░ │
15
+ │ ░░ "Sub: search" ░░ │
16
+ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
17
+ │ │
18
+ ├──────────────────────────────────────────────────────────────┤
19
+ │ Agent 1: typing (1 sub) │ Agent 2: reading │ q: quit │
20
+ └──────────────────────────────────────────────────────────────┘
21
+ ```
22
+
23
+ ## What it does
24
+
25
+ claude-office reads Claude Code's JSONL transcript files in real-time and renders an animated office where each Claude session is a kaomoji character sitting at a desk. Characters change expressions based on what tools Claude is using:
26
+
27
+ | Expression | State | Meaning |
28
+ |-----------|-------|---------|
29
+ | `(o_o)` | idle | Waiting between actions |
30
+ | `(o.o)~` | typing | Editing or writing files |
31
+ | `(o.O)` | reading | Reading files, searching code |
32
+ | `(>.<)` | running | Executing bash commands |
33
+ | `(-.-)zzZ` | waiting | Turn ended, needs your input |
34
+
35
+ Sub-agents spawned by the `Task` tool appear indented below their parent with a tree connector.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ gem install claude-office
41
+ ```
42
+
43
+ **Requirements:** Ruby 3.2+
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ # Watch Claude sessions in the current directory
49
+ claude-office
50
+
51
+ # Watch a specific project
52
+ claude-office ~/workspace/my-project
53
+
54
+ # Disable terminal bell notifications
55
+ claude-office --no-sound
56
+ ```
57
+
58
+ Run this in a **separate terminal window** alongside your Claude Code session. The office updates in real-time at 30fps as Claude works.
59
+
60
+ Press `q` to quit.
61
+
62
+ ## How it works
63
+
64
+ 1. Claude Code writes JSONL transcripts to `~/.claude/projects/<project-slug>/`
65
+ 2. claude-office polls that directory for new `.jsonl` files
66
+ 3. Each file becomes an agent (kaomoji character) at a desk
67
+ 4. Tool use events (`Read`, `Edit`, `Bash`, etc.) drive character animations
68
+ 5. Sub-agents from `Task` tool calls appear as child characters
69
+ 6. When a turn ends, the character enters a waiting state with a speech bubble
70
+
71
+ ## Architecture
72
+
73
+ Built with the [Charm Ruby](https://github.com/nicholaides/charm-ruby) ecosystem:
74
+
75
+ - **[bubbletea](https://rubygems.org/gems/bubbletea)** — Elm Architecture TUI framework
76
+ - **[lipgloss](https://rubygems.org/gems/lipgloss)** — Terminal styling and layout
77
+ - **[harmonica](https://rubygems.org/gems/harmonica)** — Spring-based animation physics
78
+
79
+ ### Components
80
+
81
+ | Component | Purpose |
82
+ |-----------|---------|
83
+ | `Transcript::Watcher` | Background thread polling JSONL files |
84
+ | `Transcript::Parser` | Converts JSONL lines into typed events |
85
+ | `Agents::Agent` | State machine: idle / working / waiting |
86
+ | `Agents::SubAgent` | Child agent tracking for Task tool |
87
+ | `Office::Grid` | 2D tile grid with auto-layout |
88
+ | `Office::Pathfinder` | BFS pathfinding for character movement |
89
+ | `Rendering::Renderer` | Composites grid + characters + status bar |
90
+ | `Animation::SpringMover` | Smooth position interpolation |
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ git clone https://github.com/fruizg0302/claude-office.git
96
+ cd claude-office
97
+ bundle install
98
+ bundle exec rspec
99
+ ```
100
+
101
+ ## License
102
+
103
+ [MIT](LICENSE)
@@ -11,75 +11,137 @@ module ClaudeOffice
11
11
  end
12
12
 
13
13
  def render(grid:, agents:, frame:)
14
- title = render_title(grid)
15
- office = render_office(grid, agents, frame)
14
+ # Build a plain-text 2D buffer, then style entire lines
15
+ buffer = build_buffer(grid)
16
+
17
+ # Place desks into buffer
18
+ grid.desks.each do |desk|
19
+ place_desk(buffer, desk)
20
+ end
21
+
22
+ # Place agents into buffer
23
+ agent_labels = []
24
+ agents.each do |agent|
25
+ place_agent(buffer, agent, frame, agent_labels)
26
+ end
27
+
28
+ # Render buffer to styled string
29
+ title = render_title
30
+ office = render_buffer(buffer)
31
+ labels = render_labels(agent_labels)
16
32
  status = render_status_bar(agents)
17
33
 
18
- Lipgloss.join_vertical(:left, title, office, status)
34
+ parts = [title, office]
35
+ parts << labels unless labels.empty?
36
+ parts << status
37
+
38
+ Lipgloss.join_vertical(:left, *parts)
19
39
  end
20
40
 
21
41
  private
22
42
 
23
- def render_title(grid)
24
- Theme::TITLE_STYLE
25
- .width(@width)
26
- .render("claude-office")
43
+ def build_buffer(grid)
44
+ Array.new(grid.height) do |y|
45
+ Array.new(grid.width) do |x|
46
+ case grid.tiles[y][x]
47
+ when :wall then { char: Sprites::WALL_CHAR, type: :wall }
48
+ else { char: Sprites::FLOOR_CHAR, type: :floor }
49
+ end
50
+ end
51
+ end
27
52
  end
28
53
 
29
- def render_office(grid, agents, frame)
30
- lines = []
54
+ def place_desk(buffer, desk)
55
+ dx, dy = desk.position
56
+ Sprites::DESK.each_with_index do |row, row_i|
57
+ row.chars.each_with_index do |ch, col_i|
58
+ bx = dx + col_i
59
+ by = dy + row_i
60
+ next if by < 0 || by >= buffer.length
61
+ next if bx < 0 || bx >= buffer[0].length
31
62
 
32
- grid.height.times do |y|
33
- line = ""
34
- grid.width.times do |x|
35
- case grid.tiles[y][x]
36
- when :wall
37
- line += Theme::WALL_STYLE.render(Sprites::WALL_CHAR)
38
- else
39
- line += Theme::FLOOR_STYLE.render(Sprites::FLOOR_CHAR)
40
- end
63
+ buffer[by][bx] = { char: ch, type: :desk }
41
64
  end
42
- lines << line
43
65
  end
66
+ end
44
67
 
45
- office_str = lines.join("\n")
68
+ def place_agent(buffer, agent, frame, agent_labels)
69
+ pos = agent.desk_position
70
+ face = Sprites.face_for(agent.animation, frame: frame)
71
+ char_x = pos[0] + 1
72
+ char_y = pos[1] + Sprites::DESK.length + 1
46
73
 
47
- grid.desks.each do |desk|
48
- desk_str = Sprites::DESK.map { |row| Theme::DESK_STYLE.render(row) }.join("\n")
49
- office_str = overlay(office_str, desk_str, desk.position[0], desk.position[1])
74
+ # Place face characters into buffer
75
+ face.chars.each_with_index do |ch, i|
76
+ bx = char_x + i
77
+ next if char_y < 0 || char_y >= buffer.length
78
+ next if bx < 0 || bx >= buffer[0].length
79
+
80
+ buffer[char_y][bx] = { char: ch, type: :agent, state: agent.state }
50
81
  end
51
82
 
52
- agents.each do |agent|
53
- face = Sprites.face_for(agent.animation, frame: frame)
54
- style = Theme.agent_style(agent.state)
55
- agent_str = style.render(face)
83
+ # Collect label info for rendering below the grid
84
+ label_parts = []
85
+ label_parts << agent.status_text unless agent.status_text.empty?
56
86
 
57
- unless agent.status_text.empty?
58
- status = Theme::STATUS_TEXT_STYLE.render("\"#{agent.status_text}\"")
59
- agent_str = Lipgloss.join_vertical(:center, agent_str, status)
60
- end
87
+ agent.sub_agents.each_value do |sub|
88
+ sub_face = Sprites.sub_face_for(sub.animation)
89
+ sub_text = " └─ #{sub_face}"
90
+ sub_text += " \"#{sub.status_text}\"" unless sub.status_text.empty?
91
+ label_parts << sub_text
92
+ end
61
93
 
62
- agent.sub_agents.each_value do |sub|
63
- sub_face = Sprites.sub_face_for(sub.animation)
64
- sub_line = Theme::SUB_AGENT_STYLE.render("└─ #{sub_face}")
65
- unless sub.status_text.empty?
66
- sub_line += " " + Theme::STATUS_TEXT_STYLE.render("\"#{sub.status_text}\"")
94
+ if agent.state == :waiting
95
+ label_parts.unshift("Needs input!")
96
+ end
97
+
98
+ unless label_parts.empty?
99
+ agent_labels << { state: agent.state, parts: label_parts }
100
+ end
101
+ end
102
+
103
+ def render_title
104
+ Theme::TITLE_STYLE
105
+ .width(@width)
106
+ .render("claude-office")
107
+ end
108
+
109
+ def render_buffer(buffer)
110
+ lines = buffer.map do |row|
111
+ row.map do |cell|
112
+ case cell[:type]
113
+ when :wall
114
+ Theme::WALL_STYLE.render(cell[:char])
115
+ when :desk
116
+ Theme::DESK_STYLE.render(cell[:char])
117
+ when :agent
118
+ Theme.agent_style(cell[:state]).render(cell[:char])
119
+ else
120
+ Theme::FLOOR_STYLE.render(cell[:char])
67
121
  end
68
- agent_str = Lipgloss.join_vertical(:left, agent_str, sub_line)
69
- end
122
+ end.join
123
+ end
70
124
 
71
- if agent.state == :waiting
72
- bubble = Theme::SPEECH_BUBBLE_STYLE.render("Needs input!")
73
- agent_str = Lipgloss.join_vertical(:center, bubble, agent_str)
74
- end
125
+ lines.join("\n")
126
+ end
127
+
128
+ def render_labels(agent_labels)
129
+ return "" if agent_labels.empty?
75
130
 
76
- pos = agent.desk_position
77
- char_x = pos[0] + 1
78
- char_y = pos[1] + Sprites::DESK.length + 1
79
- office_str = overlay(office_str, agent_str, char_x, char_y)
131
+ lines = agent_labels.map do |info|
132
+ style = Theme.agent_style(info[:state])
133
+ info[:parts].map do |part|
134
+ if part.start_with?(" └─")
135
+ Theme::SUB_AGENT_STYLE.render(part)
136
+ elsif part == "Needs input!"
137
+ Theme::WAITING_AGENT_STYLE.render("⚡ #{part}")
138
+ else
139
+ Theme::STATUS_TEXT_STYLE.render(" \"#{part}\"")
140
+ end
141
+ end.join("\n")
80
142
  end
81
143
 
82
- office_str
144
+ lines.join("\n")
83
145
  end
84
146
 
85
147
  def render_status_bar(agents)
@@ -97,26 +159,6 @@ module ClaudeOffice
97
159
  .width(@width)
98
160
  .render(bar_text)
99
161
  end
100
-
101
- def overlay(base, overlay_str, x, y)
102
- base_lines = base.split("\n")
103
- overlay_lines = overlay_str.split("\n")
104
-
105
- overlay_lines.each_with_index do |oline, i|
106
- target_y = y + i
107
- next if target_y < 0 || target_y >= base_lines.length
108
-
109
- base_line = base_lines[target_y]
110
- visible_len = Lipgloss.width(oline)
111
-
112
- before = base_line[0...x] || ""
113
- after_start = x + visible_len
114
- after = base_line[after_start..] || ""
115
- base_lines[target_y] = before + oline + after
116
- end
117
-
118
- base_lines.join("\n")
119
- end
120
162
  end
121
163
  end
122
164
  end
@@ -1,3 +1,3 @@
1
1
  module ClaudeOffice
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-office
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fernando Ruiz
@@ -90,6 +90,7 @@ extensions: []
90
90
  extra_rdoc_files: []
91
91
  files:
92
92
  - LICENSE
93
+ - README.md
93
94
  - bin/claude-office
94
95
  - lib/claude_office.rb
95
96
  - lib/claude_office/agents/agent.rb